Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 13, 05:30 UTC
caddyhttp: Determine real client IP if trusted proxies configured (#5104)
* caddyhttp: Determine real client IP if trusted proxies configured * Support customizing client IP header * Implement client_ip matcher, deprecate remote_ip's forwarded option
Francis Lavoie committed
Mar 27, 2023 at 16:22 UTC
05e9974570a08df14b1162a1e98315d4ee9ec2ee
9 files changed
+462
-187
caddyconfig/httpcaddyfile/httptype.go
+1
@@ -1328,6 +1328,7 @@ func placeholderShorthands() []string {
1328
"{tls_client_certificate_pem}", "{http.request.tls.client.certificate_pem}",
1329
"{tls_client_certificate_der_base64}", "{http.request.tls.client.certificate_der_base64}",
1330
"{upstream_hostport}", "{http.reverse_proxy.upstream.hostport}",
1331
+ "{client_ip}", "{http.vars.client_ip}",
1332
}
1333
}
1334
caddyconfig/httpcaddyfile/serveroptions.go
+14
@@ -44,6 +44,7 @@ type serverOptions struct {
44
Protocols []string
45
StrictSNIHost *bool
46
TrustedProxiesRaw json.RawMessage
47
+ ClientIPHeaders []string
48
ShouldLogCredentials bool
49
Metrics *caddyhttp.Metrics
50
}
@@ -208,6 +209,18 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
209
)
210
serverOpts.TrustedProxiesRaw = jsonSource
211
212
+ case "client_ip_headers":
213
+ headers := d.RemainingArgs()
214
+ for _, header := range headers {
215
+ if sliceContains(serverOpts.ClientIPHeaders, header) {
216
+ return nil, d.Errf("client IP header %s specified more than once", header)
217
+ }
218
+ serverOpts.ClientIPHeaders = append(serverOpts.ClientIPHeaders, header)
219
+ }
220
+ if nesting := d.Nesting(); d.NextBlock(nesting) {
221
+ return nil, d.ArgErr()
222
+ }
223
+
224
case "metrics":
225
if d.NextArg() {
226
return nil, d.ArgErr()
@@ -317,6 +330,7 @@ func applyServerOptions(
330
server.Protocols = opts.Protocols
331
server.StrictSNIHost = opts.StrictSNIHost
332
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
333
+ server.ClientIPHeaders = opts.ClientIPHeaders
334
server.Metrics = opts.Metrics
335
if opts.ShouldLogCredentials {
336
if server.Logs == nil {
caddytest/integration/caddyfile_adapt/global_server_options_single.txt
+7
@@ -15,6 +15,8 @@
15
protocols h1 h2 h2c h3
16
strict_sni_host
17
trusted_proxies static private_ranges
18
+ client_ip_headers Custom-Real-Client-IP X-Forwarded-For
19
+ client_ip_headers A-Third-One
20
}
21
}
22
@@ -67,6 +69,11 @@ foo.com {
69
],
70
"source": "static"
71
},
72
+ "client_ip_headers": [
73
+ "Custom-Real-Client-IP",
74
+ "X-Forwarded-For",
75
+ "A-Third-One"
76
+ ],
77
"logs": {
78
"should_log_credentials": true
79
},
caddytest/integration/caddyfile_adapt/matcher_syntax.txt
+25
@@ -43,6 +43,9 @@
43
44
@matcher11 remote_ip private_ranges
45
respond @matcher11 "remote_ip matcher with private ranges"
46
+
47
+ @matcher12 client_ip private_ranges
48
+ respond @matcher12 "client_ip matcher with private ranges"
49
}
50
----------
51
{
@@ -250,6 +253,28 @@
253
"handler": "static_response"
254
}
255
]
256
+ },
257
+ {
258
+ "match": [
259
+ {
260
+ "client_ip": {
261
+ "ranges": [
262
+ "192.168.0.0/16",
263
+ "172.16.0.0/12",
264
+ "10.0.0.0/8",
265
+ "127.0.0.1/8",
266
+ "fd00::/8",
267
+ "::1"
268
+ ]
269
+ }
270
+ }
271
+ ],
272
+ "handle": [
273
+ {
274
+ "body": "client_ip matcher with private ranges",
275
+ "handler": "static_response"
276
+ }
277
+ ]
278
}
279
]
280
}
modules/caddyhttp/app.go
+5
@@ -232,6 +232,11 @@ func (app *App) Provision(ctx caddy.Context) error {
232
srv.trustedProxies = val.(IPRangeSource)
233
}
234
235
+ // set the default client IP header to read from
236
+ if srv.ClientIPHeaders == nil {
237
+ srv.ClientIPHeaders = []string{"X-Forwarded-For"}
238
+ }
239
+
240
// process each listener address
241
for i := range srv.Listen {
242
lnOut, err := repl.ReplaceOrErr(srv.Listen[i], true, true)
modules/caddyhttp/ip_matchers.go
new
+344
@@ -0,0 +1,344 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package caddyhttp
16
+
17
+import (
18
+ "errors"
19
+ "fmt"
20
+ "net"
21
+ "net/http"
22
+ "net/netip"
23
+ "reflect"
24
+ "strings"
25
+
26
+ "github.com/caddyserver/caddy/v2"
27
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
28
+ "github.com/google/cel-go/cel"
29
+ "github.com/google/cel-go/common/types/ref"
30
+ "go.uber.org/zap"
31
+)
32
+
33
+// MatchRemoteIP matches requests by the remote IP address,
34
+// i.e. the IP address of the direct connection to Caddy.
35
+type MatchRemoteIP struct {
36
+ // The IPs or CIDR ranges to match.
37
+ Ranges []string `json:"ranges,omitempty"`
38
+
39
+ // If true, prefer the first IP in the request's X-Forwarded-For
40
+ // header, if present, rather than the immediate peer's IP, as
41
+ // the reference IP against which to match. Note that it is easy
42
+ // to spoof request headers. Default: false
43
+ // DEPRECATED: This is insecure, MatchClientIP should be used instead.
44
+ Forwarded bool `json:"forwarded,omitempty"`
45
+
46
+ // cidrs and zones vars should aligned always in the same
47
+ // length and indexes for matching later
48
+ cidrs []*netip.Prefix
49
+ zones []string
50
+ logger *zap.Logger
51
+}
52
+
53
+// MatchClientIP matches requests by the client IP address,
54
+// i.e. the resolved address, considering trusted proxies.
55
+type MatchClientIP struct {
56
+ // The IPs or CIDR ranges to match.
57
+ Ranges []string `json:"ranges,omitempty"`
58
+
59
+ // cidrs and zones vars should aligned always in the same
60
+ // length and indexes for matching later
61
+ cidrs []*netip.Prefix
62
+ zones []string
63
+ logger *zap.Logger
64
+}
65
+
66
+func init() {
67
+ caddy.RegisterModule(MatchRemoteIP{})
68
+ caddy.RegisterModule(MatchClientIP{})
69
+}
70
+
71
+// CaddyModule returns the Caddy module information.
72
+func (MatchRemoteIP) CaddyModule() caddy.ModuleInfo {
73
+ return caddy.ModuleInfo{
74
+ ID: "http.matchers.remote_ip",
75
+ New: func() caddy.Module { return new(MatchRemoteIP) },
76
+ }
77
+}
78
+
79
+// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
80
+func (m *MatchRemoteIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
81
+ for d.Next() {
82
+ for d.NextArg() {
83
+ if d.Val() == "forwarded" {
84
+ if len(m.Ranges) > 0 {
85
+ return d.Err("if used, 'forwarded' must be first argument")
86
+ }
87
+ m.Forwarded = true
88
+ continue
89
+ }
90
+ if d.Val() == "private_ranges" {
91
+ m.Ranges = append(m.Ranges, PrivateRangesCIDR()...)
92
+ continue
93
+ }
94
+ m.Ranges = append(m.Ranges, d.Val())
95
+ }
96
+ if d.NextBlock(0) {
97
+ return d.Err("malformed remote_ip matcher: blocks are not supported")
98
+ }
99
+ }
100
+ return nil
101
+}
102
+
103
+// CELLibrary produces options that expose this matcher for use in CEL
104
+// expression matchers.
105
+//
106
+// Example:
107
+//
108
+// expression remote_ip('forwarded', '192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
109
+func (MatchRemoteIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
110
+ return CELMatcherImpl(
111
+ // name of the macro, this is the function name that users see when writing expressions.
112
+ "remote_ip",
113
+ // name of the function that the macro will be rewritten to call.
114
+ "remote_ip_match_request_list",
115
+ // internal data type of the MatchPath value.
116
+ []*cel.Type{cel.ListType(cel.StringType)},
117
+ // function to convert a constant list of strings to a MatchPath instance.
118
+ func(data ref.Val) (RequestMatcher, error) {
119
+ refStringList := reflect.TypeOf([]string{})
120
+ strList, err := data.ConvertToNative(refStringList)
121
+ if err != nil {
122
+ return nil, err
123
+ }
124
+
125
+ m := MatchRemoteIP{}
126
+
127
+ for _, input := range strList.([]string) {
128
+ if input == "forwarded" {
129
+ if len(m.Ranges) > 0 {
130
+ return nil, errors.New("if used, 'forwarded' must be first argument")
131
+ }
132
+ m.Forwarded = true
133
+ continue
134
+ }
135
+ m.Ranges = append(m.Ranges, input)
136
+ }
137
+
138
+ err = m.Provision(ctx)
139
+ return m, err
140
+ },
141
+ )
142
+}
143
+
144
+// Provision parses m's IP ranges, either from IP or CIDR expressions.
145
+func (m *MatchRemoteIP) Provision(ctx caddy.Context) error {
146
+ m.logger = ctx.Logger()
147
+ cidrs, zones, err := provisionCidrsZonesFromRanges(m.Ranges)
148
+ if err != nil {
149
+ return err
150
+ }
151
+ m.cidrs = cidrs
152
+ m.zones = zones
153
+
154
+ if m.Forwarded {
155
+ m.logger.Warn("remote_ip's forwarded mode is deprecated; use the 'client_ip' matcher instead")
156
+ }
157
+
158
+ return nil
159
+}
160
+
161
+// Match returns true if r matches m.
162
+func (m MatchRemoteIP) Match(r *http.Request) bool {
163
+ address := r.RemoteAddr
164
+ if m.Forwarded {
165
+ if fwdFor := r.Header.Get("X-Forwarded-For"); fwdFor != "" {
166
+ address = strings.TrimSpace(strings.Split(fwdFor, ",")[0])
167
+ }
168
+ }
169
+ clientIP, zoneID, err := parseIPZoneFromString(address)
170
+ if err != nil {
171
+ m.logger.Error("getting remote IP", zap.Error(err))
172
+ return false
173
+ }
174
+ matches, zoneFilter := matchIPByCidrZones(clientIP, zoneID, m.cidrs, m.zones)
175
+ if !matches && !zoneFilter {
176
+ m.logger.Debug("zone ID from remote IP did not match", zap.String("zone", zoneID))
177
+ }
178
+ return matches
179
+}
180
+
181
+// CaddyModule returns the Caddy module information.
182
+func (MatchClientIP) CaddyModule() caddy.ModuleInfo {
183
+ return caddy.ModuleInfo{
184
+ ID: "http.matchers.client_ip",
185
+ New: func() caddy.Module { return new(MatchClientIP) },
186
+ }
187
+}
188
+
189
+// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
190
+func (m *MatchClientIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
191
+ for d.Next() {
192
+ for d.NextArg() {
193
+ if d.Val() == "private_ranges" {
194
+ m.Ranges = append(m.Ranges, PrivateRangesCIDR()...)
195
+ continue
196
+ }
197
+ m.Ranges = append(m.Ranges, d.Val())
198
+ }
199
+ if d.NextBlock(0) {
200
+ return d.Err("malformed client_ip matcher: blocks are not supported")
201
+ }
202
+ }
203
+ return nil
204
+}
205
+
206
+// CELLibrary produces options that expose this matcher for use in CEL
207
+// expression matchers.
208
+//
209
+// Example:
210
+//
211
+// expression client_ip('192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
212
+func (MatchClientIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
213
+ return CELMatcherImpl(
214
+ // name of the macro, this is the function name that users see when writing expressions.
215
+ "client_ip",
216
+ // name of the function that the macro will be rewritten to call.
217
+ "client_ip_match_request_list",
218
+ // internal data type of the MatchPath value.
219
+ []*cel.Type{cel.ListType(cel.StringType)},
220
+ // function to convert a constant list of strings to a MatchPath instance.
221
+ func(data ref.Val) (RequestMatcher, error) {
222
+ refStringList := reflect.TypeOf([]string{})
223
+ strList, err := data.ConvertToNative(refStringList)
224
+ if err != nil {
225
+ return nil, err
226
+ }
227
+
228
+ m := MatchClientIP{
229
+ Ranges: strList.([]string),
230
+ }
231
+
232
+ err = m.Provision(ctx)
233
+ return m, err
234
+ },
235
+ )
236
+}
237
+
238
+// Provision parses m's IP ranges, either from IP or CIDR expressions.
239
+func (m *MatchClientIP) Provision(ctx caddy.Context) error {
240
+ m.logger = ctx.Logger()
241
+ cidrs, zones, err := provisionCidrsZonesFromRanges(m.Ranges)
242
+ if err != nil {
243
+ return err
244
+ }
245
+ m.cidrs = cidrs
246
+ m.zones = zones
247
+ return nil
248
+}
249
+
250
+// Match returns true if r matches m.
251
+func (m MatchClientIP) Match(r *http.Request) bool {
252
+ address := GetVar(r.Context(), ClientIPVarKey).(string)
253
+ clientIP, zoneID, err := parseIPZoneFromString(address)
254
+ if err != nil {
255
+ m.logger.Error("getting client IP", zap.Error(err))
256
+ return false
257
+ }
258
+ matches, zoneFilter := matchIPByCidrZones(clientIP, zoneID, m.cidrs, m.zones)
259
+ if !matches && !zoneFilter {
260
+ m.logger.Debug("zone ID from client IP did not match", zap.String("zone", zoneID))
261
+ }
262
+ return matches
263
+}
264
+
265
+func provisionCidrsZonesFromRanges(ranges []string) ([]*netip.Prefix, []string, error) {
266
+ cidrs := []*netip.Prefix{}
267
+ zones := []string{}
268
+ for _, str := range ranges {
269
+ // Exclude the zone_id from the IP
270
+ if strings.Contains(str, "%") {
271
+ split := strings.Split(str, "%")
272
+ str = split[0]
273
+ // write zone identifiers in m.zones for matching later
274
+ zones = append(zones, split[1])
275
+ } else {
276
+ zones = append(zones, "")
277
+ }
278
+ if strings.Contains(str, "/") {
279
+ ipNet, err := netip.ParsePrefix(str)
280
+ if err != nil {
281
+ return nil, nil, fmt.Errorf("parsing CIDR expression '%s': %v", str, err)
282
+ }
283
+ cidrs = append(cidrs, &ipNet)
284
+ } else {
285
+ ipAddr, err := netip.ParseAddr(str)
286
+ if err != nil {
287
+ return nil, nil, fmt.Errorf("invalid IP address: '%s': %v", str, err)
288
+ }
289
+ ipNew := netip.PrefixFrom(ipAddr, ipAddr.BitLen())
290
+ cidrs = append(cidrs, &ipNew)
291
+ }
292
+ }
293
+ return cidrs, zones, nil
294
+}
295
+
296
+func parseIPZoneFromString(address string) (netip.Addr, string, error) {
297
+ ipStr, _, err := net.SplitHostPort(address)
298
+ if err != nil {
299
+ ipStr = address // OK; probably didn't have a port
300
+ }
301
+
302
+ // Some IPv6-Adresses can contain zone identifiers at the end,
303
+ // which are separated with "%"
304
+ zoneID := ""
305
+ if strings.Contains(ipStr, "%") {
306
+ split := strings.Split(ipStr, "%")
307
+ ipStr = split[0]
308
+ zoneID = split[1]
309
+ }
310
+
311
+ ipAddr, err := netip.ParseAddr(ipStr)
312
+ if err != nil {
313
+ return netip.IPv4Unspecified(), "", err
314
+ }
315
+
316
+ return ipAddr, zoneID, nil
317
+}
318
+
319
+func matchIPByCidrZones(clientIP netip.Addr, zoneID string, cidrs []*netip.Prefix, zones []string) (bool, bool) {
320
+ zoneFilter := true
321
+ for i, ipRange := range cidrs {
322
+ if ipRange.Contains(clientIP) {
323
+ // Check if there are zone filters assigned and if they match.
324
+ if zones[i] == "" || zoneID == zones[i] {
325
+ return true, false
326
+ }
327
+ zoneFilter = false
328
+ }
329
+ }
330
+ return false, zoneFilter
331
+}
332
+
333
+// Interface guards
334
+var (
335
+ _ RequestMatcher = (*MatchRemoteIP)(nil)
336
+ _ caddy.Provisioner = (*MatchRemoteIP)(nil)
337
+ _ caddyfile.Unmarshaler = (*MatchRemoteIP)(nil)
338
+ _ CELLibraryProducer = (*MatchRemoteIP)(nil)
339
+
340
+ _ RequestMatcher = (*MatchClientIP)(nil)
341
+ _ caddy.Provisioner = (*MatchClientIP)(nil)
342
+ _ caddyfile.Unmarshaler = (*MatchClientIP)(nil)
343
+ _ CELLibraryProducer = (*MatchClientIP)(nil)
344
+)
modules/caddyhttp/marshalers.go
+1
@@ -40,6 +40,7 @@ func (r LoggableHTTPRequest) MarshalLogObject(enc zapcore.ObjectEncoder) error {
40
41
enc.AddString("remote_ip", ip)
42
enc.AddString("remote_port", port)
43
+ enc.AddString("client_ip", GetVar(r.Context(), ClientIPVarKey).(string))
44
enc.AddString("proto", r.Proto)
45
enc.AddString("method", r.Method)
46
enc.AddString("host", r.Host)
modules/caddyhttp/matchers.go
-178
@@ -20,7 +20,6 @@ import (
20
"fmt"
21
"net"
22
"net/http"
23
- "net/netip"
23
"net/textproto"
24
"net/url"
25
"path"
@@ -35,7 +34,6 @@ import (
34
"github.com/google/cel-go/cel"
35
"github.com/google/cel-go/common/types"
36
"github.com/google/cel-go/common/types/ref"
38
- "go.uber.org/zap"
37
)
38
39
type (
@@ -176,24 +174,6 @@ type (
174
// "http/2", "http/3", or minimum versions: "http/2+", etc.
175
MatchProtocol string
176
179
- // MatchRemoteIP matches requests by client IP (or CIDR range).
180
- MatchRemoteIP struct {
181
- // The IPs or CIDR ranges to match.
182
- Ranges []string `json:"ranges,omitempty"`
183
-
184
- // If true, prefer the first IP in the request's X-Forwarded-For
185
- // header, if present, rather than the immediate peer's IP, as
186
- // the reference IP against which to match. Note that it is easy
187
- // to spoof request headers. Default: false
188
- Forwarded bool `json:"forwarded,omitempty"`
189
-
190
- // cidrs and zones vars should aligned always in the same
191
- // length and indexes for matching later
192
- cidrs []*netip.Prefix
193
- zones []string
194
- logger *zap.Logger
195
- }
196
-
177
// MatchNot matches requests by negating the results of its matcher
178
// sets. A single "not" matcher takes one or more matcher sets. Each
179
// matcher set is OR'ed; in other words, if any matcher set returns
@@ -229,7 +209,6 @@ func init() {
209
caddy.RegisterModule(MatchHeader{})
210
caddy.RegisterModule(MatchHeaderRE{})
211
caddy.RegisterModule(new(MatchProtocol))
232
- caddy.RegisterModule(MatchRemoteIP{})
212
caddy.RegisterModule(MatchNot{})
213
}
214
@@ -1261,159 +1240,6 @@ func (m MatchNot) Match(r *http.Request) bool {
1240
return true
1241
}
1242
1264
-// CaddyModule returns the Caddy module information.
1265
-func (MatchRemoteIP) CaddyModule() caddy.ModuleInfo {
1266
- return caddy.ModuleInfo{
1267
- ID: "http.matchers.remote_ip",
1268
- New: func() caddy.Module { return new(MatchRemoteIP) },
1269
- }
1270
-}
1271
-
1272
-// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
1273
-func (m *MatchRemoteIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
1274
- for d.Next() {
1275
- for d.NextArg() {
1276
- if d.Val() == "forwarded" {
1277
- if len(m.Ranges) > 0 {
1278
- return d.Err("if used, 'forwarded' must be first argument")
1279
- }
1280
- m.Forwarded = true
1281
- continue
1282
- }
1283
- if d.Val() == "private_ranges" {
1284
- m.Ranges = append(m.Ranges, PrivateRangesCIDR()...)
1285
- continue
1286
- }
1287
- m.Ranges = append(m.Ranges, d.Val())
1288
- }
1289
- if d.NextBlock(0) {
1290
- return d.Err("malformed remote_ip matcher: blocks are not supported")
1291
- }
1292
- }
1293
- return nil
1294
-}
1295
-
1296
-// CELLibrary produces options that expose this matcher for use in CEL
1297
-// expression matchers.
1298
-//
1299
-// Example:
1300
-//
1301
-// expression remote_ip('forwarded', '192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
1302
-func (MatchRemoteIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
1303
- return CELMatcherImpl(
1304
- // name of the macro, this is the function name that users see when writing expressions.
1305
- "remote_ip",
1306
- // name of the function that the macro will be rewritten to call.
1307
- "remote_ip_match_request_list",
1308
- // internal data type of the MatchPath value.
1309
- []*cel.Type{cel.ListType(cel.StringType)},
1310
- // function to convert a constant list of strings to a MatchPath instance.
1311
- func(data ref.Val) (RequestMatcher, error) {
1312
- refStringList := reflect.TypeOf([]string{})
1313
- strList, err := data.ConvertToNative(refStringList)
1314
- if err != nil {
1315
- return nil, err
1316
- }
1317
-
1318
- m := MatchRemoteIP{}
1319
-
1320
- for _, input := range strList.([]string) {
1321
- if input == "forwarded" {
1322
- if len(m.Ranges) > 0 {
1323
- return nil, errors.New("if used, 'forwarded' must be first argument")
1324
- }
1325
- m.Forwarded = true
1326
- continue
1327
- }
1328
- m.Ranges = append(m.Ranges, input)
1329
- }
1330
-
1331
- err = m.Provision(ctx)
1332
- return m, err
1333
- },
1334
- )
1335
-}
1336
-
1337
-// Provision parses m's IP ranges, either from IP or CIDR expressions.
1338
-func (m *MatchRemoteIP) Provision(ctx caddy.Context) error {
1339
- m.logger = ctx.Logger()
1340
- for _, str := range m.Ranges {
1341
- // Exclude the zone_id from the IP
1342
- if strings.Contains(str, "%") {
1343
- split := strings.Split(str, "%")
1344
- str = split[0]
1345
- // write zone identifiers in m.zones for matching later
1346
- m.zones = append(m.zones, split[1])
1347
- } else {
1348
- m.zones = append(m.zones, "")
1349
- }
1350
- if strings.Contains(str, "/") {
1351
- ipNet, err := netip.ParsePrefix(str)
1352
- if err != nil {
1353
- return fmt.Errorf("parsing CIDR expression '%s': %v", str, err)
1354
- }
1355
- m.cidrs = append(m.cidrs, &ipNet)
1356
- } else {
1357
- ipAddr, err := netip.ParseAddr(str)
1358
- if err != nil {
1359
- return fmt.Errorf("invalid IP address: '%s': %v", str, err)
1360
- }
1361
- ipNew := netip.PrefixFrom(ipAddr, ipAddr.BitLen())
1362
- m.cidrs = append(m.cidrs, &ipNew)
1363
- }
1364
- }
1365
- return nil
1366
-}
1367
-
1368
-func (m MatchRemoteIP) getClientIP(r *http.Request) (netip.Addr, string, error) {
1369
- remote := r.RemoteAddr
1370
- zoneID := ""
1371
- if m.Forwarded {
1372
- if fwdFor := r.Header.Get("X-Forwarded-For"); fwdFor != "" {
1373
- remote = strings.TrimSpace(strings.Split(fwdFor, ",")[0])
1374
- }
1375
- }
1376
- ipStr, _, err := net.SplitHostPort(remote)
1377
- if err != nil {
1378
- ipStr = remote // OK; probably didn't have a port
1379
- }
1380
- // Some IPv6-Adresses can contain zone identifiers at the end,
1381
- // which are separated with "%"
1382
- if strings.Contains(ipStr, "%") {
1383
- split := strings.Split(ipStr, "%")
1384
- ipStr = split[0]
1385
- zoneID = split[1]
1386
- }
1387
- ipAddr, err := netip.ParseAddr(ipStr)
1388
- if err != nil {
1389
- return netip.IPv4Unspecified(), "", err
1390
- }
1391
- return ipAddr, zoneID, nil
1392
-}
1393
-
1394
-// Match returns true if r matches m.
1395
-func (m MatchRemoteIP) Match(r *http.Request) bool {
1396
- clientIP, zoneID, err := m.getClientIP(r)
1397
- if err != nil {
1398
- m.logger.Error("getting client IP", zap.Error(err))
1399
- return false
1400
- }
1401
- zoneFilter := true
1402
- for i, ipRange := range m.cidrs {
1403
- if ipRange.Contains(clientIP) {
1404
- // Check if there are zone filters assigned and if they match.
1405
- if m.zones[i] == "" || zoneID == m.zones[i] {
1406
- return true
1407
- }
1408
- zoneFilter = false
1409
- }
1410
- }
1411
- if !zoneFilter {
1412
- m.logger.Debug("zone ID from remote did not match", zap.String("zone", zoneID))
1413
- }
1414
- return false
1415
-}
1416
-
1243
// MatchRegexp is an embedable type for matching
1244
// using regular expressions. It adds placeholders
1245
// to the request's replacer.
@@ -1588,8 +1414,6 @@ var (
1414
_ RequestMatcher = (*MatchHeaderRE)(nil)
1415
_ caddy.Provisioner = (*MatchHeaderRE)(nil)
1416
_ RequestMatcher = (*MatchProtocol)(nil)
1591
- _ RequestMatcher = (*MatchRemoteIP)(nil)
1592
- _ caddy.Provisioner = (*MatchRemoteIP)(nil)
1417
_ RequestMatcher = (*MatchNot)(nil)
1418
_ caddy.Provisioner = (*MatchNot)(nil)
1419
_ caddy.Provisioner = (*MatchRegexp)(nil)
@@ -1602,7 +1426,6 @@ var (
1426
_ caddyfile.Unmarshaler = (*MatchHeader)(nil)
1427
_ caddyfile.Unmarshaler = (*MatchHeaderRE)(nil)
1428
_ caddyfile.Unmarshaler = (*MatchProtocol)(nil)
1605
- _ caddyfile.Unmarshaler = (*MatchRemoteIP)(nil)
1429
_ caddyfile.Unmarshaler = (*VarsMatcher)(nil)
1430
_ caddyfile.Unmarshaler = (*MatchVarsRE)(nil)
1431
@@ -1614,7 +1437,6 @@ var (
1437
_ CELLibraryProducer = (*MatchHeader)(nil)
1438
_ CELLibraryProducer = (*MatchHeaderRE)(nil)
1439
_ CELLibraryProducer = (*MatchProtocol)(nil)
1617
- _ CELLibraryProducer = (*MatchRemoteIP)(nil)
1440
// _ CELLibraryProducer = (*VarsMatcher)(nil)
1441
// _ CELLibraryProducer = (*MatchVarsRE)(nil)
1442
modules/caddyhttp/server.go
+65
-9
@@ -130,6 +130,17 @@ type Server struct {
130
// to trust sensitive incoming `X-Forwarded-*` headers.
131
TrustedProxiesRaw json.RawMessage `json:"trusted_proxies,omitempty" caddy:"namespace=http.ip_sources inline_key=source"`
132
133
+ // The headers from which the client IP address could be
134
+ // read from. These will be considered in order, with the
135
+ // first good value being used as the client IP.
136
+ // By default, only `X-Forwarded-For` is considered.
137
+ //
138
+ // This depends on `trusted_proxies` being configured and
139
+ // the request being validated as coming from a trusted
140
+ // proxy, otherwise the client IP will be set to the direct
141
+ // remote IP address.
142
+ ClientIPHeaders []string `json:"client_ip_headers,omitempty"`
143
+
144
// Enables access logging and configures how access logs are handled
145
// in this server. To minimally enable access logs, simply set this
146
// to a non-null, empty struct.
@@ -690,10 +701,15 @@ func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter
701
// set up the context for the request
702
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
703
ctx = context.WithValue(ctx, ServerCtxKey, s)
704
+
705
+ trusted, clientIP := determineTrustedProxy(r, s)
706
ctx = context.WithValue(ctx, VarsCtxKey, map[string]any{
694
- TrustedProxyVarKey: determineTrustedProxy(r, s),
707
+ TrustedProxyVarKey: trusted,
708
+ ClientIPVarKey: clientIP,
709
})
710
+
711
ctx = context.WithValue(ctx, routeGroupCtxKey, make(map[string]struct{}))
712
+
713
var url2 url.URL // avoid letting this escape to the heap
714
ctx = context.WithValue(ctx, OriginalRequestCtxKey, originalRequest(r, &url2))
715
r = r.WithContext(ctx)
@@ -724,11 +740,12 @@ func originalRequest(req *http.Request, urlCopy *url.URL) http.Request {
740
741
// determineTrustedProxy parses the remote IP address of
742
// the request, and determines (if the server configured it)
727
-// if the client is a trusted proxy.
728
-func determineTrustedProxy(r *http.Request, s *Server) bool {
743
+// if the client is a trusted proxy. If trusted, also returns
744
+// the real client IP if possible.
745
+func determineTrustedProxy(r *http.Request, s *Server) (bool, string) {
746
// If there's no server, then we can't check anything
747
if s == nil {
731
- return false
748
+ return false, ""
749
}
750
751
// Parse the remote IP, ignore the error as non-fatal,
@@ -738,7 +755,7 @@ func determineTrustedProxy(r *http.Request, s *Server) bool {
755
// remote address and used an invalid value.
756
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
757
if err != nil {
741
- return false
758
+ return false, ""
759
}
760
761
// Client IP may contain a zone if IPv6, so we need
@@ -746,20 +763,56 @@ func determineTrustedProxy(r *http.Request, s *Server) bool {
763
clientIP, _, _ = strings.Cut(clientIP, "%")
764
ipAddr, err := netip.ParseAddr(clientIP)
765
if err != nil {
749
- return false
766
+ return false, ""
767
}
768
769
// Check if the client is a trusted proxy
770
if s.trustedProxies == nil {
754
- return false
771
+ return false, ipAddr.String()
772
}
773
for _, ipRange := range s.trustedProxies.GetIPRanges(r) {
774
if ipRange.Contains(ipAddr) {
758
- return true
775
+ // We trust the proxy, so let's try to
776
+ // determine the real client IP
777
+ return true, trustedRealClientIP(r, s.ClientIPHeaders, ipAddr.String())
778
}
779
}
780
762
- return false
781
+ return false, ipAddr.String()
782
+}
783
+
784
+// trustedRealClientIP finds the client IP from the request assuming it is
785
+// from a trusted client. If there is no client IP headers, then the
786
+// direct remote address is returned. If there are client IP headers,
787
+// then the first value from those headers is used.
788
+func trustedRealClientIP(r *http.Request, headers []string, clientIP string) string {
789
+ // Read all the values of the configured client IP headers, in order
790
+ var values []string
791
+ for _, field := range headers {
792
+ values = append(values, r.Header.Values(field)...)
793
+ }
794
+
795
+ // If we don't have any values, then give up
796
+ if len(values) == 0 {
797
+ return clientIP
798
+ }
799
+
800
+ // Since there can be many header values, we need to
801
+ // join them together before splitting to get the full list
802
+ allValues := strings.Split(strings.Join(values, ","), ",")
803
+
804
+ // Get first valid left-most IP address
805
+ for _, ip := range allValues {
806
+ ip, _, _ = strings.Cut(strings.TrimSpace(ip), "%")
807
+ ipAddr, err := netip.ParseAddr(ip)
808
+ if err != nil {
809
+ continue
810
+ }
811
+ return ipAddr.String()
812
+ }
813
+
814
+ // We didn't find a valid IP
815
+ return clientIP
816
}
817
818
// cloneURL makes a copy of r.URL and returns a
@@ -787,4 +840,7 @@ const (
840
841
// For tracking whether the client is a trusted proxy
842
TrustedProxyVarKey string = "trusted_proxy"
843
+
844
+ // For tracking the real client IP (affected by trusted_proxy)
845
+ ClientIPVarKey string = "client_ip"
846
)