mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user