Files
coder/coderd/workspaceapps/proxy_test.go
T

185 lines
5.9 KiB
Go

package workspaceapps_test
// App tests can be found in the apptest package.
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"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/jwtutils"
"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(t *testing.T) {
t.Parallel()
t.Run("IgnoresUntrustedForwardedHost", func(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)
})
// After consuming a smuggled API key, the handler redirects to strip the key.
// A path that smuggles a separate host (e.g. "//evil.com") must redirect back
// to the current origin, not off-site. The full path-sanitization matrix lives
// in Test_originLocalURL.
t.Run("APIKeySmugglingStaysOnOrigin", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
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)
// StaticKey lets us mint a smuggled key the handler can decrypt.
// A256GCMKW needs a 32-byte key.
keycache := jwtutils.StaticKey{ID: "test", Key: generateSecret(t, 32)}
payload := workspaceapps.EncryptedAPIKeyPayload{APIKey: "fake-api-key"}
payload.Fill(time.Now())
encryptedAPIKey, err := jwtutils.Encrypt(ctx, keycache, payload)
require.NoError(t, err)
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: &fakeSignedTokenProvider{},
APIKeyEncryptionKeycache: keycache,
})
host := appurl.ApplicationURL{
AppSlugOrPort: "app",
WorkspaceName: "workspace",
Username: "user",
}.String() + "--apps.test.coder.com"
// Set r.URL.Path directly: the slash-collapsing middleware writes to chi's
// RoutePath, not r.URL.Path, so the raw "//evil.com" survives to the handler.
// "x=1" rides along to confirm unrelated params are preserved.
req := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil)
req.Host = host
req.RemoteAddr = "10.0.0.1:1234"
req.URL.Path = "//evil.com/phish"
req.URL.RawQuery = url.Values{
workspaceapps.SubdomainProxyAPIKeyParam: {encryptedAPIKey},
"x": {"1"},
}.Encode()
rec := httptest.NewRecorder()
nextCalled := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
nextCalled = true
})
srv.HandleSubdomain()(next).ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
// The key is consumed and a redirect is issued instead of proxying.
require.Equal(t, http.StatusSeeOther, res.StatusCode)
require.False(t, nextCalled)
loc := res.Header.Get("Location")
require.NotEmpty(t, loc)
require.False(t, strings.HasPrefix(loc, "//"), "redirect %q must stay same-origin", loc)
parsed, err := url.Parse(loc)
require.NoError(t, err)
require.Empty(t, parsed.Scheme, "redirect %q must not carry a scheme", loc)
require.Empty(t, parsed.Host, "redirect %q must not carry a host", loc)
// The smuggled key is stripped and unrelated query params survive.
require.Empty(t, parsed.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam))
require.Equal(t, "1", parsed.Query().Get("x"))
})
}