Skip to content

Commit 8a304bd

Browse files
committed
feat: Support x-forwarded-for headers for IPs
Fixes #4430.
1 parent 72288c3 commit 8a304bd

File tree

9 files changed

+1052
-0
lines changed

9 files changed

+1052
-0
lines changed

cli/deployment/flags.go

+12
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,18 @@ func Flags() *codersdk.DeploymentFlags {
236236
Description: "Scopes to grant when authenticating with OIDC.",
237237
Default: []string{oidc.ScopeOpenID, "profile", "email"},
238238
},
239+
ProxyTrustedHeaders: &codersdk.StringArrayFlag{
240+
Name: "Trusted HTTP Proxy Headers",
241+
Flag: "proxy-trusted-headers",
242+
EnvVar: "CODER_PROXY_TRUSTED_HEADERS",
243+
Description: "Headers to trust for forwarding IP addresses. e.g. \"X-Forwarded-for\"",
244+
},
245+
ProxyTrustedOrigins: &codersdk.StringArrayFlag{
246+
Name: "Trusted HTTP Proxy Origins",
247+
Flag: "proxy-trusted-origins",
248+
EnvVar: "CODER_PROXY_TRUSTED_ORIGINS",
249+
Description: "Origin addresses to respect \"proxy-trusted-headers\".",
250+
},
239251
TelemetryEnable: &codersdk.BoolFlag{
240252
Name: "Telemetry Enabled",
241253
Flag: "telemetry",

cli/server.go

+7
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import (
5656
"github.com/coder/coder/coderd/devtunnel"
5757
"github.com/coder/coder/coderd/gitsshkey"
5858
"github.com/coder/coder/coderd/httpapi"
59+
"github.com/coder/coder/coderd/httpmw"
5960
"github.com/coder/coder/coderd/prometheusmetrics"
6061
"github.com/coder/coder/coderd/telemetry"
6162
"github.com/coder/coder/coderd/tracing"
@@ -321,6 +322,11 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code
321322
}
322323
}
323324

325+
realIPConfig, err := httpmw.ParseRealIPConfig(dflags.ProxyTrustedHeaders.Value, dflags.ProxyTrustedOrigins.Value)
326+
if err != nil {
327+
return xerrors.Errorf("parse real ip config: %w", err)
328+
}
329+
324330
options := &coderd.Options{
325331
AccessURL: accessURLParsed,
326332
AppHostname: appHostname,
@@ -332,6 +338,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code
332338
CacheDir: dflags.CacheDir.Value,
333339
GoogleTokenValidator: googleTokenValidator,
334340
SecureAuthCookie: dflags.SecureAuthCookie.Value,
341+
RealIPConfig: realIPConfig,
335342
SSHKeygenAlgorithm: sshKeygenAlgorithm,
336343
TracerProvider: tracerProvider,
337344
Telemetry: telemetry.NewNoop(),

coderd/coderd.go

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type Options struct {
8282
Telemetry telemetry.Reporter
8383
TracerProvider trace.TracerProvider
8484
AutoImportTemplates []AutoImportTemplate
85+
RealIPConfig *httpmw.RealIPConfig
8586

8687
// TLSCertificates is used to mesh DERP servers securely.
8788
TLSCertificates []tls.Certificate
@@ -198,6 +199,7 @@ func New(options *Options) *API {
198199
r.Use(
199200
httpmw.AttachRequestID,
200201
httpmw.Recover(api.Logger),
202+
httpmw.ExtractRealIP(api.RealIPConfig),
201203
httpmw.Logger(api.Logger),
202204
httpmw.Prometheus(options.PrometheusRegistry),
203205
// handleSubdomainApplications checks if the first subdomain is a valid

coderd/coderdtest/coderdtest.go

+3
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import (
5757
"github.com/coder/coder/coderd/database/dbtestutil"
5858
"github.com/coder/coder/coderd/gitsshkey"
5959
"github.com/coder/coder/coderd/httpapi"
60+
"github.com/coder/coder/coderd/httpmw"
6061
"github.com/coder/coder/coderd/rbac"
6162
"github.com/coder/coder/coderd/telemetry"
6263
"github.com/coder/coder/coderd/util/ptr"
@@ -77,6 +78,7 @@ type Options struct {
7778
Experimental bool
7879
AzureCertificates x509.VerifyOptions
7980
GithubOAuth2Config *coderd.GithubOAuth2Config
81+
RealIPConfig *httpmw.RealIPConfig
8082
OIDCConfig *coderd.OIDCConfig
8183
GoogleTokenValidator *idtoken.Validator
8284
SSHKeygenAlgorithm gitsshkey.Algorithm
@@ -238,6 +240,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
238240
AWSCertificates: options.AWSCertificates,
239241
AzureCertificates: options.AzureCertificates,
240242
GithubOAuth2Config: options.GithubOAuth2Config,
243+
RealIPConfig: options.RealIPConfig,
241244
OIDCConfig: options.OIDCConfig,
242245
GoogleTokenValidator: options.GoogleTokenValidator,
243246
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,

coderd/httpmw/realip.go

+310
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
package httpmw
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"strings"
8+
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/coder/coderd/httpapi"
12+
)
13+
14+
const (
15+
// Note: these should be canonicalized (see http.CanonicalHeaderKey)
16+
// or else things will not work correctly
17+
headerCFConnectingIP string = "Cf-Connecting-Ip"
18+
headerTrueClientIP string = "True-Client-Ip"
19+
headerXRealIP string = "X-Real-Ip"
20+
headerXForwardedFor string = "X-Forwarded-For"
21+
headerXForwardedProto string = "X-Forwarded-Proto"
22+
)
23+
24+
var headersAll = []string{
25+
headerCFConnectingIP,
26+
headerTrueClientIP,
27+
headerXRealIP,
28+
headerXForwardedFor,
29+
headerXForwardedProto,
30+
}
31+
32+
// Config configures the search order for the function, which controls
33+
// which headers to consider trusted.
34+
type RealIPConfig struct {
35+
// TrustedOrigins is a list of networks that will be trusted. If
36+
// any non-trusted address supplies these headers, they will be
37+
// ignored.
38+
TrustedOrigins []*net.IPNet
39+
40+
// CloudflareConnectingIP trusts the CF-Connecting-IP header.
41+
// https://support.cloudflare.com/hc/en-us/articles/206776727-Understanding-the-True-Client-IP-Header
42+
CloudflareConnectingIP bool
43+
44+
// TrueClientIP trusts the True-Client-IP header.
45+
TrueClientIP bool
46+
47+
// XRealIP trusts the X-Real-IP header.
48+
XRealIP bool
49+
50+
// X-Forwarded-For trusts the X-Forwarded-For and X-Forwarded-Proto
51+
// headers.
52+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
53+
XForwardedFor bool
54+
}
55+
56+
// Middleware is a middleware that uses headers from reverse proxies to
57+
// propagate origin IP address information, when configured to do so.
58+
func ExtractRealIP(config *RealIPConfig) func(next http.Handler) http.Handler {
59+
return func(next http.Handler) http.Handler {
60+
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
61+
// Preserve the original TLS connection state and RemoteAddr
62+
req = req.WithContext(context.WithValue(req.Context(), ctxKey{}, &RealIPState{
63+
Config: config,
64+
OriginalRemoteAddr: req.RemoteAddr,
65+
}))
66+
67+
info, err := ExtractRealIPAddress(config, req)
68+
if err != nil {
69+
httpapi.InternalServerError(w, err)
70+
return
71+
}
72+
req.RemoteAddr = info.String()
73+
74+
next.ServeHTTP(w, req)
75+
})
76+
}
77+
}
78+
79+
// ExtractRealIPAddress returns the original client address according to the
80+
// configuration and headers. It does not mutate the original request.
81+
func ExtractRealIPAddress(config *RealIPConfig, req *http.Request) (net.IP, error) {
82+
if config == nil {
83+
config = &RealIPConfig{}
84+
}
85+
86+
cf := isContainedIn(config.TrustedOrigins, getRemoteAddress(req.RemoteAddr))
87+
if !cf {
88+
// Address is not valid or the origin is not trusted; use the
89+
// original address
90+
return getRemoteAddress(req.RemoteAddr), nil
91+
}
92+
93+
// We want to prefer (in order):
94+
// - CF-Connecting-IP
95+
// - True-Client-IP
96+
// - X-Real-IP
97+
// - X-Forwarded-For
98+
if config.CloudflareConnectingIP {
99+
addr := getRemoteAddress(req.Header.Get(headerCFConnectingIP))
100+
if addr != nil {
101+
return addr, nil
102+
}
103+
}
104+
105+
if config.TrueClientIP {
106+
addr := getRemoteAddress(req.Header.Get(headerTrueClientIP))
107+
if addr != nil {
108+
return addr, nil
109+
}
110+
}
111+
112+
if config.XRealIP {
113+
addr := getRemoteAddress(req.Header.Get(headerXRealIP))
114+
if addr != nil {
115+
return addr, nil
116+
}
117+
}
118+
119+
if config.XForwardedFor {
120+
addr := getRemoteAddress(req.Header.Get(headerXForwardedFor))
121+
if addr != nil {
122+
return addr, nil
123+
}
124+
}
125+
126+
return getRemoteAddress(req.RemoteAddr), nil
127+
}
128+
129+
// FilterUntrustedOriginHeaders removes all known proxy headers from the
130+
// request for untrusted origins, and ensures that only one copy
131+
// of each proxy header is set.
132+
func FilterUntrustedOriginHeaders(config *RealIPConfig, req *http.Request) {
133+
if config == nil {
134+
config = &RealIPConfig{}
135+
}
136+
137+
cf := isContainedIn(config.TrustedOrigins, getRemoteAddress(req.RemoteAddr))
138+
if !cf {
139+
// Address is not valid or the origin is not trusted; clear
140+
// all known proxy headers and return
141+
for _, header := range headersAll {
142+
req.Header.Del(header)
143+
}
144+
return
145+
}
146+
147+
if config.CloudflareConnectingIP {
148+
req.Header.Set(headerCFConnectingIP, req.Header.Get(headerCFConnectingIP))
149+
} else {
150+
req.Header.Del(headerCFConnectingIP)
151+
}
152+
153+
if config.TrueClientIP {
154+
req.Header.Set(headerTrueClientIP, req.Header.Get(headerTrueClientIP))
155+
} else {
156+
req.Header.Del(headerTrueClientIP)
157+
}
158+
159+
if config.XRealIP {
160+
req.Header.Set(headerXRealIP, req.Header.Get(headerXRealIP))
161+
} else {
162+
req.Header.Del(headerXRealIP)
163+
}
164+
165+
if config.XForwardedFor {
166+
req.Header.Set(headerXForwardedFor, req.Header.Get(headerXForwardedFor))
167+
req.Header.Set(headerXForwardedProto, req.Header.Get(headerXForwardedProto))
168+
} else {
169+
req.Header.Del(headerXForwardedFor)
170+
req.Header.Del(headerXForwardedProto)
171+
}
172+
}
173+
174+
// EnsureXForwardedForHeader ensures that the request has an X-Forwarded-For
175+
// header. It uses the following logic:
176+
//
177+
// 1. If we have a direct connection (remoteAddr == proxyAddr), then
178+
// set it to remoteAddr
179+
// 2. If we have a proxied connection (remoteAddr != proxyAddr) and
180+
// X-Forwarded-For doesn't begin with remoteAddr, then overwrite
181+
// it with remoteAddr,proxyAddr
182+
// 3. If we have a proxied connection (remoteAddr != proxyAddr) and
183+
// X-Forwarded-For begins with remoteAddr, then append proxyAddr
184+
// to the original X-Forwarded-For header
185+
// 4. If X-Forwarded-Proto is not set, then it will be set to "https"
186+
// if req.TLS != nil, otherwise it will be set to "http"
187+
func EnsureXForwardedForHeader(req *http.Request) error {
188+
state := RealIP(req.Context())
189+
if state == nil {
190+
return xerrors.New("request does not contain realip.State; was it processed by httpmw.ExtractRealIP?")
191+
}
192+
193+
remoteAddr := getRemoteAddress(req.RemoteAddr)
194+
if remoteAddr == nil {
195+
return xerrors.Errorf("failed to parse remote address: %s", remoteAddr)
196+
}
197+
198+
proxyAddr := getRemoteAddress(state.OriginalRemoteAddr)
199+
if proxyAddr == nil {
200+
return xerrors.Errorf("failed to parse original address: %s", proxyAddr)
201+
}
202+
203+
if remoteAddr.Equal(proxyAddr) {
204+
req.Header.Set(headerXForwardedFor, remoteAddr.String())
205+
} else {
206+
forwarded := req.Header.Get(headerXForwardedFor)
207+
if forwarded == "" || !remoteAddr.Equal(getRemoteAddress(forwarded)) {
208+
req.Header.Set(headerXForwardedFor, remoteAddr.String()+","+proxyAddr.String())
209+
} else {
210+
req.Header.Set(headerXForwardedFor, forwarded+","+proxyAddr.String())
211+
}
212+
}
213+
214+
if req.Header.Get(headerXForwardedProto) == "" {
215+
if req.TLS != nil {
216+
req.Header.Set(headerXForwardedProto, "https")
217+
} else {
218+
req.Header.Set(headerXForwardedProto, "http")
219+
}
220+
}
221+
222+
return nil
223+
}
224+
225+
// getRemoteAddress extracts the IP address from the given string. If
226+
// the string contains commas, it assumes that the first part is the
227+
// original address.
228+
func getRemoteAddress(address string) net.IP {
229+
// X-Forwarded-For may contain multiple addresses, in case the
230+
// proxies are chained; the first value is the client address
231+
i := strings.IndexByte(address, ',')
232+
if i == -1 {
233+
i = len(address)
234+
}
235+
236+
// If the address contains a port, remove it
237+
firstAddress := address[:i]
238+
host, _, err := net.SplitHostPort(firstAddress)
239+
if err != nil {
240+
// This will error if there is no port, so try to parse the address
241+
return net.ParseIP(firstAddress)
242+
}
243+
return net.ParseIP(host)
244+
}
245+
246+
// isContainedIn checks that the given address is contained in the given
247+
// network.
248+
func isContainedIn(networks []*net.IPNet, address net.IP) bool {
249+
for _, network := range networks {
250+
if network.Contains(address) {
251+
return true
252+
}
253+
}
254+
255+
return false
256+
}
257+
258+
// RealIPState is the original state prior to modification by this middleware,
259+
// useful for getting information about the connecting client if needed.
260+
type RealIPState struct {
261+
// Config is the configuration applied in the middleware. Consider
262+
// this read-only and do not modify.
263+
Config *RealIPConfig
264+
265+
// OriginalRemoteAddr is the original RemoteAddr for the request.
266+
OriginalRemoteAddr string
267+
}
268+
269+
type ctxKey struct{}
270+
271+
// FromContext retrieves the state from the given context.Context.
272+
func RealIP(ctx context.Context) *RealIPState {
273+
state, ok := ctx.Value(ctxKey{}).(*RealIPState)
274+
if !ok {
275+
return nil
276+
}
277+
return state
278+
}
279+
280+
// ParseRealIPConfig takes a raw string array of headers and origins
281+
// to produce a config.
282+
func ParseRealIPConfig(headers, origins []string) (*RealIPConfig, error) {
283+
// If PROXY_TRUSTED_ORIGINS is set, assume we have a comma-separated
284+
// list of CIDRs and parse them.
285+
config := &RealIPConfig{}
286+
for _, origin := range origins {
287+
_, network, err := net.ParseCIDR(origin)
288+
if err != nil {
289+
return nil, xerrors.Errorf("parse proxy origin %q: %w", origin, err)
290+
}
291+
config.TrustedOrigins = append(config.TrustedOrigins, network)
292+
}
293+
294+
for _, header := range headers {
295+
header = http.CanonicalHeaderKey(header)
296+
switch header {
297+
case "Cf-Connecting-Ip":
298+
config.CloudflareConnectingIP = true
299+
case "True-Client-Ip":
300+
config.TrueClientIP = true
301+
case "X-Real-Ip":
302+
config.XRealIP = true
303+
case "X-Forwarded-For":
304+
config.XForwardedFor = true
305+
default:
306+
return nil, xerrors.Errorf("unsupported trusted proxy header %q", header)
307+
}
308+
}
309+
return config, nil
310+
}

0 commit comments

Comments
 (0)