mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 17:43:26 +00:00
c10023ea8f
* 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).
84 lines
3.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|