502 lines
18 KiB
Go
502 lines
18 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
/* ── Consent is required ────────────────────────────────────────────────── */
|
|
|
|
// A GET must ASK, not grant. This is the Phase 4 behaviour change, asserted
|
|
// directly: before, a signed-in user's authorization was approved on sight.
|
|
func TestAuthorizeRendersConsentRatherThanIssuingACode(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
|
|
rec := h.authorize(authorizeParamsFor(clientID, verifier43))
|
|
|
|
if rec.Code == http.StatusFound {
|
|
t.Fatalf("a GET issued a code without asking: %s", rec.Header().Get("Location"))
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 with a consent page", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("Content-Type = %q, want text/html", ct)
|
|
}
|
|
|
|
// No grant row may exist yet: rendering a question must not spend anything.
|
|
var codes int
|
|
if err := h.h.Pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM oauth_grants`).Scan(&codes); err != nil {
|
|
t.Fatalf("count grants: %v", err)
|
|
}
|
|
if codes != 0 {
|
|
t.Errorf("%d authorization codes exist after merely rendering consent", codes)
|
|
}
|
|
}
|
|
|
|
// The page must tell a person what they are agreeing to, in their terms.
|
|
func TestConsentPageShowsWhatIsBeingGranted(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
rec, _ := h.consent(authorizeParamsFor(clientID, verifier43))
|
|
body := rec.Body.String()
|
|
|
|
for name, want := range map[string]string{
|
|
"client name": "Test Client",
|
|
"signed-in user": "oauth-user@example.test",
|
|
"organisation": "OAuth Test",
|
|
"resource": testResource,
|
|
"approve control": "approve",
|
|
"deny control": "deny",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the consent page does not show the %s (%q)", name, want)
|
|
}
|
|
}
|
|
|
|
// A person asked to approve "krow.read" has not been asked anything.
|
|
if strings.Contains(body, ScopeRead) && !strings.Contains(body, "Read workforce activity") {
|
|
t.Error("the page shows a raw scope identifier without explaining it")
|
|
}
|
|
// krow.write must never appear on a screen for a flow that cannot grant it.
|
|
if strings.Contains(body, ScopeWrite) {
|
|
t.Error("the consent page mentions krow.write")
|
|
}
|
|
}
|
|
|
|
// The client name is attacker-controlled: anyone may register a client called
|
|
// <script>. It must be escaped, not rendered.
|
|
func TestConsentPageEscapesTheClientName(t *testing.T) {
|
|
h := newHarness(t)
|
|
|
|
const payload = `<script>alert('xss')</script>`
|
|
body, _ := jsonMarshal(registrationRequest{
|
|
ClientName: payload, RedirectURIs: []string{testRedirect},
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
h.server.RegisterHandler().ServeHTTP(rec,
|
|
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
|
|
var reg registrationResponse
|
|
_ = jsonUnmarshal(rec.Body.Bytes(), ®)
|
|
|
|
page, _ := h.consent(authorizeParamsFor(reg.ClientID, verifier43))
|
|
if strings.Contains(page.Body.String(), "<script>alert") {
|
|
t.Fatal("a registered client name was rendered as live HTML")
|
|
}
|
|
if !strings.Contains(page.Body.String(), "<script>") {
|
|
t.Error("the client name does not appear escaped; check it is shown at all")
|
|
}
|
|
}
|
|
|
|
/* ── Approve and deny ───────────────────────────────────────────────────── */
|
|
|
|
func TestConsentApproveIssuesACode(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
params := authorizeParamsFor(clientID, verifier43)
|
|
_, csrf := h.consent(params)
|
|
|
|
rec := h.decide(params, "approve", csrf)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
}
|
|
loc, _ := url.Parse(rec.Header().Get("Location"))
|
|
if loc.Query().Get("code") == "" {
|
|
t.Error("approve issued no code")
|
|
}
|
|
if loc.Query().Get("state") != "xyz" {
|
|
t.Errorf("state = %q, want xyz", loc.Query().Get("state"))
|
|
}
|
|
}
|
|
|
|
// Denial must reach the client as access_denied, at its registered redirect,
|
|
// with state intact and NO code.
|
|
func TestConsentDenyReturnsAccessDeniedAndNoCode(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
params := authorizeParamsFor(clientID, verifier43)
|
|
_, csrf := h.consent(params)
|
|
|
|
rec := h.decide(params, "deny", csrf)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
}
|
|
|
|
loc, err := url.Parse(rec.Header().Get("Location"))
|
|
if err != nil {
|
|
t.Fatalf("Location: %v", err)
|
|
}
|
|
if !strings.HasPrefix(loc.String(), testRedirect) {
|
|
t.Fatalf("denial went to %q, not the registered redirect", loc)
|
|
}
|
|
if got := loc.Query().Get("error"); got != "access_denied" {
|
|
t.Errorf("error = %q, want access_denied", got)
|
|
}
|
|
if got := loc.Query().Get("state"); got != "xyz" {
|
|
t.Errorf("state = %q, want xyz — the client needs it to match the response", got)
|
|
}
|
|
if loc.Query().Get("code") != "" {
|
|
t.Error("a denial returned an authorization code")
|
|
}
|
|
|
|
// And nothing was written.
|
|
var codes int
|
|
_ = h.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM oauth_grants`).Scan(&codes)
|
|
if codes != 0 {
|
|
t.Errorf("%d authorization codes exist after a denial", codes)
|
|
}
|
|
}
|
|
|
|
// A POST with no decision must re-ask, never infer approval.
|
|
func TestConsentWithNoDecisionDoesNotApprove(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
params := authorizeParamsFor(clientID, verifier43)
|
|
_, csrf := h.consent(params)
|
|
|
|
rec := h.decide(params, "", csrf)
|
|
if rec.Code == http.StatusFound {
|
|
t.Fatalf("a decision-less POST was treated as a decision: %s", rec.Header().Get("Location"))
|
|
}
|
|
var codes int
|
|
_ = h.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM oauth_grants`).Scan(&codes)
|
|
if codes != 0 {
|
|
t.Error("a decision-less POST issued a code")
|
|
}
|
|
}
|
|
|
|
/* ── CSRF ───────────────────────────────────────────────────────────────── */
|
|
|
|
// Without the form's token, a cross-site POST must not be able to approve.
|
|
// This is what stops a page on the internet connecting a client to somebody's
|
|
// workspace while they are signed in.
|
|
func TestConsentRequiresTheFormCSRFToken(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
params := authorizeParamsFor(clientID, verifier43)
|
|
_, valid := h.consent(params)
|
|
|
|
for name, token := range map[string]string{
|
|
"absent": "",
|
|
"garbage": "not-the-token",
|
|
"flipped": strings.Repeat("0", len(valid)),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
rec := h.decide(params, "approve", token)
|
|
if rec.Code == http.StatusFound {
|
|
t.Fatalf("approval succeeded without a valid CSRF token: %s",
|
|
rec.Header().Get("Location"))
|
|
}
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The real token still works, or the test above would pass vacuously.
|
|
if rec := h.decide(params, "approve", valid); rec.Code != http.StatusFound {
|
|
t.Errorf("the valid CSRF token was rejected: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
/* ── Validation still applies on the POST ───────────────────────────────── */
|
|
|
|
// The POST must be validated as strictly as the GET. Trusting the form's
|
|
// hidden fields would let a tampered POST change the redirect, the resource or
|
|
// the PKCE challenge after the person read the page.
|
|
func TestConsentPostRevalidatesEveryParameter(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
params := authorizeParamsFor(clientID, verifier43)
|
|
_, csrf := h.consent(params)
|
|
|
|
tamper := func(k, v string) map[string]string {
|
|
out := map[string]string{}
|
|
for key, val := range params {
|
|
out[key] = val
|
|
}
|
|
out[k] = v
|
|
return out
|
|
}
|
|
|
|
t.Run("redirect swapped", func(t *testing.T) {
|
|
rec := h.decide(tamper("redirect_uri", "https://attacker.example/steal"), "approve", csrf)
|
|
if rec.Code == http.StatusFound {
|
|
t.Fatalf("a tampered redirect_uri was honoured: %s", rec.Header().Get("Location"))
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("resource swapped", func(t *testing.T) {
|
|
rec := h.decide(tamper("resource", "https://elsewhere.test/mcp"), "approve", csrf)
|
|
loc, _ := url.Parse(rec.Header().Get("Location"))
|
|
if loc.Query().Get("code") != "" {
|
|
t.Error("a tampered resource still produced a code")
|
|
}
|
|
})
|
|
|
|
t.Run("pkce removed", func(t *testing.T) {
|
|
rec := h.decide(tamper("code_challenge", ""), "approve", csrf)
|
|
loc, _ := url.Parse(rec.Header().Get("Location"))
|
|
if loc.Query().Get("code") != "" {
|
|
t.Error("PKCE was dropped at the consent POST")
|
|
}
|
|
})
|
|
|
|
t.Run("scope escalated", func(t *testing.T) {
|
|
rec := h.decide(tamper("scope", ScopeWrite), "approve", csrf)
|
|
loc, _ := url.Parse(rec.Header().Get("Location"))
|
|
if loc.Query().Get("code") != "" {
|
|
t.Error("krow.write was granted through the consent POST")
|
|
}
|
|
if got := loc.Query().Get("error"); got != errInvalidScope {
|
|
t.Errorf("error = %q, want %q", got, errInvalidScope)
|
|
}
|
|
})
|
|
}
|
|
|
|
// An anonymous visitor must be sent to the existing login, not shown a consent
|
|
// screen for nobody.
|
|
func TestConsentRequiresAuthentication(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
h.session.signedIn = false
|
|
|
|
rec := h.authorize(authorizeParamsFor(clientID, verifier43))
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want a redirect to login", rec.Code)
|
|
}
|
|
if !strings.HasPrefix(rec.Header().Get("Location"), "/login?") {
|
|
t.Errorf("Location = %q, want the existing login", rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
// The consent page must never be cached: it names a client and an organisation,
|
|
// and the next person on a shared machine must not see it.
|
|
func TestConsentPageIsNotCacheable(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register()
|
|
rec, _ := h.consent(authorizeParamsFor(clientID, verifier43))
|
|
|
|
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
|
t.Errorf("Cache-Control = %q, want no-store", got)
|
|
}
|
|
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
|
|
t.Errorf("X-Frame-Options = %q, want DENY — a consent screen must not be framed", got)
|
|
}
|
|
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") {
|
|
t.Error("the CSP does not forbid framing")
|
|
}
|
|
}
|
|
|
|
// Thin wrappers so the test reads as prose rather than as error handling.
|
|
func jsonMarshal(v any) (string, error) {
|
|
b, err := json.Marshal(v)
|
|
return string(b), err
|
|
}
|
|
|
|
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
|
|
|
/* ── The consent page's CSP ─────────────────────────────────────────────── */
|
|
|
|
// The regression test for the bug a real browser found and httptest could not.
|
|
//
|
|
// `form-action 'self'` blocked the consent form before it could submit, because
|
|
// a consent form's successful submission ends at the client's registered
|
|
// callback — always cross-origin. These tests assert the policy admits exactly
|
|
// that callback and nothing else.
|
|
|
|
func TestConsentCSPAllowsTheRegisteredRedirectOrigin(t *testing.T) {
|
|
h := newHarness(t)
|
|
|
|
body, _ := jsonMarshal(registrationRequest{
|
|
ClientName: "Claude", RedirectURIs: []string{"https://claude.ai/api/mcp/auth_callback"},
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
h.server.RegisterHandler().ServeHTTP(rec,
|
|
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
|
|
var reg registrationResponse
|
|
_ = jsonUnmarshal(rec.Body.Bytes(), ®)
|
|
|
|
page, _ := h.consent(authorizeParamsForClient(reg.ClientID, verifier43, "https://claude.ai/api/mcp/auth_callback"))
|
|
csp := page.Header().Get("Content-Security-Policy")
|
|
|
|
// The registered ORIGIN — not the full URI. A CSP source is an origin; a
|
|
// path would be matched as a prefix and is not what a navigation is
|
|
// checked against.
|
|
if !strings.Contains(csp, "https://claude.ai") {
|
|
t.Errorf("CSP does not permit the registered redirect origin:\n %s", csp)
|
|
}
|
|
if strings.Contains(csp, "/api/mcp/auth_callback") {
|
|
t.Errorf("CSP carries a path rather than an origin:\n %s", csp)
|
|
}
|
|
// Everything that must survive the change.
|
|
for _, required := range []string{
|
|
"default-src 'none'",
|
|
"style-src 'unsafe-inline'",
|
|
"frame-ancestors 'none'",
|
|
"form-action 'self'",
|
|
} {
|
|
if !strings.Contains(csp, required) {
|
|
t.Errorf("CSP lost %q:\n %s", required, csp)
|
|
}
|
|
}
|
|
// And everything that must never appear.
|
|
for _, forbidden := range []string{"form-action *", "'unsafe-eval'", "'unsafe-inline' 'unsafe", "*;", " *"} {
|
|
if strings.Contains(csp, forbidden) {
|
|
t.Errorf("CSP contains a broad source %q:\n %s", forbidden, csp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// One client's callback must never appear in another client's policy.
|
|
func TestConsentCSPDoesNotLeakBetweenClients(t *testing.T) {
|
|
h := newHarness(t)
|
|
|
|
register := func(name, redirect string) string {
|
|
t.Helper()
|
|
body, _ := jsonMarshal(registrationRequest{ClientName: name, RedirectURIs: []string{redirect}})
|
|
rec := httptest.NewRecorder()
|
|
h.server.RegisterHandler().ServeHTTP(rec,
|
|
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
|
|
var reg registrationResponse
|
|
_ = jsonUnmarshal(rec.Body.Bytes(), ®)
|
|
return reg.ClientID
|
|
}
|
|
|
|
first := register("First", "https://first.example.test/cb")
|
|
second := register("Second", "https://second.example.test/cb")
|
|
|
|
firstPage, _ := h.consent(authorizeParamsForClient(first, verifier43, "https://first.example.test/cb"))
|
|
firstCSP := firstPage.Header().Get("Content-Security-Policy")
|
|
|
|
if !strings.Contains(firstCSP, "https://first.example.test") {
|
|
t.Errorf("the first client's own origin is missing:\n %s", firstCSP)
|
|
}
|
|
if strings.Contains(firstCSP, "second.example.test") {
|
|
t.Errorf("another client's redirect origin leaked into this policy:\n %s", firstCSP)
|
|
}
|
|
|
|
secondPage, _ := h.consent(authorizeParamsForClient(second, verifier43, "https://second.example.test/cb"))
|
|
secondCSP := secondPage.Header().Get("Content-Security-Policy")
|
|
if strings.Contains(secondCSP, "first.example.test") {
|
|
t.Errorf("the first client's origin leaked into the second's policy:\n %s", secondCSP)
|
|
}
|
|
}
|
|
|
|
// An unrelated origin must never be permitted.
|
|
func TestConsentCSPExcludesUnrelatedOrigins(t *testing.T) {
|
|
h := newHarness(t)
|
|
clientID := h.register() // registered for testRedirect only
|
|
page, _ := h.consent(authorizeParamsFor(clientID, verifier43))
|
|
csp := page.Header().Get("Content-Security-Policy")
|
|
|
|
for _, unrelated := range []string{"https://evil.test", "https://attacker.example", "https://google.com"} {
|
|
if strings.Contains(csp, unrelated) {
|
|
t.Errorf("CSP permits an unrelated origin %q:\n %s", unrelated, csp)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── redirectOrigins, directly ──────────────────────────────────────────── */
|
|
|
|
// The helper carries the whole safety argument, so it is tested on its own
|
|
// rather than only through a rendered page.
|
|
func TestRedirectOrigins(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
in []string
|
|
want []string
|
|
}{
|
|
"https with path": {
|
|
[]string{"https://claude.ai/api/mcp/auth_callback"},
|
|
[]string{"https://claude.ai"},
|
|
},
|
|
"port preserved": {
|
|
[]string{"https://app.example.test:8443/cb"},
|
|
[]string{"https://app.example.test:8443"},
|
|
},
|
|
"loopback http is kept, per registration rules": {
|
|
[]string{"http://127.0.0.1:33418/callback"},
|
|
[]string{"http://127.0.0.1:33418"},
|
|
},
|
|
"localhost loopback": {
|
|
[]string{"http://localhost:3000/cb"},
|
|
[]string{"http://localhost:3000"},
|
|
},
|
|
"multiple registered URIs": {
|
|
[]string{"https://claude.ai/cb", "http://127.0.0.1:33418/callback"},
|
|
[]string{"https://claude.ai", "http://127.0.0.1:33418"},
|
|
},
|
|
"duplicates collapse to one source": {
|
|
[]string{"https://claude.ai/one", "https://claude.ai/two", "https://claude.ai/three"},
|
|
[]string{"https://claude.ai"},
|
|
},
|
|
"order is the order registered": {
|
|
[]string{"https://b.test/cb", "https://a.test/cb"},
|
|
[]string{"https://b.test", "https://a.test"},
|
|
},
|
|
// Everything below must be DROPPED, never broadened.
|
|
"relative uri": {[]string{"/callback"}, nil},
|
|
"no host": {[]string{"https://"}, nil},
|
|
"custom scheme": {[]string{"myapp://callback"}, nil},
|
|
"javascript scheme": {[]string{"javascript:alert(1)"}, nil},
|
|
"data scheme": {[]string{"data:text/html,x"}, nil},
|
|
"wildcard host": {[]string{"https://*.evil.test/cb"}, nil},
|
|
"semicolon injection": {[]string{"https://evil.test;form-action *"}, nil},
|
|
"space injection": {[]string{"https://evil.test /cb"}, nil},
|
|
"empty": {[]string{""}, nil},
|
|
"whitespace only": {[]string{" "}, nil},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
got := redirectOrigins(tc.in)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("redirectOrigins(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
for i := range tc.want {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("origin[%d] = %q, want %q", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A dropped URI must never widen the policy — the page still renders, and the
|
|
// form-action list is simply shorter.
|
|
func TestABadRedirectURICannotWidenTheCSP(t *testing.T) {
|
|
csp := consentCSP([]string{"https://evil.test;form-action *", "myapp://cb", "https://*.evil.test"})
|
|
|
|
if strings.Contains(csp, "*") {
|
|
t.Errorf("a malformed redirect URI introduced a wildcard:\n %s", csp)
|
|
}
|
|
if strings.Count(csp, ";") != 3 {
|
|
t.Errorf("the header has %d separators, want 3 — a URI broke out of its directive:\n %s",
|
|
strings.Count(csp, ";"), csp)
|
|
}
|
|
if !strings.Contains(csp, "form-action 'self'") {
|
|
t.Errorf("form-action lost 'self':\n %s", csp)
|
|
}
|
|
// With every URI dropped, the policy is exactly the strict one — which
|
|
// blocks the flow visibly rather than permitting more.
|
|
if strings.Contains(csp, "evil.test") {
|
|
t.Errorf("a dropped URI still reached the policy:\n %s", csp)
|
|
}
|
|
}
|
|
|
|
// authorizeParamsForClient is authorizeParamsFor with an explicit redirect, so
|
|
// a test can drive a client registered for something other than testRedirect.
|
|
func authorizeParamsForClient(clientID, verifier, redirect string) map[string]string {
|
|
p := authorizeParamsFor(clientID, verifier)
|
|
p["redirect_uri"] = redirect
|
|
return p
|
|
}
|