Files
krow_backend/go-api/internal/oauth/abuse_test.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

375 lines
14 KiB
Go

package oauth
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// Abuse cases and input limits.
//
// The property every test here shares: a refusal must not teach the caller
// anything. Not whether a token existed, not whether an account is suspended
// rather than deleted, not what the database is called, and never the value of
// a credential that was presented.
/* ── Nothing sensitive reaches a response ───────────────────────────────── */
// The broadest check in this file: drive every failure path with known secret
// values, and assert none of them comes back.
func TestNoSecretEverAppearsInAResponse(t *testing.T) {
h := newHarness(t)
clientID := h.register()
const (
secretVerifier = "SENTINELverifier0123456789abcdefghijklmnop"
secretCode = "SENTINELcodevalue"
secretToken = "SENTINELtokenvalue"
)
bodies := map[string]string{}
// A failed exchange, with a sentinel code and verifier.
bodies["bad code"] = h.exchange(clientID, secretCode, secretVerifier).Body.String()
// A real code with the wrong verifier.
real := h.authorizeOK(clientID, verifier43)
bodies["bad verifier"] = h.exchange(clientID, real, secretVerifier).Body.String()
// A refresh with a sentinel token.
bodies["bad refresh"] = h.token(url.Values{
"grant_type": {"refresh_token"}, "refresh_token": {secretToken}, "client_id": {clientID},
}).Body.String()
// Revocation of an unknown token.
form := url.Values{"token": {secretToken}}
req := httptest.NewRequest(http.MethodPost, "/oauth/revoke", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.server.RevokeHandler().ServeHTTP(rec, req)
bodies["revoke unknown"] = rec.Body.String()
for where, body := range bodies {
for what, secret := range map[string]string{
"code_verifier": secretVerifier,
"authorization code": secretCode,
"token": secretToken,
} {
if strings.Contains(body, secret) {
t.Errorf("%s: the response echoes the presented %s:\n%s", where, what, body)
}
}
// Nor may it leak the shape of the system.
for _, tell := range []string{"SQLSTATE", "pq:", "pgx", "oauth_tokens", "oauth_grants",
"password", "Krow-force", "relation", "column"} {
if strings.Contains(body, tell) {
t.Errorf("%s: the response leaks an internal detail (%q):\n%s", where, tell, body)
}
}
}
}
/* ── Replay ─────────────────────────────────────────────────────────────── */
// Ten attempts to spend one code. Exactly one may succeed.
func TestAuthorizationCodeReplayUnderLoad(t *testing.T) {
h := newHarness(t)
clientID := h.register()
code := h.authorizeOK(clientID, verifier43)
succeeded := 0
for i := 0; i < 10; i++ {
if h.exchange(clientID, code, verifier43).Code == http.StatusOK {
succeeded++
}
}
if succeeded != 1 {
t.Errorf("%d of 10 exchanges of the same code succeeded, want exactly 1", succeeded)
}
}
// The same, concurrently. A single-use credential redeemed by two racing
// callers must be spent exactly once — this is the property that
// UPDATE … RETURNING buys, and the one a SELECT-then-UPDATE would lose.
// Run with -race.
func TestConcurrentCodeRedemptionSpendsItOnce(t *testing.T) {
h := newHarness(t)
clientID := h.register()
code := h.authorizeOK(clientID, verifier43)
const racers = 8
var wg sync.WaitGroup
var mu sync.Mutex
succeeded := 0
for i := 0; i < racers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if h.exchange(clientID, code, verifier43).Code == http.StatusOK {
mu.Lock()
succeeded++
mu.Unlock()
}
}()
}
wg.Wait()
if succeeded != 1 {
t.Errorf("%d of %d concurrent redemptions succeeded, want exactly 1", succeeded, racers)
}
}
// Concurrent refresh of the same token: one rotation, not several. Two
// successes would mean two live families from one credential.
// Run with -race.
func TestConcurrentRefreshRotatesOnce(t *testing.T) {
h := newHarness(t)
clientID := h.register()
code := h.authorizeOK(clientID, verifier43)
tokens := decodeTokens(t, h.exchange(clientID, code, verifier43))
const racers = 8
var wg sync.WaitGroup
var mu sync.Mutex
succeeded := 0
for i := 0; i < racers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rec := h.token(url.Values{
"grant_type": {"refresh_token"}, "refresh_token": {tokens.RefreshToken},
"client_id": {clientID},
})
if rec.Code == http.StatusOK {
mu.Lock()
succeeded++
mu.Unlock()
}
}()
}
wg.Wait()
if succeeded != 1 {
t.Errorf("%d of %d concurrent refreshes succeeded, want exactly 1 — "+
"a refresh token was double-spent", succeeded, racers)
}
}
/* ── Open redirect ──────────────────────────────────────────────────────── */
// Every shape of redirect tampering, each of which has been a real CVE
// somewhere. None may be honoured, and none may be answered WITH a redirect —
// redirecting an error to an unvalidated URI is the open redirect itself.
func TestOpenRedirectAttempts(t *testing.T) {
h := newHarness(t)
clientID := h.register()
for name, redirect := range map[string]string{
"different host": "https://attacker.example/cb",
"prefix extension": testRedirect + ".attacker.example",
"path traversal": testRedirect + "/../../evil",
"userinfo trick": "https://claude.example.test@attacker.example/cb",
"added query": testRedirect + "?next=https://attacker.example",
"protocol swap": strings.Replace(testRedirect, "https", "http", 1),
"case variation": strings.ToUpper(testRedirect),
"trailing slash": testRedirect + "/",
"double slash": "//attacker.example/cb",
"encoded traversal": testRedirect + "/%2e%2e/evil",
"null byte": testRedirect + "\x00.attacker.example",
"newline injection": testRedirect + "\nLocation: https://attacker.example",
"javascript": "javascript:alert(1)",
"completely missing": "",
} {
t.Run(name, func(t *testing.T) {
rec := h.authorize(map[string]string{
"client_id": clientID, "redirect_uri": redirect, "response_type": "code",
"state": "xyz", "code_challenge": ChallengeFor(verifier43),
"code_challenge_method": "S256", "resource": testResource,
})
if rec.Code == http.StatusFound {
location := rec.Header().Get("Location")
t.Fatalf("answered with a redirect to %q — an unregistered target "+
"must produce a direct error, never a redirect", location)
}
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
if strings.Contains(rec.Body.String(), "attacker.example") {
t.Error("the error echoes the attacker's host back")
}
})
}
}
/* ── Malformed and oversized input ──────────────────────────────────────── */
func TestRegistrationRejectsMalformedBodies(t *testing.T) {
h := newHarness(t)
for name, body := range map[string]string{
"not json": `not json at all`,
"truncated": `{"client_name":`,
"null": `null`,
"array": `[]`,
"deeply nested": `{"client_name":` + strings.Repeat(`[`, 2000) + strings.Repeat(`]`, 2000) + `}`,
"empty": ``,
"wrong types": `{"client_name":123,"redirect_uris":"not-an-array"}`,
"huge name": `{"client_name":"` + strings.Repeat("A", 100_000) + `","redirect_uris":["https://a.test/cb"]}`,
"too many uris": `{"client_name":"x","redirect_uris":[` + strings.TrimSuffix(strings.Repeat(`"https://a.test/cb",`, 50), ",") + `]}`,
"oversized body": `{"client_name":"` + strings.Repeat("A", 20<<10) + `"}`,
} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body))
rec := httptest.NewRecorder()
// A panic here fails the test by crashing it, which is the assertion.
h.server.RegisterHandler().ServeHTTP(rec, req)
if rec.Code == http.StatusCreated {
// Only the "huge name" case may legitimately succeed, truncated.
if name != "huge name" {
t.Errorf("status = %d; a malformed registration was accepted", rec.Code)
}
return
}
if rec.Code < 400 || rec.Code >= 500 {
t.Errorf("status = %d, want a 4xx", rec.Code)
}
})
}
}
// A client name is stored, shown on a consent screen, and attacker-controlled.
// It must be bounded, or registration becomes free storage.
func TestClientNameIsBounded(t *testing.T) {
h := newHarness(t)
body := `{"client_name":"` + strings.Repeat("A", 5000) + `","redirect_uris":["https://a.test/cb"]}`
rec := httptest.NewRecorder()
h.server.RegisterHandler().ServeHTTP(rec,
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d", rec.Code)
}
var stored string
if err := h.h.Pool.QueryRow(context.Background(),
`SELECT client_name FROM oauth_clients ORDER BY created_date DESC LIMIT 1`).Scan(&stored); err != nil {
t.Fatalf("read: %v", err)
}
if len(stored) > 200 {
t.Errorf("stored client_name is %d characters; the column's CHECK allows 200", len(stored))
}
}
func TestTokenEndpointRejectsMalformedRequests(t *testing.T) {
h := newHarness(t)
for name, tc := range map[string]struct {
body string
contentType string
}{
"no content type": {"grant_type=authorization_code", ""},
"json body": {`{"grant_type":"authorization_code"}`, "application/json"},
"empty": {"", "application/x-www-form-urlencoded"},
"garbage": {"%%%%", "application/x-www-form-urlencoded"},
"huge": {"grant_type=authorization_code&code=" + strings.Repeat("A", 200_000), "application/x-www-form-urlencoded"},
"repeated params": {"grant_type=authorization_code&grant_type=password", "application/x-www-form-urlencoded"},
"null grant": {"grant_type=", "application/x-www-form-urlencoded"},
"unknown grant": {"grant_type=magic", "application/x-www-form-urlencoded"},
"injection in code": {"grant_type=authorization_code&code=' OR 1=1 --&client_id=x&redirect_uri=y&code_verifier=" +
strings.Repeat("a", 43), "application/x-www-form-urlencoded"},
} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(tc.body))
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
rec := httptest.NewRecorder()
h.server.TokenHandler().ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Errorf("a malformed token request succeeded: %s", rec.Body.String())
}
if rec.Code >= 500 {
t.Errorf("status = %d; a malformed request must not be an internal error: %s",
rec.Code, rec.Body.String())
}
})
}
}
/* ── Scope escalation ───────────────────────────────────────────────────── */
// krow.write must be unreachable from every angle: registration, authorization,
// and the consent POST.
func TestWriteScopeIsUnreachable(t *testing.T) {
h := newHarness(t)
t.Run("at registration", func(t *testing.T) {
body := `{"client_name":"x","redirect_uris":["` + testRedirect + `"],"scope":"` + ScopeWrite + `"}`
rec := httptest.NewRecorder()
h.server.RegisterHandler().ServeHTTP(rec,
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
if rec.Code == http.StatusCreated {
t.Error("a client registered for krow.write")
}
})
t.Run("at authorization", func(t *testing.T) {
clientID := h.register()
rec := h.authorize(map[string]string{
"client_id": clientID, "redirect_uri": testRedirect, "response_type": "code",
"state": "xyz", "code_challenge": ChallengeFor(verifier43),
"code_challenge_method": "S256", "resource": testResource,
"scope": ScopeRead + " " + ScopeWrite,
})
loc, _ := url.Parse(rec.Header().Get("Location"))
if loc.Query().Get("code") != "" {
t.Error("an authorization requesting krow.write produced a code")
}
})
t.Run("no issued token carries it", func(t *testing.T) {
clientID := h.register()
code := h.authorizeOK(clientID, verifier43)
tokens := decodeTokens(t, h.exchange(clientID, code, verifier43))
if strings.Contains(tokens.Scope, ScopeWrite) {
t.Errorf("an issued token carries %q", tokens.Scope)
}
})
}
/* ── Cache and transport headers ────────────────────────────────────────── */
// A credential-bearing response must never be cached, and no endpoint may put
// a token in a URL.
func TestSensitiveResponsesAreNotCacheable(t *testing.T) {
h := newHarness(t)
clientID := h.register()
code := h.authorizeOK(clientID, verifier43)
rec := h.exchange(clientID, code, verifier43)
for header, want := range map[string]string{
"Cache-Control": "no-store",
"Pragma": "no-cache",
} {
if got := rec.Header().Get(header); !strings.Contains(got, want) {
t.Errorf("%s = %q, want it to contain %q", header, got, want)
}
}
// The authorization redirect carries a code in its query — that is the
// protocol — but it must never carry a token.
approved := h.authorize(authorizeParamsFor(clientID, verifier43))
if strings.Contains(approved.Header().Get("Location"), "access_token") {
t.Error("an access token appeared in a redirect URL")
}
}