Files
pad/internal/server/token_scopes_test.go
T
xarmian c10023ea8f fix(server): deny-by-default whitelist for API token scopes (TASK-667) (#192)
* fix(server): deny-by-default whitelist for API token scopes (TASK-667)

tokenScopeAllows previously fell open on unrecognized scopes and on
unparseable scope JSON. A typo like "read-only" silently granted full
access — exactly the kind of landmine that a fresh token minted by an
admin who misremembers the vocabulary would step on.

New policy (deny-by-default):
- Unparseable JSON → deny + warn (was allow). Data corruption or
  tampering should never fall open.
- Unrecognized scopes → never contribute to allow; all unknowns on a
  given request get a single warning log so operators can spot typos.
- Explicit wildcard "*" and "write" still allow all methods; "read"
  still allows safe methods only.
- Empty scope string and empty JSON array `[]` still allow — these
  represent legacy pre-enforcement rows we don't want to break on
  upgrade.

Test table updated:
- old "unknown scope allows GET/POST" flipped to deny
- new "read-only typo denies GET" regression pin
- new "unknown+write/wildcard still allow" guard rails confirming that
  a recognized allow-granting scope alongside an unknown one still
  grants (unknown is logged, not failing the request)
- old "invalid json allows all" flipped to deny

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): reject JSON null token scopes (TASK-667)

Addresses Codex P2 on PR #192: json.Unmarshal accepts the literal
\`null\` without error and leaves the target slice nil, so "scopes": "null"
would match the legacy empty-array allow-path and grant full access —
bypassing the new deny-by-default intent whenever a client-side
serializer emits null for a missing field.

- Gate the "unrestricted" path on the raw string being "", ["*"],
  [ "*" ], or [] only (with whitespace trimming on the outside). "null"
  no longer slips through.
- Post-unmarshal, any empty slice that wasn't one of those explicit
  allow-forms is logged as "non-array or null scopes; denying" and
  denied.
- New test cases: "json null denies POST" / "json null denies GET".

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): distinguish JSON null from empty array in token scopes (TASK-667)

Addresses Codex P2 on PR #192: the previous raw-string whitelist for
legacy empty-array tokens rejected valid whitespace-padded forms like
\`[ ]\` or \`[\\n]\` that some clients emit. Those decoded to a non-nil
empty slice, so a smarter check works: use the Go json package's
nil-vs-empty distinction.

- scopes == nil → JSON was literal null. Deny + warn (unchanged intent).
- scopes != nil && len == 0 → explicit empty array regardless of
  whitespace. Allow (legacy unrestricted form, as documented).
- scopes has entries → existing whitelist logic.

Empty-string fast path kept for the no-column case; wildcard fast path
now trims whitespace too.

New tests: \`[ ]\`, \`[\\n]\`, \`[\\t]\` empty arrays and \`[ "*" ]\`
wildcard all allow; \`null\` still denies.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 11:28:44 -04:00

84 lines
3.8 KiB
Go

package server
import (
"net/http"
"testing"
)
func TestTokenScopeAllows(t *testing.T) {
tests := []struct {
name string
scopes string
method string
path string
want bool
}{
// Wildcard scope
{"wildcard allows GET", `["*"]`, http.MethodGet, "/api/v1/test", true},
{"wildcard allows POST", `["*"]`, http.MethodPost, "/api/v1/test", true},
{"wildcard allows DELETE", `["*"]`, http.MethodDelete, "/api/v1/test", true},
// Empty/default scopes
{"empty string allows all", "", http.MethodPost, "/api/v1/test", true},
{"default wildcard allows all", `["*"]`, http.MethodPatch, "/api/v1/test", true},
// Read scope
{"read allows GET", `["read"]`, http.MethodGet, "/api/v1/test", true},
{"read allows HEAD", `["read"]`, http.MethodHead, "/api/v1/test", true},
{"read allows OPTIONS", `["read"]`, http.MethodOptions, "/api/v1/test", true},
{"read blocks POST", `["read"]`, http.MethodPost, "/api/v1/test", false},
{"read blocks DELETE", `["read"]`, http.MethodDelete, "/api/v1/test", false},
{"read blocks PATCH", `["read"]`, http.MethodPatch, "/api/v1/test", false},
// Write scope
{"write allows GET", `["write"]`, http.MethodGet, "/api/v1/test", true},
{"write allows POST", `["write"]`, http.MethodPost, "/api/v1/test", true},
{"write allows DELETE", `["write"]`, http.MethodDelete, "/api/v1/test", true},
// Invalid/unparseable JSON — now DENY (TASK-667 deny-by-default).
// Data corruption / tampering should not fall open.
{"invalid json denies all", "not-json", http.MethodPost, "/api/v1/test", false},
{"invalid json denies GET", "not-json", http.MethodGet, "/api/v1/test", false},
// JSON null MUST NOT fall through the legacy-unrestricted path.
// json.Unmarshal("null", &[]string) succeeds and leaves nil slice —
// without an explicit raw-string check we'd grant full access.
{"json null denies POST", "null", http.MethodPost, "/api/v1/test", false},
{"json null denies GET", "null", http.MethodGet, "/api/v1/test", false},
// Multiple scopes
{"read+write allows POST", `["read","write"]`, http.MethodPost, "/api/v1/test", true},
{"read only blocks PUT", `["read"]`, http.MethodPut, "/api/v1/test", false},
// Empty array still allows all — legacy "unrestricted" form. New
// tokens should use ["*"]. Whitespace-padded variants that clients
// might serialize also count as empty arrays.
{"empty array allows all", `[]`, http.MethodGet, "/api/v1/test", true},
{"empty array allows POST", `[]`, http.MethodPost, "/api/v1/test", true},
{"empty array with spaces", `[ ]`, http.MethodPost, "/api/v1/test", true},
{"empty array with newline", "[\n]", http.MethodPost, "/api/v1/test", true},
{"empty array with tab", "[\t]", http.MethodGet, "/api/v1/test", true},
{"wildcard with spaces", `[ "*" ]`, http.MethodPost, "/api/v1/test", true},
// Unrecognized scopes — TASK-667 deny-by-default. A typo like
// "read-only" must NOT silently grant full access.
{"unknown scope only denies GET", `["docs"]`, http.MethodGet, "/api/v1/test", false},
{"unknown scope only denies POST", `["repo"]`, http.MethodPost, "/api/v1/test", false},
{"read-only typo denies GET", `["read-only"]`, http.MethodGet, "/api/v1/test", false},
{"unknown+read allows GET", `["docs","read"]`, http.MethodGet, "/api/v1/test", true},
{"unknown+read blocks POST", `["docs","read"]`, http.MethodPost, "/api/v1/test", false},
{"unknown+wildcard still allows POST", `["docs","*"]`, http.MethodPost, "/api/v1/test", true},
{"unknown+write still allows DELETE", `["docs","write"]`, http.MethodDelete, "/api/v1/test", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tokenScopeAllows(tt.scopes, tt.method, tt.path)
if got != tt.want {
t.Errorf("tokenScopeAllows(%q, %q, %q) = %v, want %v",
tt.scopes, tt.method, tt.path, got, tt.want)
}
})
}
}