From b5ef700dd65fd369430b015b77818301b62021f9 Mon Sep 17 00:00:00 2001 From: George K Date: Thu, 11 Jun 2026 10:55:00 -0700 Subject: [PATCH] fix!: only trust x-forwarded-host from configured trusted proxies (#26204) Subdomain app routing derived the app identity from httpapi.RequestHost, which returned the client-supplied X-Forwarded-Host header verbatim. No middleware validated or stripped that header, so a request from an untrusted peer could forge it. Since the application_connect cookie is scoped to the wildcard apps domain, JavaScript in a share=authenticated app could fetch() with a forged X-Forwarded-Host pointing at a victim's owner-only app; coderd routed and authorized the request as the victim and returned the private app response same-origin to the attacker. Replace RequestHost with httpmw.EffectiveHost, which honors X-Forwarded-Host only when the original socket peer is a configured trusted origin, otherwise falling back to the received Host header. This ties host trust to the same RealIPConfig model already used for X-Forwarded-For and -Proto. Wire it into HandleSubdomain for both coderd and wsproxy, and log both the effective host and the raw received_host. Add coverage: EffectiveHost unit tests assert the trust decision uses the socket peer rather than the spoofable forwarded client IP, and a HandleSubdomain test confirms a forged X-Forwarded-Host from an untrusted peer never reaches token resolution. Refs: https://linear.app/codercom/issue/PLAT-259 --- .claude/docs/OBSERVABILITY.md | 6 +- agent/agentchat/log_test.go | 6 +- agent/agentproc/api_test.go | 2 +- agent/api.go | 2 +- cli/testdata/coder_server_--help.golden | 4 +- cli/testdata/server-config.yaml.golden | 3 +- coderd/coderd.go | 4 +- coderd/httpapi/request.go | 11 -- coderd/httpmw/loggermw/logger.go | 11 +- .../httpmw/loggermw/logger_internal_test.go | 40 ++++++- coderd/httpmw/realip.go | 29 +++++ coderd/httpmw/realip_test.go | 107 ++++++++++++++++++ coderd/workspaceapps/proxy.go | 2 +- coderd/workspaceapps/proxy_test.go | 87 ++++++++++++++ codersdk/deployment.go | 2 +- docs/admin/networking/wildcard-access-url.md | 6 + docs/reference/cli/server.md | 2 +- .../cli/testdata/coder_server_--help.golden | 4 +- enterprise/wsproxy/wsproxy.go | 4 +- tailnet/test/integration/integration.go | 2 +- 20 files changed, 298 insertions(+), 36 deletions(-) diff --git a/.claude/docs/OBSERVABILITY.md b/.claude/docs/OBSERVABILITY.md index 629a40915b..a7533e95ec 100644 --- a/.claude/docs/OBSERVABILITY.md +++ b/.claude/docs/OBSERVABILITY.md @@ -30,8 +30,10 @@ under a named logger such as `api`, `site`, `proxy`, `ext-provisioner`, or `prometheus`. HTTP request logging is implemented in `coderd/httpmw/loggermw`. Request log -fields include `user_agent`, `host`, `path`, `proto`, `remote_addr`, `start`, -`status_code`, `latency_ms`, route params, and selected safe query params. +fields include `user_agent`, `host`, the effective trust-aware host, +`received_host`, the raw received Host header, `path`, `proto`, +`remote_addr`, `start`, `status_code`, `latency_ms`, route params, and +selected safe query params. Responses with status codes of 500 or higher include the response body in the request log. Successful `GET /api/v2` requests are skipped. diff --git a/agent/agentchat/log_test.go b/agent/agentchat/log_test.go index c9fb1fc49a..99cd94a133 100644 --- a/agent/agentchat/log_test.go +++ b/agent/agentchat/log_test.go @@ -22,7 +22,7 @@ func TestMiddlewareAccessLog(t *testing.T) { chatID := uuid.New() ancestorID := uuid.New() sink := testutil.NewFakeSink(t) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger())( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)( agentchat.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) })), @@ -46,7 +46,7 @@ func TestMiddlewareWithoutChatHeader(t *testing.T) { t.Parallel() sink := testutil.NewFakeSink(t) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger())( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)( agentchat.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) })), @@ -68,7 +68,7 @@ func TestMiddlewareContextFields(t *testing.T) { chatID := uuid.New() sink := testutil.NewFakeSink(t) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger())( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)( agentchat.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { sink.Logger().With(agentchat.Fields(r.Context())...).Info(r.Context(), "handler log") rw.WriteHeader(http.StatusNoContent) diff --git a/agent/agentproc/api_test.go b/agent/agentproc/api_test.go index 6a9cf8f130..73efa6bdf7 100644 --- a/agent/agentproc/api_test.go +++ b/agent/agentproc/api_test.go @@ -178,7 +178,7 @@ func TestAccessLogIncludesChatID(t *testing.T) { t.Cleanup(func() { _ = api.Close() }) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(logger)( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(logger, nil)( agentchat.Middleware(api.Routes()), )) diff --git a/agent/api.go b/agent/api.go index 91f575675f..300d92475e 100644 --- a/agent/api.go +++ b/agent/api.go @@ -20,7 +20,7 @@ func (a *agent) apiHandler() http.Handler { r.Use( httpmw.Recover(a.logger), tracing.StatusWriterMiddleware, - loggermw.Logger(a.logger), + loggermw.Logger(a.logger, nil), agentchat.Middleware, ) r.Get("/", func(rw http.ResponseWriter, r *http.Request) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 63640d49f9..fe44898f1f 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -454,8 +454,8 @@ NETWORKING OPTIONS: True-Client-Ip, X-Forwarded-For. --proxy-trusted-origins string-array, $CODER_PROXY_TRUSTED_ORIGINS - Origin addresses to respect "proxy-trusted-headers". e.g. - 192.168.1.0/24. + Origin addresses to respect "proxy-trusted-headers" and + X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. --redirect-to-access-url bool, $CODER_REDIRECT_TO_ACCESS_URL Specifies whether to redirect requests that do not match the access diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 15dd31638d..6cbc09f231 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -172,7 +172,8 @@ networking: # True-Client-Ip, X-Forwarded-For. # (default: , type: string-array) proxyTrustedHeaders: [] - # Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24. + # Origin addresses to respect "proxy-trusted-headers" and X-Forwarded-Host for + # subdomain app routing. e.g. 192.168.1.0/24. # (default: , type: string-array) proxyTrustedOrigins: [] # Controls if the 'Secure' property is set on browser session cookies. diff --git a/coderd/coderd.go b/coderd/coderd.go index b2d50f7068..c5e5a12793 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1012,7 +1012,9 @@ func New(options *Options) *API { tracing.Middleware(api.TracerProvider), httpmw.AttachRequestID, httpmw.ExtractRealIP(api.RealIPConfig), - loggermw.Logger(api.Logger), + loggermw.Logger(api.Logger, func(r *http.Request) string { + return httpmw.EffectiveHost(api.RealIPConfig, r) + }), singleSlashMW, rolestore.CustomRoleMW, // Validate API key on every request (if present) and store diff --git a/coderd/httpapi/request.go b/coderd/httpapi/request.go index 6a07ede6dc..95d786d241 100644 --- a/coderd/httpapi/request.go +++ b/coderd/httpapi/request.go @@ -8,17 +8,6 @@ const ( XForwardedHostHeader = "X-Forwarded-Host" ) -// RequestHost returns the name of the host from the request. It prioritizes -// 'X-Forwarded-Host' over r.Host since most requests are being proxied. -func RequestHost(r *http.Request) string { - host := r.Header.Get(XForwardedHostHeader) - if host != "" { - return host - } - - return r.Host -} - func IsWebsocketUpgrade(r *http.Request) bool { vs := r.Header.Values("Upgrade") for _, v := range vs { diff --git a/coderd/httpmw/loggermw/logger.go b/coderd/httpmw/loggermw/logger.go index d6850e31c4..767d757bc5 100644 --- a/coderd/httpmw/loggermw/logger.go +++ b/coderd/httpmw/loggermw/logger.go @@ -12,7 +12,6 @@ import ( "github.com/go-chi/chi/v5" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/tracing" ) @@ -69,7 +68,7 @@ func safeQueryParams(params url.Values) []slog.Field { return fields } -func Logger(log slog.Logger) func(next http.Handler) http.Handler { +func Logger(log slog.Logger, hostResolver func(*http.Request) string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { start := time.Now() @@ -79,9 +78,15 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler { panic(fmt.Sprintf("ResponseWriter not a *tracing.StatusWriter; got %T", rw)) } + host := r.Host + if hostResolver != nil { + host = hostResolver(r) + } + httplog := log.With( slog.F("user_agent", r.Header.Get("User-Agent")), - slog.F("host", httpapi.RequestHost(r)), + slog.F("host", host), + slog.F("received_host", r.Host), slog.F("path", r.URL.Path), slog.F("proto", r.Proto), slog.F("remote_addr", r.RemoteAddr), diff --git a/coderd/httpmw/loggermw/logger_internal_test.go b/coderd/httpmw/loggermw/logger_internal_test.go index 2f0bc5c39d..5ebb6973d3 100644 --- a/coderd/httpmw/loggermw/logger_internal_test.go +++ b/coderd/httpmw/loggermw/logger_internal_test.go @@ -68,7 +68,7 @@ func TestLoggerMiddleware_SingleRequest(t *testing.T) { }) // Wrap the test handler with the Logger middleware - loggerMiddleware := Logger(logger) + loggerMiddleware := Logger(logger, nil) wrappedHandler := loggerMiddleware(testHandler) // Create a test HTTP request @@ -91,7 +91,7 @@ func TestLoggerMiddleware_SingleRequest(t *testing.T) { } // Check that the log contains the expected fields - requiredFields := []string{"host", "path", "proto", "remote_addr", "start", "took", "status_code", "user_agent", "latency_ms"} + requiredFields := []string{"host", "received_host", "path", "proto", "remote_addr", "start", "took", "status_code", "user_agent", "latency_ms"} for _, field := range requiredFields { _, exists := fieldsMap[field] require.True(t, exists, "field %q is missing in log fields", field) @@ -103,6 +103,38 @@ func TestLoggerMiddleware_SingleRequest(t *testing.T) { require.Equal(t, fieldsMap["status_code"], http.StatusOK) } +func TestLoggerMiddleware_HostFields(t *testing.T) { + t.Parallel() + + sink := testutil.NewFakeSink(t) + logger := sink.Logger() + + testHandler := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + + loggerMiddleware := Logger(logger, func(_ *http.Request) string { + return "effective.test" + }) + wrappedHandler := loggerMiddleware(testHandler) + + req := httptest.NewRequest(http.MethodGet, "http://received.test/path", nil) + + sw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()} + wrappedHandler.ServeHTTP(sw, req) + + entries := sink.Entries() + require.Len(t, entries, 1, "expected exactly one log entry") + + fieldsMap := make(map[string]any) + for _, field := range entries[0].Fields { + fieldsMap[field.Name] = field.Value + } + + require.Equal(t, "effective.test", fieldsMap["host"]) + require.Equal(t, "received.test", fieldsMap["received_host"]) +} + func TestLoggerMiddleware_WebSocket(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -129,7 +161,7 @@ func TestLoggerMiddleware_WebSocket(t *testing.T) { }) // Wrap the test handler with the Logger middleware - loggerMiddleware := Logger(logger) + loggerMiddleware := Logger(logger, nil) wrappedHandler := loggerMiddleware(testHandler) // RequestLogger expects the ResponseWriter to be *tracing.StatusWriter @@ -186,7 +218,7 @@ func TestRequestLogger_HTTPRouteParams(t *testing.T) { }) // Wrap the test handler with the Logger middleware - loggerMiddleware := Logger(logger) + loggerMiddleware := Logger(logger, nil) wrappedHandler := loggerMiddleware(testHandler) // Create a test HTTP request diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index 6f0f318b83..f428e15fcf 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -105,6 +105,35 @@ func FilterUntrustedOriginHeaders(config *RealIPConfig, req *http.Request) { } } +// EffectiveHost returns the host Coder should trust for request handling. +// It uses X-Forwarded-Host only when the immediate peer is a configured +// trusted proxy. Otherwise it uses the received Host header. +func EffectiveHost(config *RealIPConfig, r *http.Request) string { + if config == nil { + config = &RealIPConfig{ + TrustedOrigins: nil, + TrustedHeaders: nil, + } + } + + // When ExtractRealIP has run, r.RemoteAddr may hold the forwarded + // client IP, and we should use the original socket peer for proxy + // trust decisions. + remoteAddr := r.RemoteAddr + state := RealIP(r.Context()) + if state != nil && state.OriginalRemoteAddr != "" { + remoteAddr = state.OriginalRemoteAddr + } + + if isContainedIn(config.TrustedOrigins, getRemoteAddress(remoteAddr)) { + if host := r.Header.Get(httpapi.XForwardedHostHeader); host != "" { + return host + } + } + + return r.Host +} + // EnsureXForwardedForHeader ensures that the request has an X-Forwarded-For // header. It uses the following logic: // diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index 18b870ae37..caa1fe9849 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" ) @@ -472,6 +473,112 @@ func TestFilterUntrusted(t *testing.T) { } } +func TestEffectiveHost(t *testing.T) { + t.Parallel() + + cidr32 := func(t *testing.T, ip string) *net.IPNet { + t.Helper() + + return &net.IPNet{ + IP: net.ParseIP(ip), + Mask: net.CIDRMask(32, 32), + } + } + + t.Run("UntrustedPeerFallsBackToReceivedHost", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + require.Equal(t, "received.test", httpmw.EffectiveHost(nil, r)) + }) + + t.Run("TrustedPeerUsesOriginalRemoteAddrForTrust", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "17.18.19.20")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + // X-Real-Ip causes ExtractRealIP to rewrite r.RemoteAddr, so + // this test can verify trust still uses OriginalRemoteAddr, + // the actual socket peer. + r.Header.Set("X-Real-Ip", "99.88.77.66") + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + middleware := httpmw.ExtractRealIP(config) + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + require.Equal(t, "99.88.77.66", r.RemoteAddr) + require.Equal(t, "app.test.coder.com", httpmw.EffectiveHost(config, r)) + }) + + middleware(next).ServeHTTP(httptest.NewRecorder(), r) + }) + + t.Run("UntrustedPeerDoesNotHonorForwardedHost", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "99.88.77.66")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + r.Header.Set("X-Real-Ip", "99.88.77.66") + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + middleware := httpmw.ExtractRealIP(config) + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + require.Equal(t, "17.18.19.20", r.RemoteAddr) + require.Equal(t, "received.test", httpmw.EffectiveHost(config, r)) + }) + + middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), r) + }) + + t.Run("TrustedPeerWithoutForwardedHostFallsBackToReceivedHost", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "17.18.19.20")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + + middleware := httpmw.ExtractRealIP(config) + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + require.Equal(t, "received.test", httpmw.EffectiveHost(config, r)) + }) + + middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), r) + }) + + t.Run("MalformedRemoteAddrFallsBackToReceivedHost", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "17.18.19.20")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + // A RemoteAddr that cannot be parsed into an IP must be treated as + // untrusted, so the forwarded host is ignored. + r.RemoteAddr = "garbage" + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + require.Equal(t, "received.test", httpmw.EffectiveHost(config, r)) + }) +} + // TestApplicationProxy checks headers passed to DevURL services are as expected. func TestApplicationProxy(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 2e0c97725e..9859171462 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -438,7 +438,7 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler) } // Step 2: Get the request Host. - host := httpapi.RequestHost(r) + host := httpmw.EffectiveHost(s.RealIPConfig, r) if host == "" { if r.URL.Path == "/derp" { // The /derp endpoint is used by wireguard clients to tunnel diff --git a/coderd/workspaceapps/proxy_test.go b/coderd/workspaceapps/proxy_test.go index 5c71f15ffa..8678614243 100644 --- a/coderd/workspaceapps/proxy_test.go +++ b/coderd/workspaceapps/proxy_test.go @@ -1,3 +1,90 @@ package workspaceapps_test // App tests can be found in the apptest package. + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/coderd/workspaceapps/appurl" + "github.com/coder/coder/v2/testutil" +) + +type fakeSignedTokenProvider struct { + fromRequestCalls int + issueCalls int +} + +func (s *fakeSignedTokenProvider) FromRequest(_ *http.Request) (*workspaceapps.SignedToken, bool) { + s.fromRequestCalls++ + return nil, false +} + +func (s *fakeSignedTokenProvider) Issue(_ context.Context, _ http.ResponseWriter, _ *http.Request, _ workspaceapps.IssueTokenRequest) (*workspaceapps.SignedToken, string, bool) { + s.issueCalls++ + return nil, "", false +} + +func TestHandleSubdomain_IgnoresUntrustedForwardedHost(t *testing.T) { + t.Parallel() + + hostnamePattern := "*--apps.test.coder.com" + hostnameRegex, err := appurl.CompileHostnamePattern(hostnamePattern) + require.NoError(t, err) + + dashboardURL, err := url.Parse("https://dashboard.test.coder.com") + require.NoError(t, err) + + provider := &fakeSignedTokenProvider{} + srv := workspaceapps.NewServer(workspaceapps.ServerOptions{ + Logger: testutil.Logger(t), + DashboardURL: dashboardURL, + AccessURL: dashboardURL, + Hostname: hostnamePattern, + HostnameRegex: hostnameRegex, + RealIPConfig: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{{ + IP: net.ParseIP("10.0.0.1"), + Mask: net.CIDRMask(32, 32), + }}, + }, + SignedTokenProvider: provider, + }) + + forgedHost := appurl.ApplicationURL{ + AppSlugOrPort: "app", + WorkspaceName: "workspace", + Username: "victim", + }.String() + "--apps.test.coder.com" + + nextCalled := false + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + nextCalled = true + }) + + // Given: a request with a forged X-Forwarded-Host set to a valid + // app hostname, and an immediate peer outside the trusted proxy + // config. + req := httptest.NewRequest(http.MethodGet, "https://dashboard.test.coder.com/", nil) + req.Header.Set(httpapi.XForwardedHostHeader, forgedHost) + req.RemoteAddr = "17.18.19.20:1234" + + // When: HandleSubdomain runs. + srv.HandleSubdomain()(next).ServeHTTP(httptest.NewRecorder(), req) + + // Then: it ignores untrusted X-Forwarded-Host, so the received + // dashboard host is used, the request falls through to the next + // handler, and the signed app token provider is never called. + require.True(t, nextCalled) + require.Zero(t, provider.fromRequestCalls) + require.Zero(t, provider.issueCalls) +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 3e447a4db4..5d3ffe8294 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3352,7 +3352,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Name: "Proxy Trusted Origins", Flag: "proxy-trusted-origins", Env: "CODER_PROXY_TRUSTED_ORIGINS", - Description: "Origin addresses to respect \"proxy-trusted-headers\". e.g. 192.168.1.0/24.", + Description: "Origin addresses to respect \"proxy-trusted-headers\" and X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24.", Value: &c.ProxyTrustedOrigins, Group: &deploymentGroupNetworking, YAML: "proxyTrustedOrigins", diff --git a/docs/admin/networking/wildcard-access-url.md b/docs/admin/networking/wildcard-access-url.md index 44afba2e5b..fc9e917331 100644 --- a/docs/admin/networking/wildcard-access-url.md +++ b/docs/admin/networking/wildcard-access-url.md @@ -56,6 +56,12 @@ Use a reverse proxy to handle TLS termination with automatic certificate managem - [Apache with Let's Encrypt](../../tutorials/reverse-proxy-apache.md) - [Caddy reverse proxy](../../tutorials/reverse-proxy-caddy.md) +If your reverse proxy rewrites the request `Host` and forwards the original +host in `X-Forwarded-Host`, configure +[`CODER_PROXY_TRUSTED_ORIGINS`](../../reference/cli/server.md#--proxy-trusted-origins) +to trust that proxy's address. Otherwise Coder will ignore `X-Forwarded-Host` +for subdomain app routing. + ### DNS Setup You'll need to configure DNS to point wildcard subdomains to your Coder server: diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 356358a873..22b929d660 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -982,7 +982,7 @@ Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client | Environment | $CODER_PROXY_TRUSTED_ORIGINS | | YAML | networking.proxyTrustedOrigins | -Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24. +Origin addresses to respect "proxy-trusted-headers" and X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. ### --cache-dir diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 801bae69c9..f4aed57bb8 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -455,8 +455,8 @@ NETWORKING OPTIONS: True-Client-Ip, X-Forwarded-For. --proxy-trusted-origins string-array, $CODER_PROXY_TRUSTED_ORIGINS - Origin addresses to respect "proxy-trusted-headers". e.g. - 192.168.1.0/24. + Origin addresses to respect "proxy-trusted-headers" and + X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. --redirect-to-access-url bool, $CODER_REDIRECT_TO_ACCESS_URL Specifies whether to redirect requests that do not match the access diff --git a/enterprise/wsproxy/wsproxy.go b/enterprise/wsproxy/wsproxy.go index 715e29c6d6..402ee53d5e 100644 --- a/enterprise/wsproxy/wsproxy.go +++ b/enterprise/wsproxy/wsproxy.go @@ -364,7 +364,9 @@ func New(ctx context.Context, opts *Options) (*Server, error) { tracing.Middleware(s.TracerProvider), httpmw.AttachRequestID, httpmw.ExtractRealIP(s.Options.RealIPConfig), - loggermw.Logger(s.Logger), + loggermw.Logger(s.Logger, func(r *http.Request) string { + return httpmw.EffectiveHost(s.Options.RealIPConfig, r) + }), prometheusMW, // HandleSubdomain is a middleware that handles all requests to the diff --git a/tailnet/test/integration/integration.go b/tailnet/test/integration/integration.go index 43648e89d8..449d32f579 100644 --- a/tailnet/test/integration/integration.go +++ b/tailnet/test/integration/integration.go @@ -216,7 +216,7 @@ func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { }) }, tracing.StatusWriterMiddleware, - loggermw.Logger(logger), + loggermw.Logger(logger, nil), ) r.Route("/derp", func(r chi.Router) {