From 79d3b008b0208cf86c4686966efbe9614cc3639c Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 10:56:40 -0500 Subject: [PATCH 01/17] test(security): add cross-cutting regression suite Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 92 ++++ test/security/security_fuzz_test.go | 80 +++ test/security/security_regression_test.go | 495 ++++++++++++++++++ .../security-regression-v1alpha1.golden.json | 85 +++ 4 files changed, 752 insertions(+) create mode 100644 docs/security/regression-suite.md create mode 100644 test/security/security_fuzz_test.go create mode 100644 test/security/security_regression_test.go create mode 100644 test/security/testdata/security-regression-v1alpha1.golden.json diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md new file mode 100644 index 0000000..c6a2bc6 --- /dev/null +++ b/docs/security/regression-suite.md @@ -0,0 +1,92 @@ +# Security regression suite + +Status: executable evidence for issue #28 at the current `main` contract. This +report covers repository components that exist. It does not qualify future +production storage, queue, worker, provider-adapter, telemetry-export, or +support-bundle implementations. + +## Public-route security matrix + +[`TestPublicRouteSecurityMatrix`](../../test/security/security_regression_test.go) +derives the complete operation set from the validated OpenAPI artifact. The +test fails if a public operation is added without a matrix row. Its checked-in +result is +[`security-regression-v1alpha1.golden.json`](../../test/security/testdata/security-regression-v1alpha1.golden.json). + +| Operation | Authenticated member | Authenticated outsider | Missing or invalid credential | +| --- | --- | --- | --- | +| `listWorkspaces` | `200`, authorized row returned | `200`, denied rows removed before pagination | `401` | +| `createWorkspace` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` | +| `getWorkspace` | `200` | `403` | `401` | +| `replaceWorkspace` | `202` | `403` | `401` | +| `deleteWorkspace` | Authorization passes; `409` protects the retained Policy child | `403` | `401` | +| `replaceWorkspaceStatus` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` | +| `getOperation` | `200` | `403` | `401` | + +Workspace creation and status replacement intentionally have no tenant allow +case. The closed authorization matrix reserves both actions. Treating a `2xx` +response as required evidence would weaken the security contract. Their +positive path proves that a valid credential reaches the authorization layer; +their invariant is a tenant `403` for every role. + +## Retained untrusted-input and fuzz corpus + +The following fuzz targets run their seed corpus during ordinary `go test` and +remain available for time-bounded fuzzing: + +| Boundary | Target | Retained input classes | +| --- | --- | --- | +| Public HTTP routes | `FuzzReferencePublicBoundary` | alternate token carrier, encoded path, duplicate JSON member, unknown credential-like field, trailing JSON, invalid UTF-8, depth bomb, webhook/remote-reference shape, oversized body, invalid credential | +| Bearer extraction | `FuzzExtractBearer`, `FuzzCredentialTrailerScrubbing` | header/query/cookie/trailer ambiguity, divergent query views, body and trailer aliasing, arbitrary casing | +| OIDC/JWKS | deterministic negative corpus in `internal/adapters/identity/oidc` | non-canonical JWT segments, duplicate members, forbidden remote key headers, claim failures, redirect/cookie response state, malformed and over-bound keys | +| Resource admission | `FuzzAdmitRaw` | every resource kind, duplicate and unknown fields, invalid Unicode, depth/node/byte bounds, trailing values | +| Closed domain values | fuzz targets under `internal/core/domain` | invalid sum types, identifiers, hierarchy, role/action vocabulary, operations, conditions, audit canonicalization, credentials, and reconciliation digests | + +The public-boundary target asserts panic freedom, bounded JSON responses, +mandatory no-store/nosniff/correlation headers, and bearer-canary absence. The +OpenAPI validator independently rejects remote servers, remote references, and +webhook expansion. No provider response parser exists yet; provider-specific +malicious-response corpora belong with the adapters introduced by issues #38 +and #41. + +Run the public-boundary fuzzer for one minute with: + +```sh +go test ./test/security -run '^$' -fuzz '^FuzzReferencePublicBoundary$' -fuzztime=1m +``` + +## Redaction report + +| Surface named by issue #28 | Implemented evidence | Result | +| --- | --- | --- | +| Plans and reconciliation state | `TestOpaqueValuesRejectGenericSerializationAndRedactDiagnostics` covers plans, evidence, effects, attempts, leases, queue/idempotency state, proofs, and `slog` output | Secret and identity canaries absent; generic serialization rejected | +| Credential state | `TestCredentialValuesRedactAndRejectSerialization`, `FuzzSourceMaterialSafety`, and broker lease/lifecycle tests cover source/session material, requests, brokers, leases, rotation, errors, and destruction | Raw material remains callback-bounded; diagnostics redact; serialization rejected | +| Identity and bearer state | Identity diagnostic/serialization tests, bearer canary tests, OIDC negative corpus, and the route matrix cover principals, request carriers, challenges, problems, and headers | Raw token and identity claims absent from output; rejected request carriers scrubbed | +| Audit and privileged state | `TestOpaqueRuntimeValuesForbidLossyGenericSerialization` and `TestAdministrationDiagnosticsAndSerializationAreSafe` cover audit/elevation values, nested containers, errors, formatting, and `slog` | Canaries absent; opaque runtime values reject lossy serialization | +| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer canaries absent | +| Logs | Package redaction tests send every sensitive value through `fmt` and `slog` | Canaries absent from implemented logging surfaces | +| Traces | No tracer or trace exporter exists | Not applicable at this revision; issue #63 must add canaries with the first implementation | +| Metrics | No metrics registry or exporter exists | Not applicable at this revision; issue #63 must add label/value canaries and cardinality bounds | +| Support output | No support-bundle or diagnostic export exists | Not applicable at this revision; the implementing issue must add a closed allowlist and canary corpus | + +This report proves current executable behavior only. A future component does +not inherit an “issue #28 passed” claim: its pull request must extend this +matrix, fuzz corpus, and redaction table before merge. + +## Verification + +```sh +go test ./test/security -count=1 +go test ./internal/transport/http ./internal/adapters/identity/oidc \ + ./internal/core/domain/admission ./internal/core/domain/credential \ + ./internal/core/service/credentialbroker ./internal/core/domain/reconciliation \ + ./internal/core/domain/audit ./internal/core/domain/administration -count=1 +./hack/dev security +./hack/dev check +./hack/dev race +``` + +The OAuth token restrictions are cross-checked against +[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html), and the retained +regression practice follows the verification intent of +[NIST SP 800-218 SSDF](https://csrc.nist.gov/pubs/sp/800/218/final). diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go new file mode 100644 index 0000000..5528943 --- /dev/null +++ b/test/security/security_fuzz_test.go @@ -0,0 +1,80 @@ +package security_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ArdurAI/veer/internal/core/domain/resource" + "github.com/ArdurAI/veer/internal/core/service/reference" +) + +func FuzzReferencePublicBoundary(f *testing.F) { + seeds := []struct { + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string + body []byte + }{ + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil}, + {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil}, + {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil}, + {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", "", "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`)}, + {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", "", "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`)}, + {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", "", "req-fuzz-trailing", []byte(`{} {}`)}, + {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", "", "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}}, + {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", "", "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65))}, + {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", "", "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`)}, + {http.MethodPut, "/api/v1alpha1/workspaces/wsp_0000000000000001/status", "Bearer " + securityBearerCanary, "application/json", "fuzz-status-00001", `"rv_0000000000000001"`, "req-fuzz-status", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1)}, + {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil}, + } + for _, seed := range seeds { + f.Add(seed.method, seed.target, seed.authorization, seed.contentType, seed.idempotencyKey, seed.ifMatch, seed.requestID, seed.body) + } + + f.Fuzz(func( + t *testing.T, + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string, + body []byte, + ) { + if len(method) > 32 || len(target) > 4*1024 || len(authorization) > 16*1024 || + len(contentType) > 512 || len(idempotencyKey) > 2*1024 || len(ifMatch) > 512 || + len(requestID) > 512 || len(body) > 2*resource.MaxCanonicalBytes { + t.Skip() + } + fixture := newSecurityFixture(t) + request, err := http.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body)) + if err != nil { + return + } + request.RequestURI = target + for name, value := range map[string]string{ + "Authorization": authorization, + "Content-Type": contentType, + "Idempotency-Key": idempotencyKey, + "If-Match": ifMatch, + "Veer-Request-Id": requestID, + } { + if value != "" { + request.Header.Set(name, value) + } + } + response := httptest.NewRecorder() + fixture.memberHandler.ServeHTTP(response, request) + + if response.Code < 100 || response.Code > 599 { + t.Fatalf("invalid HTTP status %d", response.Code) + } + if response.Header().Get("Cache-Control") != "no-store" || + response.Header().Get("X-Content-Type-Options") != "nosniff" || + response.Header().Get("Veer-Request-Id") == "" { + t.Fatalf("security headers = %#v", response.Header()) + } + if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { + t.Fatalf("response is unbounded or invalid JSON: bytes=%d body=%q", response.Body.Len(), response.Body.Bytes()) + } + assertNoBearerCanary(t, response) + }) +} diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go new file mode 100644 index 0000000..5b7693b --- /dev/null +++ b/test/security/security_regression_test.go @@ -0,0 +1,495 @@ +package security_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/ArdurAI/veer/api/openapi" + "github.com/ArdurAI/veer/internal/adapters/store/memory" + "github.com/ArdurAI/veer/internal/core/domain/authorization" + "github.com/ArdurAI/veer/internal/core/domain/hierarchy" + "github.com/ArdurAI/veer/internal/core/domain/identity" + "github.com/ArdurAI/veer/internal/core/domain/resource" + "github.com/ArdurAI/veer/internal/core/ports" + "github.com/ArdurAI/veer/internal/core/service/reference" + "github.com/ArdurAI/veer/internal/core/service/referenceauthorization" + httptransport "github.com/ArdurAI/veer/internal/transport/http" +) + +const ( + securityBearerCanary = "security-route-bearer-canary" + invalidBearerCanary = "invalid-security-route-bearer-canary" +) + +type securityRoute struct { + operationID string + method string + pathTemplate string + target func(securityFixture) string + body func(securityFixture) []byte + headers func(securityFixture) map[string]string + memberStatus int + memberOutcome string + outsiderStatus int + outsiderOutcome string + assertMemberBody func(*testing.T, *httptest.ResponseRecorder) + assertOutsiderBody func(*testing.T, *httptest.ResponseRecorder) +} + +type securityFixture struct { + memberHandler http.Handler + outsiderHandler http.Handler + workspaceID resource.ID + resourceVersion string + operationID resource.ID +} + +type securityAccess struct { + principal identity.Principal +} + +func (access securityAccess) Authenticate( + ctx context.Context, + credential ports.BearerCredential, +) (identity.Principal, error) { + if err := ctx.Err(); err != nil { + return identity.Principal{}, err + } + if credential.Token() != securityBearerCanary { + return identity.Principal{}, ports.ErrAuthenticationInvalid + } + return identity.ClonePrincipal(access.principal), nil +} + +type securityMatrixReport struct { + Contract string `json:"contract"` + OpenAPIArtifact string `json:"openapiArtifact"` + OpenAPISHA256 string `json:"openapiSha256"` + OpenAPIValidated bool `json:"openapiValidated"` + Routes []securityRouteResult `json:"routes"` +} + +type securityRouteResult struct { + OperationID string `json:"operationId"` + Method string `json:"method"` + PathTemplate string `json:"pathTemplate"` + MemberStatus int `json:"memberStatus"` + MemberOutcome string `json:"memberOutcome"` + OutsiderStatus int `json:"outsiderStatus"` + OutsiderOutcome string `json:"outsiderOutcome"` + MissingCredentialStatus int `json:"missingCredentialStatus"` + InvalidCredentialStatus int `json:"invalidCredentialStatus"` +} + +func TestPublicRouteSecurityMatrix(t *testing.T) { + routes := publicSecurityRoutes() + assertOpenAPIRouteCoverage(t, routes) + results := make([]securityRouteResult, 0, len(routes)) + + for _, route := range routes { + route := route + t.Run(route.operationID, func(t *testing.T) { + fixture := newSecurityFixture(t) + member := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, member, route.memberStatus) + if route.assertMemberBody != nil { + route.assertMemberBody(t, member) + } + + fixture = newSecurityFixture(t) + outsider := securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, outsider, route.outsiderStatus) + if route.assertOutsiderBody != nil { + route.assertOutsiderBody(t, outsider) + } + + fixture = newSecurityFixture(t) + missing := securityRequest(t, fixture.memberHandler, route, fixture, "") + assertAuthenticationDenial(t, missing, `Bearer realm="veer"`) + + fixture = newSecurityFixture(t) + invalid := securityRequest(t, fixture.memberHandler, route, fixture, invalidBearerCanary) + assertAuthenticationDenial(t, invalid, `Bearer realm="veer", error="invalid_token"`) + + results = append(results, securityRouteResult{ + OperationID: route.operationID, Method: route.method, PathTemplate: route.pathTemplate, + MemberStatus: route.memberStatus, MemberOutcome: route.memberOutcome, + OutsiderStatus: route.outsiderStatus, OutsiderOutcome: route.outsiderOutcome, + MissingCredentialStatus: missing.Code, InvalidCredentialStatus: invalid.Code, + }) + }) + } + + writeOrCompareSecurityMatrix(t, results) +} + +func publicSecurityRoutes() []securityRoute { + workspaceTarget := func(fixture securityFixture) string { + return "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + } + mutationHeaders := func(fixture securityFixture) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Idempotency-Key": "security-route-mutation-0001", + "If-Match": `"` + fixture.resourceVersion + `"`, + } + } + return []securityRoute{ + { + operationID: "listWorkspaces", method: http.MethodGet, + pathTemplate: "/api/v1alpha1/workspaces", + target: func(securityFixture) string { return "/api/v1alpha1/workspaces" }, + memberStatus: http.StatusOK, memberOutcome: "allowed", + outsiderStatus: http.StatusOK, outsiderOutcome: "denied-by-row-filter", + assertMemberBody: func(t *testing.T, response *httptest.ResponseRecorder) { + assertWorkspacePageLength(t, response, 1) + }, + assertOutsiderBody: func(t *testing.T, response *httptest.ResponseRecorder) { + assertWorkspacePageLength(t, response, 0) + }, + }, + { + operationID: "createWorkspace", method: http.MethodPost, + pathTemplate: "/api/v1alpha1/workspaces", + target: func(securityFixture) string { return "/api/v1alpha1/workspaces" }, + body: func(securityFixture) []byte { return validWorkspaceBody("reserved") }, + headers: func(securityFixture) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Idempotency-Key": "security-route-create-0001", + } + }, + memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", + }, + { + operationID: "getWorkspace", method: http.MethodGet, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, + memberStatus: http.StatusOK, memberOutcome: "allowed", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + }, + { + operationID: "replaceWorkspace", method: http.MethodPut, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, + body: func(securityFixture) []byte { return validWorkspaceBody("replacement") }, + headers: mutationHeaders, + memberStatus: http.StatusAccepted, memberOutcome: "allowed", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + }, + { + operationID: "deleteWorkspace", method: http.MethodDelete, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, + headers: mutationHeaders, + memberStatus: http.StatusConflict, memberOutcome: "authorization-allowed-lifecycle-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + }, + { + operationID: "replaceWorkspaceStatus", method: http.MethodPut, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}/status", + target: func(fixture securityFixture) string { return workspaceTarget(fixture) + "/status" }, + body: func(securityFixture) []byte { + return []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","status":{"observedGeneration":1,"conditions":[]}}`) + }, + headers: mutationHeaders, + memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", + }, + { + operationID: "getOperation", method: http.MethodGet, + pathTemplate: "/api/v1alpha1/operations/{operationId}", + target: func(fixture securityFixture) string { + return "/api/v1alpha1/operations/" + fixture.operationID.String() + }, + memberStatus: http.StatusOK, memberOutcome: "allowed", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + }, + } +} + +func newSecurityFixture(t testing.TB) securityFixture { + t.Helper() + member := securityPrincipal(t, "security-member") + outsider := securityPrincipal(t, "security-outsider") + service, err := reference.New(reference.Config{ + Store: memory.NewStore(), Clock: reference.ClockFunc(func() time.Time { + return time.Date(2026, time.September, 12, 12, 0, 0, 0, time.UTC) + }), + Issuer: &reference.SequentialIssuer{}, PageTokenKey: bytes.Repeat([]byte{0x28}, 32), + MaximumPageTokens: 16, MaximumReplays: 32, + }) + if err != nil { + t.Fatal(err) + } + workspace, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: member, Kind: hierarchy.KindWorkspace, + CanonicalTarget: "security:bootstrap:workspace", IdempotencyKey: "security-bootstrap-workspace-0001", + Body: validWorkspaceBody("security workspace"), + }) + if err != nil { + t.Fatal(err) + } + memberID := resource.ID("mem_security_matrix_0001") + record, err := authorization.NewMemberRecord(authorization.MemberInput{ + ID: memberID, WorkspaceID: workspace.ResourceID, Kind: member.Kind(), + LogicalIdentity: member.LogicalIdentity(), + }) + if err != nil { + t.Fatal(err) + } + directory, err := authorization.NewMemberDirectory( + workspace.ResourceID, + []authorization.MemberRecord{record}, + ) + if err != nil { + t.Fatal(err) + } + parentID := workspace.ResourceID + policyBody := []byte(`{"apiVersion":"v1alpha1","kind":"Policy","metadata":{"displayName":"security matrix administrator"},"spec":{"bindings":[{"memberId":"` + memberID.String() + `","role":"WorkspaceAdministrator","scope":{"kind":"Workspace"}}]}}`) + if _, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: member, Kind: hierarchy.KindPolicy, WorkspaceID: workspace.ResourceID, ParentID: &parentID, + CanonicalTarget: "security:bootstrap:policy", IdempotencyKey: "security-bootstrap-policy-0001", + Body: policyBody, Members: directory, + }); err != nil { + t.Fatal(err) + } + runtime, err := referenceauthorization.New(referenceauthorization.Config{ + Reference: service, MemberDirectories: []authorization.MemberDirectory{directory}, MaximumAdmissions: 32, + }) + if err != nil { + t.Fatal(err) + } + memberHandler, err := httptransport.NewReferenceHandler(runtime, securityAccess{principal: member}) + if err != nil { + t.Fatal(err) + } + outsiderHandler, err := httptransport.NewReferenceHandler(runtime, securityAccess{principal: outsider}) + if err != nil { + t.Fatal(err) + } + return securityFixture{ + memberHandler: memberHandler, outsiderHandler: outsiderHandler, + workspaceID: workspace.ResourceID, resourceVersion: workspace.ResourceVersion, + operationID: workspace.OperationID, + } +} + +func securityPrincipal(t testing.TB, subject string) identity.Principal { + t.Helper() + principal, err := identity.NewPrincipal(identity.PrincipalInput{ + Kind: identity.KindHuman, Issuer: "https://security.example", Subject: subject, + Audiences: []string{"veer-api"}, + }) + if err != nil { + t.Fatal(err) + } + return principal +} + +func securityRequest( + t testing.TB, + handler http.Handler, + route securityRoute, + fixture securityFixture, + token string, +) *httptest.ResponseRecorder { + t.Helper() + var body []byte + if route.body != nil { + body = route.body(fixture) + } + request := httptest.NewRequest(route.method, route.target(fixture), bytes.NewReader(body)) + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + if route.headers != nil { + for name, value := range route.headers(fixture) { + request.Header.Set(name, value) + } + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func assertSecurityResponse(t testing.TB, response *httptest.ResponseRecorder, status int) { + t.Helper() + if response.Code != status { + t.Fatalf("response status = %d, want %d; body=%s", response.Code, status, response.Body.String()) + } + if response.Header().Get("Cache-Control") != "no-store" || + response.Header().Get("X-Content-Type-Options") != "nosniff" || + response.Header().Get("Veer-Request-Id") == "" { + t.Fatalf("security headers = %#v", response.Header()) + } + if !json.Valid(response.Body.Bytes()) { + t.Fatalf("response is not JSON: %q", response.Body.Bytes()) + } + assertNoBearerCanary(t, response) +} + +func assertNoBearerCanary(t testing.TB, response *httptest.ResponseRecorder) { + t.Helper() + outputs := []string{response.Body.String()} + for _, values := range response.Header() { + outputs = append(outputs, values...) + } + for _, output := range outputs { + if strings.Contains(output, securityBearerCanary) || strings.Contains(output, invalidBearerCanary) { + t.Fatalf("response disclosed a bearer canary: %q", output) + } + } +} + +func assertAuthenticationDenial(t testing.TB, response *httptest.ResponseRecorder, challenge string) { + t.Helper() + assertSecurityResponse(t, response, http.StatusUnauthorized) + if response.Header().Get("WWW-Authenticate") != challenge { + t.Fatalf("WWW-Authenticate = %q, want %q", response.Header().Get("WWW-Authenticate"), challenge) + } + var problem struct { + Code string `json:"code"` + } + if err := json.Unmarshal(response.Body.Bytes(), &problem); err != nil || problem.Code != "authentication-required" { + t.Fatalf("authentication problem = %q, %v", response.Body.Bytes(), err) + } +} + +func assertWorkspacePageLength(t testing.TB, response *httptest.ResponseRecorder, want int) { + t.Helper() + var page struct { + Items []json.RawMessage `json:"items"` + } + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || len(page.Items) != want { + t.Fatalf("workspace page length = %d, want %d; error=%v body=%s", len(page.Items), want, err, response.Body.String()) + } +} + +func assertOpenAPIRouteCoverage(t *testing.T, routes []securityRoute) { + t.Helper() + data, err := openapi.Load(filepath.Join(repositoryRoot(t), "api/openapi/veer-v1alpha1.json")) + if err != nil { + t.Fatal(err) + } + if err := openapi.Validate(data); err != nil { + t.Fatal(err) + } + var document struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + var got []string + for path, item := range document.Paths { + for method, raw := range item { + if !isHTTPMethod(method) { + continue + } + var operation struct { + OperationID string `json:"operationId"` + } + if err := json.Unmarshal(raw, &operation); err != nil || operation.OperationID == "" { + t.Fatalf("decode %s %s operation: %v", method, path, err) + } + got = append(got, operation.OperationID+"\t"+strings.ToUpper(method)+"\t"+path) + } + } + want := make([]string, len(routes)) + for index, route := range routes { + want[index] = route.operationID + "\t" + route.method + "\t" + route.pathTemplate + } + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("public route security coverage drifted:\n got %q\nwant %q", got, want) + } +} + +func isHTTPMethod(value string) bool { + switch strings.ToUpper(value) { + case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, + http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace: + return true + default: + return false + } +} + +func writeOrCompareSecurityMatrix(t *testing.T, results []securityRouteResult) { + t.Helper() + root := repositoryRoot(t) + artifact := "api/openapi/veer-v1alpha1.json" + data, err := openapi.Load(filepath.Join(root, artifact)) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + report := securityMatrixReport{ + Contract: "veer-security-regression-v1alpha1", OpenAPIArtifact: artifact, + OpenAPISHA256: hex.EncodeToString(digest[:]), OpenAPIValidated: true, Routes: results, + } + got, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + golden := filepath.Join(root, "test/security/testdata/security-regression-v1alpha1.golden.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read security matrix: %v\ngenerated:\n%s", err, got) + } + if !bytes.Equal(got, want) { + t.Fatalf("security matrix drifted:\n generated:\n%s\nchecked in:\n%s", got, want) + } +} + +func validWorkspaceBody(name string) []byte { + encoded, err := json.Marshal(map[string]any{ + "apiVersion": "v1alpha1", + "kind": "Workspace", + "metadata": map[string]any{"displayName": name}, + "spec": map[string]any{}, + }) + if err != nil { + panic(err) + } + return encoded +} + +func repositoryRoot(t testing.TB) string { + t.Helper() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + contents, readErr := os.ReadFile(filepath.Join(directory, "go.mod")) + if readErr == nil && bytes.Contains(contents, []byte("module github.com/ArdurAI/veer\n")) { + return directory + } + parent := filepath.Dir(directory) + if parent == directory { + t.Fatal(errors.New("repository root containing canonical go.mod was not found")) + } + directory = parent + } +} diff --git a/test/security/testdata/security-regression-v1alpha1.golden.json b/test/security/testdata/security-regression-v1alpha1.golden.json new file mode 100644 index 0000000..809b7df --- /dev/null +++ b/test/security/testdata/security-regression-v1alpha1.golden.json @@ -0,0 +1,85 @@ +{ + "contract": "veer-security-regression-v1alpha1", + "openapiArtifact": "api/openapi/veer-v1alpha1.json", + "openapiSha256": "7f7ab682e07e4cd9d4b5e626c2aafdc416efe43cb08ddc8a55b9bc6cf448237e", + "openapiValidated": true, + "routes": [ + { + "operationId": "listWorkspaces", + "method": "GET", + "pathTemplate": "/api/v1alpha1/workspaces", + "memberStatus": 200, + "memberOutcome": "allowed", + "outsiderStatus": 200, + "outsiderOutcome": "denied-by-row-filter", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "createWorkspace", + "method": "POST", + "pathTemplate": "/api/v1alpha1/workspaces", + "memberStatus": 403, + "memberOutcome": "service-reserved-deny", + "outsiderStatus": 403, + "outsiderOutcome": "service-reserved-deny", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "getWorkspace", + "method": "GET", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}", + "memberStatus": 200, + "memberOutcome": "allowed", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "replaceWorkspace", + "method": "PUT", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}", + "memberStatus": 202, + "memberOutcome": "allowed", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "deleteWorkspace", + "method": "DELETE", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}", + "memberStatus": 409, + "memberOutcome": "authorization-allowed-lifecycle-denied", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "replaceWorkspaceStatus", + "method": "PUT", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}/status", + "memberStatus": 403, + "memberOutcome": "service-reserved-deny", + "outsiderStatus": 403, + "outsiderOutcome": "service-reserved-deny", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "getOperation", + "method": "GET", + "pathTemplate": "/api/v1alpha1/operations/{operationId}", + "memberStatus": 200, + "memberOutcome": "allowed", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + } + ] +} From 301d942839b39e9481c6fb83894c84628985ba79 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 11:14:22 -0500 Subject: [PATCH 02/17] test(security): protect failed golden updates Signed-off-by: Gnani Rahul Nutakki --- test/security/security_regression_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 5b7693b..96ef58b 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -448,6 +448,10 @@ func writeOrCompareSecurityMatrix(t *testing.T, results []securityRouteResult) { got = append(got, '\n') golden := filepath.Join(root, "test/security/testdata/security-regression-v1alpha1.golden.json") if os.Getenv("UPDATE_GOLDEN") == "1" { + if t.Failed() { + t.Log("skipping security matrix update after a failed route observation") + return + } if err := os.WriteFile(golden, got, 0o644); err != nil { t.Fatal(err) } From 6d0e0caf03de3750eb45e604bb46e200944992ab Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 11:49:12 -0500 Subject: [PATCH 03/17] test(security): close regression coverage gaps Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 24 +- test/security/security_fuzz_test.go | 46 ++- test/security/security_regression_test.go | 388 ++++++++++++++---- .../security-regression-v1alpha1.golden.json | 2 +- 4 files changed, 362 insertions(+), 98 deletions(-) diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md index c6a2bc6..766ecf9 100644 --- a/docs/security/regression-suite.md +++ b/docs/security/regression-suite.md @@ -15,7 +15,7 @@ result is | Operation | Authenticated member | Authenticated outsider | Missing or invalid credential | | --- | --- | --- | --- | -| `listWorkspaces` | `200`, authorized row returned | `200`, denied rows removed before pagination | `401` | +| `listWorkspaces` | `200`, own row returned; denied row removed before the one-item page | `200`, own row returned; denied row removed before the one-item page | `401` | | `createWorkspace` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` | | `getWorkspace` | `200` | `403` | `401` | | `replaceWorkspace` | `202` | `403` | `401` | @@ -27,7 +27,8 @@ Workspace creation and status replacement intentionally have no tenant allow case. The closed authorization matrix reserves both actions. Treating a `2xx` response as required evidence would weaken the security contract. Their positive path proves that a valid credential reaches the authorization layer; -their invariant is a tenant `403` for every role. +their invariant is a tenant `403` for every value in the closed four-role +registry. ## Retained untrusted-input and fuzz corpus @@ -42,12 +43,16 @@ remain available for time-bounded fuzzing: | Resource admission | `FuzzAdmitRaw` | every resource kind, duplicate and unknown fields, invalid Unicode, depth/node/byte bounds, trailing values | | Closed domain values | fuzz targets under `internal/core/domain` | invalid sum types, identifiers, hierarchy, role/action vocabulary, operations, conditions, audit canonicalization, credentials, and reconciliation digests | -The public-boundary target asserts panic freedom, bounded JSON responses, -mandatory no-store/nosniff/correlation headers, and bearer-canary absence. The -OpenAPI validator independently rejects remote servers, remote references, and -webhook expansion. No provider response parser exists yet; provider-specific -malicious-response corpora belong with the adapters introduced by issues #38 -and #41. +The public-boundary target sends malformed body seeds through the +member-authorized Workspace replacement route so they reach admission parsing. +It asserts panic freedom, bounded JSON responses, mandatory +no-store/nosniff/correlation headers, and bearer-canary absence. Every runtime +problem response must use a closed code/status pair, bind its request ID and +problem URNs, stay below 1,024 bytes, contain at most one field violation, and +respect the field-path and text bounds. The OpenAPI validator independently +rejects remote servers, remote references, and webhook expansion. No provider +response parser exists yet; provider-specific malicious-response corpora belong +with the adapters introduced by issues #38 and #41. Run the public-boundary fuzzer for one minute with: @@ -63,7 +68,7 @@ go test ./test/security -run '^$' -fuzz '^FuzzReferencePublicBoundary$' -fuzztim | Credential state | `TestCredentialValuesRedactAndRejectSerialization`, `FuzzSourceMaterialSafety`, and broker lease/lifecycle tests cover source/session material, requests, brokers, leases, rotation, errors, and destruction | Raw material remains callback-bounded; diagnostics redact; serialization rejected | | Identity and bearer state | Identity diagnostic/serialization tests, bearer canary tests, OIDC negative corpus, and the route matrix cover principals, request carriers, challenges, problems, and headers | Raw token and identity claims absent from output; rejected request carriers scrubbed | | Audit and privileged state | `TestOpaqueRuntimeValuesForbidLossyGenericSerialization` and `TestAdministrationDiagnosticsAndSerializationAreSafe` cover audit/elevation values, nested containers, errors, formatting, and `slog` | Canaries absent; opaque runtime values reject lossy serialization | -| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer canaries absent | +| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer, resource, operation, and identity canaries are absent from denied responses | | Logs | Package redaction tests send every sensitive value through `fmt` and `slog` | Canaries absent from implemented logging surfaces | | Traces | No tracer or trace exporter exists | Not applicable at this revision; issue #63 must add canaries with the first implementation | | Metrics | No metrics registry or exporter exists | Not applicable at this revision; issue #63 must add label/value canaries and cardinality bounds | @@ -84,6 +89,7 @@ go test ./internal/transport/http ./internal/adapters/identity/oidc \ ./hack/dev security ./hack/dev check ./hack/dev race +./hack/dev coverage ``` The OAuth token restrictions are cross-checked against diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 5528943..5ad4060 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -13,6 +13,11 @@ import ( "github.com/ArdurAI/veer/internal/core/service/reference" ) +const ( + fuzzWorkspaceTarget = "/__security_fixture_workspace__" + fuzzWorkspaceResourceMatch = "__security_fixture_resource_version__" +) + func FuzzReferencePublicBoundary(f *testing.F) { seeds := []struct { method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string @@ -21,13 +26,13 @@ func FuzzReferencePublicBoundary(f *testing.F) { {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil}, {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil}, {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil}, - {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", "", "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`)}, - {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", "", "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`)}, - {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", "", "req-fuzz-trailing", []byte(`{} {}`)}, - {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", "", "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}}, - {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", "", "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65))}, - {http.MethodPost, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", "", "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`)}, - {http.MethodPut, "/api/v1alpha1/workspaces/wsp_0000000000000001/status", "Bearer " + securityBearerCanary, "application/json", "fuzz-status-00001", `"rv_0000000000000001"`, "req-fuzz-status", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1)}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`)}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`)}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", fuzzWorkspaceResourceMatch, "req-fuzz-trailing", []byte(`{} {}`)}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", fuzzWorkspaceResourceMatch, "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65))}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", fuzzWorkspaceResourceMatch, "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`)}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-oversized-0001", fuzzWorkspaceResourceMatch, "req-fuzz-oversized", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1)}, {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil}, } for _, seed := range seeds { @@ -45,6 +50,14 @@ func FuzzReferencePublicBoundary(f *testing.F) { t.Skip() } fixture := newSecurityFixture(t) + authorizedFixtureMutation := method == http.MethodPut && target == fuzzWorkspaceTarget && + authorization == "Bearer "+securityBearerCanary && ifMatch == fuzzWorkspaceResourceMatch + if target == fuzzWorkspaceTarget { + target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + } + if ifMatch == fuzzWorkspaceResourceMatch { + ifMatch = `"` + fixture.resourceVersion + `"` + } request, err := http.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body)) if err != nil { return @@ -63,17 +76,26 @@ func FuzzReferencePublicBoundary(f *testing.F) { } response := httptest.NewRecorder() fixture.memberHandler.ServeHTTP(response, request) - - if response.Code < 100 || response.Code > 599 { - t.Fatalf("invalid HTTP status %d", response.Code) + if authorizedFixtureMutation && response.Code == http.StatusForbidden { + t.Fatalf("authorized fixture mutation was denied before reaching admission: body=%s", response.Body.String()) } + if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("X-Content-Type-Options") != "nosniff" || response.Header().Get("Veer-Request-Id") == "" { t.Fatalf("security headers = %#v", response.Header()) } - if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { - t.Fatalf("response is unbounded or invalid JSON: bytes=%d body=%q", response.Body.Len(), response.Body.Bytes()) + switch { + case response.Code >= http.StatusOK && response.Code < http.StatusMultipleChoices: + if response.Header().Get("Content-Type") != "application/json" || + response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { + t.Fatalf("success response contract failed: status=%d bytes=%d headers=%#v", response.Code, response.Body.Len(), response.Header()) + } + case response.Code >= http.StatusBadRequest && response.Code <= 599: + assertSecurityProblemContract(t, response) + assertNoFixtureCanary(t, response, fixture) + default: + t.Fatalf("unexpected HTTP status %d", response.Code) } assertNoBearerCanary(t, response) }) diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 96ef58b..d93cd7d 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -7,11 +7,13 @@ import ( "encoding/hex" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" + "regexp" "sort" "strings" "testing" @@ -30,31 +32,63 @@ import ( ) const ( - securityBearerCanary = "security-route-bearer-canary" - invalidBearerCanary = "invalid-security-route-bearer-canary" + securityBearerCanary = "security-route-bearer-canary" + invalidBearerCanary = "invalid-security-route-bearer-canary" + securityResourceCanary = "security-resource-confidential-canary" + securityIdentityCanary = "security-identity-confidential-canary" + maximumSecurityProblemBytes = 1_024 + maximumSecurityFieldPathSize = 96 +) + +var ( + securityProblemCodePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + securityRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + securityFieldPathPattern = regexp.MustCompile(`^(/([^~/]|~0|~1)*)+$`) + securityProblemStatuses = map[string]int{ + "validation-failed": http.StatusBadRequest, + "authentication-required": http.StatusUnauthorized, + "authorization-denied": http.StatusForbidden, + "not-found": http.StatusNotFound, + "method-not-allowed": http.StatusMethodNotAllowed, + "idempotency-key-reused": http.StatusConflict, + "uniqueness-conflict": http.StatusConflict, + "lifecycle-conflict": http.StatusConflict, + "policy-conflict": http.StatusConflict, + "precondition-failed": http.StatusPreconditionFailed, + "request-too-large": http.StatusRequestEntityTooLarge, + "unsupported-media-type": http.StatusUnsupportedMediaType, + "precondition-required": http.StatusPreconditionRequired, + "rate-limited": http.StatusTooManyRequests, + "internal-failure": http.StatusInternalServerError, + "unavailable": http.StatusServiceUnavailable, + } ) type securityRoute struct { - operationID string - method string - pathTemplate string - target func(securityFixture) string - body func(securityFixture) []byte - headers func(securityFixture) map[string]string - memberStatus int - memberOutcome string - outsiderStatus int - outsiderOutcome string - assertMemberBody func(*testing.T, *httptest.ResponseRecorder) - assertOutsiderBody func(*testing.T, *httptest.ResponseRecorder) + operationID string + method string + pathTemplate string + target func(securityFixture) string + body func(securityFixture) []byte + headers func(securityFixture) map[string]string + memberStatus int + memberOutcome string + memberProblemCode string + outsiderStatus int + outsiderOutcome string + outsiderProblemCode string + allRolesDenied bool + assertMemberBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) + assertOutsiderBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) } type securityFixture struct { - memberHandler http.Handler - outsiderHandler http.Handler - workspaceID resource.ID - resourceVersion string - operationID resource.ID + memberHandler http.Handler + outsiderHandler http.Handler + workspaceID resource.ID + outsiderWorkspaceID resource.ID + resourceVersion string + operationID resource.ID } type securityAccess struct { @@ -94,6 +128,24 @@ type securityRouteResult struct { InvalidCredentialStatus int `json:"invalidCredentialStatus"` } +type securityFieldViolation struct { + Field string `json:"field"` + Code string `json:"code"` + Message string `json:"message"` +} + +type securityProblem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` + Instance string `json:"instance"` + Code string `json:"code"` + RequestID string `json:"requestId"` + Errors []securityFieldViolation `json:"errors,omitempty"` + RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` +} + func TestPublicRouteSecurityMatrix(t *testing.T) { routes := publicSecurityRoutes() assertOpenAPIRouteCoverage(t, routes) @@ -104,25 +156,45 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { t.Run(route.operationID, func(t *testing.T) { fixture := newSecurityFixture(t) member := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) - assertSecurityResponse(t, member, route.memberStatus) + assertSecurityResponse(t, member, route.memberStatus, route.memberProblemCode) + if route.memberStatus >= http.StatusBadRequest { + assertNoFixtureCanary(t, member, fixture) + } if route.assertMemberBody != nil { - route.assertMemberBody(t, member) + route.assertMemberBody(t, fixture, member) } fixture = newSecurityFixture(t) outsider := securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) - assertSecurityResponse(t, outsider, route.outsiderStatus) + assertSecurityResponse(t, outsider, route.outsiderStatus, route.outsiderProblemCode) + if route.outsiderStatus >= http.StatusBadRequest { + assertNoFixtureCanary(t, outsider, fixture) + } if route.assertOutsiderBody != nil { - route.assertOutsiderBody(t, outsider) + route.assertOutsiderBody(t, fixture, outsider) } fixture = newSecurityFixture(t) missing := securityRequest(t, fixture.memberHandler, route, fixture, "") assertAuthenticationDenial(t, missing, `Bearer realm="veer"`) + assertNoFixtureCanary(t, missing, fixture) fixture = newSecurityFixture(t) invalid := securityRequest(t, fixture.memberHandler, route, fixture, invalidBearerCanary) assertAuthenticationDenial(t, invalid, `Bearer realm="veer", error="invalid_token"`) + assertNoFixtureCanary(t, invalid, fixture) + + if route.allRolesDenied { + for _, role := range authorization.Roles() { + role := role + t.Run("reserved-"+role.String(), func(t *testing.T) { + roleFixture := newSecurityFixtureForRole(t, role) + denied := securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, denied, roleFixture) + }) + } + } results = append(results, securityRouteResult{ OperationID: route.operationID, Method: route.method, PathTemplate: route.pathTemplate, @@ -151,14 +223,15 @@ func publicSecurityRoutes() []securityRoute { { operationID: "listWorkspaces", method: http.MethodGet, pathTemplate: "/api/v1alpha1/workspaces", - target: func(securityFixture) string { return "/api/v1alpha1/workspaces" }, + target: func(securityFixture) string { return "/api/v1alpha1/workspaces?pageSize=1" }, memberStatus: http.StatusOK, memberOutcome: "allowed", - outsiderStatus: http.StatusOK, outsiderOutcome: "denied-by-row-filter", - assertMemberBody: func(t *testing.T, response *httptest.ResponseRecorder) { - assertWorkspacePageLength(t, response, 1) + outsiderStatus: http.StatusOK, outsiderOutcome: "denied-rows-filtered-before-pagination", + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertWorkspacePage(t, response, fixture.workspaceID, "security outsider workspace") }, - assertOutsiderBody: func(t *testing.T, response *httptest.ResponseRecorder) { - assertWorkspacePageLength(t, response, 0) + assertOutsiderBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertWorkspacePage(t, response, fixture.outsiderWorkspaceID, securityResourceCanary) + assertNoFixtureCanary(t, response, fixture) }, }, { @@ -172,14 +245,15 @@ func publicSecurityRoutes() []securityRoute { "Idempotency-Key": "security-route-create-0001", } }, - memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", - outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", + memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", memberProblemCode: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", outsiderProblemCode: "authorization-denied", + allRolesDenied: true, }, { operationID: "getWorkspace", method: http.MethodGet, pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, memberStatus: http.StatusOK, memberOutcome: "allowed", - outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", }, { operationID: "replaceWorkspace", method: http.MethodPut, @@ -187,14 +261,14 @@ func publicSecurityRoutes() []securityRoute { body: func(securityFixture) []byte { return validWorkspaceBody("replacement") }, headers: mutationHeaders, memberStatus: http.StatusAccepted, memberOutcome: "allowed", - outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", }, { operationID: "deleteWorkspace", method: http.MethodDelete, pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, headers: mutationHeaders, - memberStatus: http.StatusConflict, memberOutcome: "authorization-allowed-lifecycle-denied", - outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + memberStatus: http.StatusConflict, memberOutcome: "authorization-allowed-lifecycle-denied", memberProblemCode: "lifecycle-conflict", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", }, { operationID: "replaceWorkspaceStatus", method: http.MethodPut, @@ -204,8 +278,9 @@ func publicSecurityRoutes() []securityRoute { return []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","status":{"observedGeneration":1,"conditions":[]}}`) }, headers: mutationHeaders, - memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", - outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", + memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", memberProblemCode: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", outsiderProblemCode: "authorization-denied", + allRolesDenied: true, }, { operationID: "getOperation", method: http.MethodGet, @@ -214,14 +289,21 @@ func publicSecurityRoutes() []securityRoute { return "/api/v1alpha1/operations/" + fixture.operationID.String() }, memberStatus: http.StatusOK, memberOutcome: "allowed", - outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", }, } } func newSecurityFixture(t testing.TB) securityFixture { + return newSecurityFixtureForRole(t, authorization.RoleWorkspaceAdministrator) +} + +func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) securityFixture { t.Helper() - member := securityPrincipal(t, "security-member") + if _, err := authorization.ParseRole(memberRole.String()); err != nil { + t.Fatal(err) + } + member := securityPrincipal(t, securityIdentityCanary) outsider := securityPrincipal(t, "security-outsider") service, err := reference.New(reference.Config{ Store: memory.NewStore(), Clock: reference.ClockFunc(func() time.Time { @@ -236,37 +318,32 @@ func newSecurityFixture(t testing.TB) securityFixture { workspace, err := service.Create(context.Background(), reference.CreateCommand{ Principal: member, Kind: hierarchy.KindWorkspace, CanonicalTarget: "security:bootstrap:workspace", IdempotencyKey: "security-bootstrap-workspace-0001", - Body: validWorkspaceBody("security workspace"), + Body: validWorkspaceBody(securityResourceCanary), }) if err != nil { t.Fatal(err) } - memberID := resource.ID("mem_security_matrix_0001") - record, err := authorization.NewMemberRecord(authorization.MemberInput{ - ID: memberID, WorkspaceID: workspace.ResourceID, Kind: member.Kind(), - LogicalIdentity: member.LogicalIdentity(), + outsiderWorkspace, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: outsider, Kind: hierarchy.KindWorkspace, + CanonicalTarget: "security:bootstrap:outsider-workspace", IdempotencyKey: "security-bootstrap-workspace-0002", + Body: validWorkspaceBody("security outsider workspace"), }) if err != nil { t.Fatal(err) } - directory, err := authorization.NewMemberDirectory( - workspace.ResourceID, - []authorization.MemberRecord{record}, + memberDirectory := createSecurityPolicy( + t, service, member, workspace.ResourceID, resource.ID("mem_security_matrix_0001"), + memberRole, "security member policy", "security:bootstrap:policy", "security-bootstrap-policy-0001", + ) + outsiderDirectory := createSecurityPolicy( + t, service, outsider, outsiderWorkspace.ResourceID, resource.ID("mem_security_matrix_0002"), + authorization.RoleWorkspaceAdministrator, "security outsider policy", + "security:bootstrap:outsider-policy", "security-bootstrap-policy-0002", ) - if err != nil { - t.Fatal(err) - } - parentID := workspace.ResourceID - policyBody := []byte(`{"apiVersion":"v1alpha1","kind":"Policy","metadata":{"displayName":"security matrix administrator"},"spec":{"bindings":[{"memberId":"` + memberID.String() + `","role":"WorkspaceAdministrator","scope":{"kind":"Workspace"}}]}}`) - if _, err := service.Create(context.Background(), reference.CreateCommand{ - Principal: member, Kind: hierarchy.KindPolicy, WorkspaceID: workspace.ResourceID, ParentID: &parentID, - CanonicalTarget: "security:bootstrap:policy", IdempotencyKey: "security-bootstrap-policy-0001", - Body: policyBody, Members: directory, - }); err != nil { - t.Fatal(err) - } runtime, err := referenceauthorization.New(referenceauthorization.Config{ - Reference: service, MemberDirectories: []authorization.MemberDirectory{directory}, MaximumAdmissions: 32, + Reference: service, + MemberDirectories: []authorization.MemberDirectory{memberDirectory, outsiderDirectory}, + MaximumAdmissions: 32, }) if err != nil { t.Fatal(err) @@ -281,9 +358,56 @@ func newSecurityFixture(t testing.TB) securityFixture { } return securityFixture{ memberHandler: memberHandler, outsiderHandler: outsiderHandler, - workspaceID: workspace.ResourceID, resourceVersion: workspace.ResourceVersion, - operationID: workspace.OperationID, + workspaceID: workspace.ResourceID, outsiderWorkspaceID: outsiderWorkspace.ResourceID, + resourceVersion: workspace.ResourceVersion, operationID: workspace.OperationID, + } +} + +func createSecurityPolicy( + t testing.TB, + service *reference.Service, + principal identity.Principal, + workspaceID resource.ID, + memberID resource.ID, + role authorization.Role, + displayName, canonicalTarget, idempotencyKey string, +) authorization.MemberDirectory { + t.Helper() + record, err := authorization.NewMemberRecord(authorization.MemberInput{ + ID: memberID, WorkspaceID: workspaceID, Kind: principal.Kind(), + LogicalIdentity: principal.LogicalIdentity(), + }) + if err != nil { + t.Fatal(err) + } + directory, err := authorization.NewMemberDirectory( + workspaceID, + []authorization.MemberRecord{record}, + ) + if err != nil { + t.Fatal(err) + } + parentID := workspaceID + policyBody, err := json.Marshal(map[string]any{ + "apiVersion": "v1alpha1", + "kind": "Policy", + "metadata": map[string]any{"displayName": displayName}, + "spec": map[string]any{"bindings": []any{map[string]any{ + "memberId": memberID.String(), "role": role.String(), + "scope": map[string]any{"kind": "Workspace"}, + }}}, + }) + if err != nil { + t.Fatal(err) } + if _, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: principal, Kind: hierarchy.KindPolicy, WorkspaceID: workspaceID, ParentID: &parentID, + CanonicalTarget: canonicalTarget, IdempotencyKey: idempotencyKey, + Body: policyBody, Members: directory, + }); err != nil { + t.Fatal(err) + } + return directory } func securityPrincipal(t testing.TB, subject string) identity.Principal { @@ -324,7 +448,12 @@ func securityRequest( return response } -func assertSecurityResponse(t testing.TB, response *httptest.ResponseRecorder, status int) { +func assertSecurityResponse( + t testing.TB, + response *httptest.ResponseRecorder, + status int, + problemCode string, +) { t.Helper() if response.Code != status { t.Fatalf("response status = %d, want %d; body=%s", response.Code, status, response.Body.String()) @@ -337,44 +466,151 @@ func assertSecurityResponse(t testing.TB, response *httptest.ResponseRecorder, s if !json.Valid(response.Body.Bytes()) { t.Fatalf("response is not JSON: %q", response.Body.Bytes()) } + if problemCode != "" { + problem := assertSecurityProblemContract(t, response) + if problem.Code != problemCode { + t.Fatalf("problem code = %q, want %q; body=%s", problem.Code, problemCode, response.Body.String()) + } + } else if response.Header().Get("Content-Type") != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", response.Header().Get("Content-Type")) + } assertNoBearerCanary(t, response) } func assertNoBearerCanary(t testing.TB, response *httptest.ResponseRecorder) { + t.Helper() + assertNoResponseCanary(t, response, securityBearerCanary, invalidBearerCanary) +} + +func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + t.Helper() + assertNoResponseCanary( + t, + response, + securityResourceCanary, + securityIdentityCanary, + fixture.workspaceID.String(), + fixture.operationID.String(), + ) +} + +func assertNoResponseCanary(t testing.TB, response *httptest.ResponseRecorder, canaries ...string) { t.Helper() outputs := []string{response.Body.String()} for _, values := range response.Header() { outputs = append(outputs, values...) } for _, output := range outputs { - if strings.Contains(output, securityBearerCanary) || strings.Contains(output, invalidBearerCanary) { - t.Fatalf("response disclosed a bearer canary: %q", output) + for _, canary := range canaries { + if canary != "" && strings.Contains(output, canary) { + t.Fatalf("response disclosed confidentiality canary %q in %q", canary, output) + } } } } func assertAuthenticationDenial(t testing.TB, response *httptest.ResponseRecorder, challenge string) { t.Helper() - assertSecurityResponse(t, response, http.StatusUnauthorized) + assertSecurityResponse(t, response, http.StatusUnauthorized, "authentication-required") if response.Header().Get("WWW-Authenticate") != challenge { t.Fatalf("WWW-Authenticate = %q, want %q", response.Header().Get("WWW-Authenticate"), challenge) } - var problem struct { - Code string `json:"code"` - } - if err := json.Unmarshal(response.Body.Bytes(), &problem); err != nil || problem.Code != "authentication-required" { - t.Fatalf("authentication problem = %q, %v", response.Body.Bytes(), err) - } } -func assertWorkspacePageLength(t testing.TB, response *httptest.ResponseRecorder, want int) { +func assertWorkspacePage( + t testing.TB, + response *httptest.ResponseRecorder, + wantID resource.ID, + forbiddenCanary string, +) { t.Helper() var page struct { - Items []json.RawMessage `json:"items"` + Items []struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil { + t.Fatalf("decode workspace page: %v; body=%s", err, response.Body.String()) + } + if len(page.Items) != 1 || page.Items[0].Metadata.ID != wantID.String() || page.NextPageToken != "" { + t.Fatalf( + "workspace page = items:%d id:%q next:%q, want 1/%q/empty; body=%s", + len(page.Items), page.Items[0].Metadata.ID, page.NextPageToken, wantID, response.Body.String(), + ) + } + assertNoResponseCanary(t, response, forbiddenCanary) +} + +func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { + t.Helper() + if response.Header().Get("Content-Type") != "application/problem+json" { + t.Fatalf("Content-Type = %q, want application/problem+json", response.Header().Get("Content-Type")) + } + if response.Body.Len() == 0 || response.Body.Len() > maximumSecurityProblemBytes { + t.Fatalf("problem body bytes = %d, want 1..%d", response.Body.Len(), maximumSecurityProblemBytes) + } + decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) + decoder.DisallowUnknownFields() + var problem securityProblem + if err := decoder.Decode(&problem); err != nil { + t.Fatalf("decode runtime problem: %v; body=%s", err, response.Body.String()) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + t.Fatalf("runtime problem has trailing JSON: %v; body=%s", err, response.Body.String()) + } + wantStatus, codeKnown := securityProblemStatuses[problem.Code] + if !codeKnown || wantStatus != response.Code || problem.Status != response.Code { + t.Fatalf( + "runtime problem code/status = %q/%d/%d, want a closed code for HTTP %d", + problem.Code, problem.Status, wantStatus, response.Code, + ) + } + requestID := response.Header().Get("Veer-Request-Id") + if !securityRequestIDPattern.MatchString(problem.RequestID) || problem.RequestID != requestID || + problem.Instance != "urn:veer:request:"+problem.RequestID || + problem.Type != "urn:veer:problem:"+problem.Code { + t.Fatalf( + "runtime problem identity = type:%q instance:%q request:%q header:%q", + problem.Type, problem.Instance, problem.RequestID, requestID, + ) + } + if len(problem.Type) > 81 || !safeSecurityProblemText(problem.Title, 64, true) || + !safeSecurityProblemText(problem.Detail, 192, false) || len(problem.Instance) > 81 || + len(problem.Code) > 64 || !securityProblemCodePattern.MatchString(problem.Code) { + t.Fatalf("runtime problem primitive bounds failed: %#v", problem) + } + if len(problem.Errors) > 1 { + t.Fatalf("runtime problem errors = %d, want at most 1", len(problem.Errors)) + } + for _, violation := range problem.Errors { + if len(violation.Field) == 0 || len(violation.Field) > maximumSecurityFieldPathSize || + !securityFieldPathPattern.MatchString(violation.Field) || + len(violation.Code) == 0 || len(violation.Code) > 32 || + !securityProblemCodePattern.MatchString(violation.Code) || + !safeSecurityProblemText(violation.Message, 96, false) { + t.Fatalf("runtime field violation bounds failed: %#v", violation) + } } - if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || len(page.Items) != want { - t.Fatalf("workspace page length = %d, want %d; error=%v body=%s", len(page.Items), want, err, response.Body.String()) + if problem.RetryAfterSeconds < 0 || problem.RetryAfterSeconds > 86_400 { + t.Fatalf("runtime retryAfterSeconds = %d, want 0 or 1..86400", problem.RetryAfterSeconds) + } + return problem +} + +func safeSecurityProblemText(value string, maximum int, required bool) bool { + if len(value) > maximum || required && value == "" { + return false + } + for index := range len(value) { + character := value[index] + if character < 0x20 || character > 0x7e || strings.ContainsRune(`"&<>\`, rune(character)) { + return false + } } + return true } func assertOpenAPIRouteCoverage(t *testing.T, routes []securityRoute) { diff --git a/test/security/testdata/security-regression-v1alpha1.golden.json b/test/security/testdata/security-regression-v1alpha1.golden.json index 809b7df..ace9797 100644 --- a/test/security/testdata/security-regression-v1alpha1.golden.json +++ b/test/security/testdata/security-regression-v1alpha1.golden.json @@ -11,7 +11,7 @@ "memberStatus": 200, "memberOutcome": "allowed", "outsiderStatus": 200, - "outsiderOutcome": "denied-by-row-filter", + "outsiderOutcome": "denied-rows-filtered-before-pagination", "missingCredentialStatus": 401, "invalidCredentialStatus": 401 }, From d4e1f17a93810c4d4c175f29ee946717bfc74845 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 12:08:51 -0500 Subject: [PATCH 04/17] test(security): enforce retained rejection contracts Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 2 +- test/security/security_fuzz_test.go | 58 +++++++++++++++++------ test/security/security_regression_test.go | 26 ++++++---- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md index 766ecf9..9ac9005 100644 --- a/docs/security/regression-suite.md +++ b/docs/security/regression-suite.md @@ -68,7 +68,7 @@ go test ./test/security -run '^$' -fuzz '^FuzzReferencePublicBoundary$' -fuzztim | Credential state | `TestCredentialValuesRedactAndRejectSerialization`, `FuzzSourceMaterialSafety`, and broker lease/lifecycle tests cover source/session material, requests, brokers, leases, rotation, errors, and destruction | Raw material remains callback-bounded; diagnostics redact; serialization rejected | | Identity and bearer state | Identity diagnostic/serialization tests, bearer canary tests, OIDC negative corpus, and the route matrix cover principals, request carriers, challenges, problems, and headers | Raw token and identity claims absent from output; rejected request carriers scrubbed | | Audit and privileged state | `TestOpaqueRuntimeValuesForbidLossyGenericSerialization` and `TestAdministrationDiagnosticsAndSerializationAreSafe` cover audit/elevation values, nested containers, errors, formatting, and `slog` | Canaries absent; opaque runtime values reject lossy serialization | -| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer, resource, operation, and identity canaries are absent from denied responses | +| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer, workspace, Policy, Member, operation, and identity canaries are absent from denied responses; retained negative seeds must preserve their exact rejection status | | Logs | Package redaction tests send every sensitive value through `fmt` and `slog` | Canaries absent from implemented logging surfaces | | Traces | No tracer or trace exporter exists | Not applicable at this revision; issue #63 must add canaries with the first implementation | | Metrics | No metrics registry or exporter exists | Not applicable at this revision; issue #63 must add label/value canaries and cardinality bounds | diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 5ad4060..e71a21e 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -18,22 +18,25 @@ const ( fuzzWorkspaceResourceMatch = "__security_fixture_resource_version__" ) +type securityFuzzSeed struct { + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string + body []byte + rejectionStatus int +} + func FuzzReferencePublicBoundary(f *testing.F) { - seeds := []struct { - method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string - body []byte - }{ - {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil}, - {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil}, - {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`)}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`)}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", fuzzWorkspaceResourceMatch, "req-fuzz-trailing", []byte(`{} {}`)}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", fuzzWorkspaceResourceMatch, "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65))}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", fuzzWorkspaceResourceMatch, "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`)}, - {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-oversized-0001", fuzzWorkspaceResourceMatch, "req-fuzz-oversized", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1)}, - {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil}, + seeds := []securityFuzzSeed{ + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil, 0}, + {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, 0}, + {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, 0}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", fuzzWorkspaceResourceMatch, "req-fuzz-trailing", []byte(`{} {}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}, http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", fuzzWorkspaceResourceMatch, "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65)), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", fuzzWorkspaceResourceMatch, "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-oversized-0001", fuzzWorkspaceResourceMatch, "req-fuzz-oversized", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1), http.StatusRequestEntityTooLarge}, + {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil, 0}, } for _, seed := range seeds { f.Add(seed.method, seed.target, seed.authorization, seed.contentType, seed.idempotencyKey, seed.ifMatch, seed.requestID, seed.body) @@ -49,6 +52,9 @@ func FuzzReferencePublicBoundary(f *testing.F) { len(requestID) > 512 || len(body) > 2*resource.MaxCanonicalBytes { t.Skip() } + retainedRejectionStatus := retainedSecurityFuzzRejectionStatus( + seeds, method, target, authorization, contentType, idempotencyKey, ifMatch, requestID, body, + ) fixture := newSecurityFixture(t) authorizedFixtureMutation := method == http.MethodPut && target == fuzzWorkspaceTarget && authorization == "Bearer "+securityBearerCanary && ifMatch == fuzzWorkspaceResourceMatch @@ -79,6 +85,12 @@ func FuzzReferencePublicBoundary(f *testing.F) { if authorizedFixtureMutation && response.Code == http.StatusForbidden { t.Fatalf("authorized fixture mutation was denied before reaching admission: body=%s", response.Body.String()) } + if retainedRejectionStatus != 0 && response.Code != retainedRejectionStatus { + t.Fatalf( + "retained negative seed status = %d, want %d; body=%s", + response.Code, retainedRejectionStatus, response.Body.String(), + ) + } if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("X-Content-Type-Options") != "nosniff" || @@ -100,3 +112,19 @@ func FuzzReferencePublicBoundary(f *testing.F) { assertNoBearerCanary(t, response) }) } + +func retainedSecurityFuzzRejectionStatus( + seeds []securityFuzzSeed, + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string, + body []byte, +) int { + for _, seed := range seeds { + if seed.rejectionStatus != 0 && method == seed.method && target == seed.target && + authorization == seed.authorization && contentType == seed.contentType && + idempotencyKey == seed.idempotencyKey && ifMatch == seed.ifMatch && requestID == seed.requestID && + bytes.Equal(body, seed.body) { + return seed.rejectionStatus + } + } + return 0 +} diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index d93cd7d..6b971f0 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -35,6 +35,8 @@ const ( securityBearerCanary = "security-route-bearer-canary" invalidBearerCanary = "invalid-security-route-bearer-canary" securityResourceCanary = "security-resource-confidential-canary" + securityPolicyCanary = "security-policy-confidential-canary" + securityMemberCanary = "mem_security_confidential_0001" securityIdentityCanary = "security-identity-confidential-canary" maximumSecurityProblemBytes = 1_024 maximumSecurityFieldPathSize = 96 @@ -89,6 +91,8 @@ type securityFixture struct { outsiderWorkspaceID resource.ID resourceVersion string operationID resource.ID + policyID resource.ID + memberID resource.ID } type securityAccess struct { @@ -331,11 +335,11 @@ func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) secu if err != nil { t.Fatal(err) } - memberDirectory := createSecurityPolicy( - t, service, member, workspace.ResourceID, resource.ID("mem_security_matrix_0001"), - memberRole, "security member policy", "security:bootstrap:policy", "security-bootstrap-policy-0001", + memberDirectory, memberPolicy := createSecurityPolicy( + t, service, member, workspace.ResourceID, resource.ID(securityMemberCanary), + memberRole, securityPolicyCanary, "security:bootstrap:policy", "security-bootstrap-policy-0001", ) - outsiderDirectory := createSecurityPolicy( + outsiderDirectory, _ := createSecurityPolicy( t, service, outsider, outsiderWorkspace.ResourceID, resource.ID("mem_security_matrix_0002"), authorization.RoleWorkspaceAdministrator, "security outsider policy", "security:bootstrap:outsider-policy", "security-bootstrap-policy-0002", @@ -360,6 +364,7 @@ func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) secu memberHandler: memberHandler, outsiderHandler: outsiderHandler, workspaceID: workspace.ResourceID, outsiderWorkspaceID: outsiderWorkspace.ResourceID, resourceVersion: workspace.ResourceVersion, operationID: workspace.OperationID, + policyID: memberPolicy.ResourceID, memberID: resource.ID(securityMemberCanary), } } @@ -371,7 +376,7 @@ func createSecurityPolicy( memberID resource.ID, role authorization.Role, displayName, canonicalTarget, idempotencyKey string, -) authorization.MemberDirectory { +) (authorization.MemberDirectory, reference.MutationReceipt) { t.Helper() record, err := authorization.NewMemberRecord(authorization.MemberInput{ ID: memberID, WorkspaceID: workspaceID, Kind: principal.Kind(), @@ -400,14 +405,15 @@ func createSecurityPolicy( if err != nil { t.Fatal(err) } - if _, err := service.Create(context.Background(), reference.CreateCommand{ + receipt, err := service.Create(context.Background(), reference.CreateCommand{ Principal: principal, Kind: hierarchy.KindPolicy, WorkspaceID: workspaceID, ParentID: &parentID, CanonicalTarget: canonicalTarget, IdempotencyKey: idempotencyKey, Body: policyBody, Members: directory, - }); err != nil { + }) + if err != nil { t.Fatal(err) } - return directory + return directory, receipt } func securityPrincipal(t testing.TB, subject string) identity.Principal { @@ -488,9 +494,13 @@ func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fi t, response, securityResourceCanary, + securityPolicyCanary, + securityMemberCanary, securityIdentityCanary, fixture.workspaceID.String(), fixture.operationID.String(), + fixture.policyID.String(), + fixture.memberID.String(), ) } From aea4de150f4a6f930f92ef7f53384670f7525062 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 12:25:20 -0500 Subject: [PATCH 05/17] test(security): preserve failure diagnostics Signed-off-by: Gnani Rahul Nutakki --- test/security/security_regression_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 6b971f0..0bda0e5 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -545,10 +545,14 @@ func assertWorkspacePage( if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil { t.Fatalf("decode workspace page: %v; body=%s", err, response.Body.String()) } - if len(page.Items) != 1 || page.Items[0].Metadata.ID != wantID.String() || page.NextPageToken != "" { + var firstID string + if len(page.Items) > 0 { + firstID = page.Items[0].Metadata.ID + } + if len(page.Items) != 1 || firstID != wantID.String() || page.NextPageToken != "" { t.Fatalf( "workspace page = items:%d id:%q next:%q, want 1/%q/empty; body=%s", - len(page.Items), page.Items[0].Metadata.ID, page.NextPageToken, wantID, response.Body.String(), + len(page.Items), firstID, page.NextPageToken, wantID, response.Body.String(), ) } assertNoResponseCanary(t, response, forbiddenCanary) From 232cda0f8951b44befade4ae4f31ce8c7a2846e1 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 12:35:45 -0500 Subject: [PATCH 06/17] test(security): separate credential canaries Signed-off-by: Gnani Rahul Nutakki --- test/security/security_regression_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 0bda0e5..e016e68 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -32,8 +32,8 @@ import ( ) const ( - securityBearerCanary = "security-route-bearer-canary" - invalidBearerCanary = "invalid-security-route-bearer-canary" + securityBearerCanary = "security-route-bearer+canary" + invalidBearerCanary = "invalid-security-route-bearer+canary" securityResourceCanary = "security-resource-confidential-canary" securityPolicyCanary = "security-policy-confidential-canary" securityMemberCanary = "mem_security_confidential_0001" @@ -150,6 +150,17 @@ type securityProblem struct { RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` } +func TestBearerCanariesAreNotRequestIDs(t *testing.T) { + for _, canary := range []string{securityBearerCanary, invalidBearerCanary} { + if _, err := ports.NewBearerCredential(canary); err != nil { + t.Fatalf("bearer canary is not a valid credential: %v", err) + } + if securityRequestIDPattern.MatchString(canary) { + t.Fatalf("bearer canary overlaps the request-ID grammar: %q", canary) + } + } +} + func TestPublicRouteSecurityMatrix(t *testing.T) { routes := publicSecurityRoutes() assertOpenAPIRouteCoverage(t, routes) From b47bdab6db8a3507e00a875d51c8b670d240b093 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 13:05:11 -0500 Subject: [PATCH 07/17] test(security): close boundary review gaps Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 4 +- test/security/security_fuzz_test.go | 4 +- test/security/security_regression_test.go | 123 +++++++++++++++++----- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md index 9ac9005..ff5cf96 100644 --- a/docs/security/regression-suite.md +++ b/docs/security/regression-suite.md @@ -47,7 +47,7 @@ The public-boundary target sends malformed body seeds through the member-authorized Workspace replacement route so they reach admission parsing. It asserts panic freedom, bounded JSON responses, mandatory no-store/nosniff/correlation headers, and bearer-canary absence. Every runtime -problem response must use a closed code/status pair, bind its request ID and +problem response must use a closed code/status/title tuple, bind its request ID and problem URNs, stay below 1,024 bytes, contain at most one field violation, and respect the field-path and text bounds. The OpenAPI validator independently rejects remote servers, remote references, and webhook expansion. No provider @@ -68,7 +68,7 @@ go test ./test/security -run '^$' -fuzz '^FuzzReferencePublicBoundary$' -fuzztim | Credential state | `TestCredentialValuesRedactAndRejectSerialization`, `FuzzSourceMaterialSafety`, and broker lease/lifecycle tests cover source/session material, requests, brokers, leases, rotation, errors, and destruction | Raw material remains callback-bounded; diagnostics redact; serialization rejected | | Identity and bearer state | Identity diagnostic/serialization tests, bearer canary tests, OIDC negative corpus, and the route matrix cover principals, request carriers, challenges, problems, and headers | Raw token and identity claims absent from output; rejected request carriers scrubbed | | Audit and privileged state | `TestOpaqueRuntimeValuesForbidLossyGenericSerialization` and `TestAdministrationDiagnosticsAndSerializationAreSafe` cover audit/elevation values, nested containers, errors, formatting, and `slog` | Canaries absent; opaque runtime values reject lossy serialization | -| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem codes and bounded field paths are returned; bearer, workspace, Policy, Member, operation, and identity canaries are absent from denied responses; retained negative seeds must preserve their exact rejection status | +| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem code/status/title tuples and bounded field paths are returned; bearer, workspace, Policy, Member, operation, resource-version, and identity canaries are absent from denied responses outside the required request-ID correlation fields; retained negative seeds must preserve their exact rejection status | | Logs | Package redaction tests send every sensitive value through `fmt` and `slog` | Canaries absent from implemented logging surfaces | | Traces | No tracer or trace exporter exists | Not applicable at this revision; issue #63 must add canaries with the first implementation | | Metrics | No metrics registry or exporter exists | Not applicable at this revision; issue #63 must add label/value canaries and cardinality bounds | diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index e71a21e..fa34c64 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -27,8 +27,8 @@ type securityFuzzSeed struct { func FuzzReferencePublicBoundary(f *testing.F) { seeds := []securityFuzzSeed{ {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil, 0}, - {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, 0}, - {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, 0}, + {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, + {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`), http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", fuzzWorkspaceResourceMatch, "req-fuzz-trailing", []byte(`{} {}`), http.StatusBadRequest}, diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index e016e68..0d45870 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -38,31 +38,37 @@ const ( securityPolicyCanary = "security-policy-confidential-canary" securityMemberCanary = "mem_security_confidential_0001" securityIdentityCanary = "security-identity-confidential-canary" + securityStaleVersionCanary = "rv_security_stale_0001" maximumSecurityProblemBytes = 1_024 maximumSecurityFieldPathSize = 96 ) +type securityProblemExpectation struct { + status int + title string +} + var ( - securityProblemCodePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - securityRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) - securityFieldPathPattern = regexp.MustCompile(`^(/([^~/]|~0|~1)*)+$`) - securityProblemStatuses = map[string]int{ - "validation-failed": http.StatusBadRequest, - "authentication-required": http.StatusUnauthorized, - "authorization-denied": http.StatusForbidden, - "not-found": http.StatusNotFound, - "method-not-allowed": http.StatusMethodNotAllowed, - "idempotency-key-reused": http.StatusConflict, - "uniqueness-conflict": http.StatusConflict, - "lifecycle-conflict": http.StatusConflict, - "policy-conflict": http.StatusConflict, - "precondition-failed": http.StatusPreconditionFailed, - "request-too-large": http.StatusRequestEntityTooLarge, - "unsupported-media-type": http.StatusUnsupportedMediaType, - "precondition-required": http.StatusPreconditionRequired, - "rate-limited": http.StatusTooManyRequests, - "internal-failure": http.StatusInternalServerError, - "unavailable": http.StatusServiceUnavailable, + securityProblemCodePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + securityRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + securityFieldPathPattern = regexp.MustCompile(`^(/([^~/]|~0|~1)*)+$`) + securityProblemExpectations = map[string]securityProblemExpectation{ + "validation-failed": {http.StatusBadRequest, "Request validation failed"}, + "authentication-required": {http.StatusUnauthorized, "Authentication required"}, + "authorization-denied": {http.StatusForbidden, "Authorization denied"}, + "not-found": {http.StatusNotFound, "Resource not found"}, + "method-not-allowed": {http.StatusMethodNotAllowed, "Method not allowed"}, + "idempotency-key-reused": {http.StatusConflict, "Request conflicts with a prior mutation"}, + "uniqueness-conflict": {http.StatusConflict, "Resource uniqueness conflict"}, + "lifecycle-conflict": {http.StatusConflict, "Resource lifecycle conflict"}, + "policy-conflict": {http.StatusConflict, "Resource policy conflict"}, + "precondition-failed": {http.StatusPreconditionFailed, "Resource version is stale"}, + "request-too-large": {http.StatusRequestEntityTooLarge, "Request body is too large"}, + "unsupported-media-type": {http.StatusUnsupportedMediaType, "Unsupported request media type"}, + "precondition-required": {http.StatusPreconditionRequired, "Mutation precondition required"}, + "rate-limited": {http.StatusTooManyRequests, "Request rate limited"}, + "internal-failure": {http.StatusInternalServerError, "Internal failure"}, + "unavailable": {http.StatusServiceUnavailable, "Service temporarily unavailable"}, } ) @@ -80,6 +86,7 @@ type securityRoute struct { outsiderOutcome string outsiderProblemCode string allRolesDenied bool + staleOutsiderCheck bool assertMemberBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) assertOutsiderBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) } @@ -188,6 +195,24 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { if route.assertOutsiderBody != nil { route.assertOutsiderBody(t, fixture, outsider) } + if route.staleOutsiderCheck { + t.Run("outsider-stale-precondition", func(t *testing.T) { + staleFixture := newSecurityFixture(t) + denied := securityRequestWithHeaderOverrides( + t, + staleFixture.outsiderHandler, + route, + staleFixture, + securityBearerCanary, + map[string]string{ + "If-Match": `"` + securityStaleVersionCanary + `"`, + "Veer-Request-Id": securityResourceCanary, + }, + ) + assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, denied, staleFixture) + }) + } fixture = newSecurityFixture(t) missing := securityRequest(t, fixture.memberHandler, route, fixture, "") @@ -277,6 +302,7 @@ func publicSecurityRoutes() []securityRoute { headers: mutationHeaders, memberStatus: http.StatusAccepted, memberOutcome: "allowed", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + staleOutsiderCheck: true, }, { operationID: "deleteWorkspace", method: http.MethodDelete, @@ -284,6 +310,7 @@ func publicSecurityRoutes() []securityRoute { headers: mutationHeaders, memberStatus: http.StatusConflict, memberOutcome: "authorization-allowed-lifecycle-denied", memberProblemCode: "lifecycle-conflict", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + staleOutsiderCheck: true, }, { operationID: "replaceWorkspaceStatus", method: http.MethodPut, @@ -445,6 +472,18 @@ func securityRequest( route securityRoute, fixture securityFixture, token string, +) *httptest.ResponseRecorder { + t.Helper() + return securityRequestWithHeaderOverrides(t, handler, route, fixture, token, nil) +} + +func securityRequestWithHeaderOverrides( + t testing.TB, + handler http.Handler, + route securityRoute, + fixture securityFixture, + token string, + overrides map[string]string, ) *httptest.ResponseRecorder { t.Helper() var body []byte @@ -460,6 +499,9 @@ func securityRequest( request.Header.Set(name, value) } } + for name, value := range overrides { + request.Header.Set(name, value) + } response := httptest.NewRecorder() handler.ServeHTTP(response, request) return response @@ -501,14 +543,37 @@ func assertNoBearerCanary(t testing.TB, response *httptest.ResponseRecorder) { func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { t.Helper() - assertNoResponseCanary( + outputs := []string{response.Body.String()} + problemResponse := response.Header().Get("Content-Type") == "application/problem+json" + if problemResponse { + var problem securityProblem + if err := json.Unmarshal(response.Body.Bytes(), &problem); err != nil { + t.Fatalf("decode problem for confidentiality scan: %v; body=%s", err, response.Body.String()) + } + problem.RequestID = "" + problem.Instance = "" + encoded, err := json.Marshal(problem) + if err != nil { + t.Fatalf("encode problem for confidentiality scan: %v", err) + } + outputs[0] = string(encoded) + } + for name, values := range response.Header() { + if problemResponse && strings.EqualFold(name, "Veer-Request-Id") { + continue + } + outputs = append(outputs, values...) + } + assertOutputsDoNotContainCanaries( t, - response, + outputs, securityResourceCanary, securityPolicyCanary, securityMemberCanary, securityIdentityCanary, + securityStaleVersionCanary, fixture.workspaceID.String(), + fixture.resourceVersion, fixture.operationID.String(), fixture.policyID.String(), fixture.memberID.String(), @@ -521,6 +586,11 @@ func assertNoResponseCanary(t testing.TB, response *httptest.ResponseRecorder, c for _, values := range response.Header() { outputs = append(outputs, values...) } + assertOutputsDoNotContainCanaries(t, outputs, canaries...) +} + +func assertOutputsDoNotContainCanaries(t testing.TB, outputs []string, canaries ...string) { + t.Helper() for _, output := range outputs { for _, canary := range canaries { if canary != "" && strings.Contains(output, canary) { @@ -586,13 +656,16 @@ func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseReco if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { t.Fatalf("runtime problem has trailing JSON: %v; body=%s", err, response.Body.String()) } - wantStatus, codeKnown := securityProblemStatuses[problem.Code] - if !codeKnown || wantStatus != response.Code || problem.Status != response.Code { + want, codeKnown := securityProblemExpectations[problem.Code] + if !codeKnown || want.status != response.Code || problem.Status != response.Code { t.Fatalf( "runtime problem code/status = %q/%d/%d, want a closed code for HTTP %d", - problem.Code, problem.Status, wantStatus, response.Code, + problem.Code, problem.Status, want.status, response.Code, ) } + if problem.Title != want.title { + t.Fatalf("runtime problem title = %q, want %q for code %q", problem.Title, want.title, problem.Code) + } requestID := response.Header().Get("Veer-Request-Id") if !securityRequestIDPattern.MatchString(problem.RequestID) || problem.RequestID != requestID || problem.Instance != "urn:veer:request:"+problem.RequestID || From 11adb70dac9ee9464b8cf2f5c79b4b474c6ae73f Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 13:25:54 -0500 Subject: [PATCH 08/17] test(security): harden boundary response oracle Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 8 +- test/security/security_fuzz_test.go | 10 +- test/security/security_regression_test.go | 218 +++++++++++++++++++--- 3 files changed, 208 insertions(+), 28 deletions(-) diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md index ff5cf96..427fa5d 100644 --- a/docs/security/regression-suite.md +++ b/docs/security/regression-suite.md @@ -47,9 +47,11 @@ The public-boundary target sends malformed body seeds through the member-authorized Workspace replacement route so they reach admission parsing. It asserts panic freedom, bounded JSON responses, mandatory no-store/nosniff/correlation headers, and bearer-canary absence. Every runtime -problem response must use a closed code/status/title tuple, bind its request ID and -problem URNs, stay below 1,024 bytes, contain at most one field violation, and -respect the field-path and text bounds. The OpenAPI validator independently +problem response must use unique JSON members and a closed code/status/title +tuple, bind its request ID and problem URNs, bind required retry body/header +metadata, stay below 1,024 bytes, contain at most one field violation, and +respect the field-path and text bounds. Valid client request IDs must be echoed +exactly. The OpenAPI validator independently rejects remote servers, remote references, and webhook expansion. No provider response parser exists yet; provider-specific malicious-response corpora belong with the adapters introduced by issues #38 and #41. diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index fa34c64..b5b9906 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -27,7 +28,7 @@ type securityFuzzSeed struct { func FuzzReferencePublicBoundary(f *testing.F) { seeds := []securityFuzzSeed{ {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil, 0}, - {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + invalidBearerCanary, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, + {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + url.QueryEscape(invalidBearerCanary), "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`), http.StatusBadRequest}, @@ -82,6 +83,13 @@ func FuzzReferencePublicBoundary(f *testing.F) { } response := httptest.NewRecorder() fixture.memberHandler.ServeHTTP(response, request) + if requestID != "" && securityRequestIDPattern.MatchString(requestID) && + response.Header().Get("Veer-Request-Id") != requestID { + t.Fatalf( + "response request ID = %q, want echoed client value %q", + response.Header().Get("Veer-Request-Id"), requestID, + ) + } if authorizedFixtureMutation && response.Code == http.StatusForbidden { t.Fatalf("authorized fixture mutation was denied before reaching admission: body=%s", response.Body.String()) } diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 0d45870..6a223eb 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -15,6 +16,7 @@ import ( "reflect" "regexp" "sort" + "strconv" "strings" "testing" "time" @@ -44,8 +46,9 @@ const ( ) type securityProblemExpectation struct { - status int - title string + status int + title string + requiresRetryAfter bool } var ( @@ -53,22 +56,26 @@ var ( securityRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) securityFieldPathPattern = regexp.MustCompile(`^(/([^~/]|~0|~1)*)+$`) securityProblemExpectations = map[string]securityProblemExpectation{ - "validation-failed": {http.StatusBadRequest, "Request validation failed"}, - "authentication-required": {http.StatusUnauthorized, "Authentication required"}, - "authorization-denied": {http.StatusForbidden, "Authorization denied"}, - "not-found": {http.StatusNotFound, "Resource not found"}, - "method-not-allowed": {http.StatusMethodNotAllowed, "Method not allowed"}, - "idempotency-key-reused": {http.StatusConflict, "Request conflicts with a prior mutation"}, - "uniqueness-conflict": {http.StatusConflict, "Resource uniqueness conflict"}, - "lifecycle-conflict": {http.StatusConflict, "Resource lifecycle conflict"}, - "policy-conflict": {http.StatusConflict, "Resource policy conflict"}, - "precondition-failed": {http.StatusPreconditionFailed, "Resource version is stale"}, - "request-too-large": {http.StatusRequestEntityTooLarge, "Request body is too large"}, - "unsupported-media-type": {http.StatusUnsupportedMediaType, "Unsupported request media type"}, - "precondition-required": {http.StatusPreconditionRequired, "Mutation precondition required"}, - "rate-limited": {http.StatusTooManyRequests, "Request rate limited"}, - "internal-failure": {http.StatusInternalServerError, "Internal failure"}, - "unavailable": {http.StatusServiceUnavailable, "Service temporarily unavailable"}, + "validation-failed": {status: http.StatusBadRequest, title: "Request validation failed"}, + "authentication-required": {status: http.StatusUnauthorized, title: "Authentication required"}, + "authorization-denied": {status: http.StatusForbidden, title: "Authorization denied"}, + "not-found": {status: http.StatusNotFound, title: "Resource not found"}, + "method-not-allowed": {status: http.StatusMethodNotAllowed, title: "Method not allowed"}, + "idempotency-key-reused": {status: http.StatusConflict, title: "Request conflicts with a prior mutation"}, + "uniqueness-conflict": {status: http.StatusConflict, title: "Resource uniqueness conflict"}, + "lifecycle-conflict": {status: http.StatusConflict, title: "Resource lifecycle conflict"}, + "policy-conflict": {status: http.StatusConflict, title: "Resource policy conflict"}, + "precondition-failed": {status: http.StatusPreconditionFailed, title: "Resource version is stale"}, + "request-too-large": {status: http.StatusRequestEntityTooLarge, title: "Request body is too large"}, + "unsupported-media-type": {status: http.StatusUnsupportedMediaType, title: "Unsupported request media type"}, + "precondition-required": {status: http.StatusPreconditionRequired, title: "Mutation precondition required"}, + "rate-limited": { + status: http.StatusTooManyRequests, title: "Request rate limited", requiresRetryAfter: true, + }, + "internal-failure": {status: http.StatusInternalServerError, title: "Internal failure"}, + "unavailable": { + status: http.StatusServiceUnavailable, title: "Service temporarily unavailable", requiresRetryAfter: true, + }, } ) @@ -154,7 +161,7 @@ type securityProblem struct { Code string `json:"code"` RequestID string `json:"requestId"` Errors []securityFieldViolation `json:"errors,omitempty"` - RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` + RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"` } func TestBearerCanariesAreNotRequestIDs(t *testing.T) { @@ -168,6 +175,64 @@ func TestBearerCanariesAreNotRequestIDs(t *testing.T) { } } +func TestSecurityProblemJSONRejectsDuplicateMembers(t *testing.T) { + tests := []struct { + name string + body string + wantErr bool + }{ + {name: "unique", body: `{"detail":"safe","errors":[{"field":"/spec","code":"invalid","message":"safe"}]}`}, + {name: "duplicate top-level", body: `{"detail":"secret","detail":"safe"}`, wantErr: true}, + {name: "escaped duplicate", body: `{"detail":"secret","\u0064etail":"safe"}`, wantErr: true}, + {name: "duplicate nested", body: `{"errors":[{"message":"secret","message":"safe"}]}`, wantErr: true}, + {name: "malformed", body: `{"detail":`, wantErr: true}, + {name: "trailing value", body: `{} {}`, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateUniqueJSONMembers([]byte(test.body)) + if (err != nil) != test.wantErr { + t.Fatalf("validateUniqueJSONMembers() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestSecurityProblemRetryMetadata(t *testing.T) { + retry := func(value int) *int { return &value } + tests := []struct { + name string + code string + body *int + headers []string + wantErr bool + }{ + {name: "unavailable", code: "unavailable", body: retry(10), headers: []string{"10"}}, + {name: "rate limited", code: "rate-limited", body: retry(5), headers: []string{"5"}}, + {name: "missing body", code: "unavailable", headers: []string{"10"}, wantErr: true}, + {name: "zero body", code: "unavailable", body: retry(0), headers: []string{"0"}, wantErr: true}, + {name: "missing header", code: "unavailable", body: retry(10), wantErr: true}, + {name: "duplicate header", code: "unavailable", body: retry(10), headers: []string{"10", "10"}, wantErr: true}, + {name: "mismatched header", code: "unavailable", body: retry(10), headers: []string{"11"}, wantErr: true}, + {name: "non-canonical header", code: "unavailable", body: retry(10), headers: []string{"010"}, wantErr: true}, + {name: "ordinary problem", code: "validation-failed"}, + {name: "unexpected body", code: "validation-failed", body: retry(1), wantErr: true}, + {name: "unexpected header", code: "validation-failed", headers: []string{"1"}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSecurityRetryAfter( + securityProblemExpectations[test.code], + securityProblem{RetryAfterSeconds: test.body}, + test.headers, + ) + if (err != nil) != test.wantErr { + t.Fatalf("validateSecurityRetryAfter() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + func TestPublicRouteSecurityMatrix(t *testing.T) { routes := publicSecurityRoutes() assertOpenAPIRouteCoverage(t, routes) @@ -517,9 +582,10 @@ func assertSecurityResponse( if response.Code != status { t.Fatalf("response status = %d, want %d; body=%s", response.Code, status, response.Body.String()) } + requestIDs := response.Header().Values("Veer-Request-Id") if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("X-Content-Type-Options") != "nosniff" || - response.Header().Get("Veer-Request-Id") == "" { + len(requestIDs) != 1 || requestIDs[0] == "" { t.Fatalf("security headers = %#v", response.Header()) } if !json.Valid(response.Body.Bytes()) { @@ -546,10 +612,23 @@ func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fi outputs := []string{response.Body.String()} problemResponse := response.Header().Get("Content-Type") == "application/problem+json" if problemResponse { + if err := validateUniqueJSONMembers(response.Body.Bytes()); err != nil { + t.Fatalf("validate problem for confidentiality scan: %v; body=%s", err, response.Body.String()) + } + decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) + decoder.DisallowUnknownFields() var problem securityProblem - if err := json.Unmarshal(response.Body.Bytes(), &problem); err != nil { + if err := decoder.Decode(&problem); err != nil { t.Fatalf("decode problem for confidentiality scan: %v; body=%s", err, response.Body.String()) } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + t.Fatalf("problem has trailing JSON during confidentiality scan: %v; body=%s", err, response.Body.String()) + } + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 || problem.RequestID != requestIDs[0] || + problem.Instance != "urn:veer:request:"+problem.RequestID { + t.Fatalf("problem correlation fields are not uniquely bound: body=%s headers=%#v", response.Body.String(), response.Header()) + } problem.RequestID = "" problem.Instance = "" encoded, err := json.Marshal(problem) @@ -647,6 +726,9 @@ func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseReco if response.Body.Len() == 0 || response.Body.Len() > maximumSecurityProblemBytes { t.Fatalf("problem body bytes = %d, want 1..%d", response.Body.Len(), maximumSecurityProblemBytes) } + if err := validateUniqueJSONMembers(response.Body.Bytes()); err != nil { + t.Fatalf("runtime problem JSON members: %v; body=%s", err, response.Body.String()) + } decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) decoder.DisallowUnknownFields() var problem securityProblem @@ -666,7 +748,11 @@ func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseReco if problem.Title != want.title { t.Fatalf("runtime problem title = %q, want %q for code %q", problem.Title, want.title, problem.Code) } - requestID := response.Header().Get("Veer-Request-Id") + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 { + t.Fatalf("Veer-Request-Id values = %q, want exactly one", requestIDs) + } + requestID := requestIDs[0] if !securityRequestIDPattern.MatchString(problem.RequestID) || problem.RequestID != requestID || problem.Instance != "urn:veer:request:"+problem.RequestID || problem.Type != "urn:veer:problem:"+problem.Code { @@ -692,12 +778,96 @@ func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseReco t.Fatalf("runtime field violation bounds failed: %#v", violation) } } - if problem.RetryAfterSeconds < 0 || problem.RetryAfterSeconds > 86_400 { - t.Fatalf("runtime retryAfterSeconds = %d, want 0 or 1..86400", problem.RetryAfterSeconds) + if err := validateSecurityRetryAfter(want, problem, response.Header().Values("Retry-After")); err != nil { + t.Fatalf("runtime retry metadata: %v", err) } return problem } +func validateSecurityRetryAfter( + want securityProblemExpectation, + problem securityProblem, + headers []string, +) error { + if !want.requiresRetryAfter { + if problem.RetryAfterSeconds != nil || len(headers) != 0 { + return errors.New("non-retryable problem declared retry metadata") + } + return nil + } + if problem.RetryAfterSeconds == nil || *problem.RetryAfterSeconds < 1 || *problem.RetryAfterSeconds > 86_400 { + return errors.New("retryable problem requires retryAfterSeconds in 1..86400") + } + if len(headers) != 1 || headers[0] != strconv.Itoa(*problem.RetryAfterSeconds) { + return fmt.Errorf("Retry-After values %q do not match retryAfterSeconds %d", headers, *problem.RetryAfterSeconds) + } + return nil +} + +func validateUniqueJSONMembers(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := scanUniqueJSONValue(decoder, 0); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return errors.New("JSON has a trailing value") + } + return nil +} + +func scanUniqueJSONValue(decoder *json.Decoder, depth int) error { + if depth > 64 { + return errors.New("JSON nesting exceeds 64 levels") + } + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, compound := token.(json.Delim) + if !compound { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("JSON object member name is not a string") + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("duplicate JSON member %q", key) + } + seen[key] = struct{}{} + if err := scanUniqueJSONValue(decoder, depth+1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return errors.New("JSON object is not closed") + } + return nil + case '[': + for decoder.More() { + if err := scanUniqueJSONValue(decoder, depth+1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return errors.New("JSON array is not closed") + } + return nil + default: + return errors.New("unexpected JSON delimiter") + } +} + func safeSecurityProblemText(value string, maximum int, required bool) bool { if len(value) > maximum || required && value == "" { return false From c22bd063171d92cff07a458b0188342fd69bfb5a Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 13:49:41 -0500 Subject: [PATCH 09/17] test(security): close boundary review gaps Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 11 +- test/security/security_regression_test.go | 119 +++++++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index b5b9906..4ff2e1b 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -83,11 +83,15 @@ func FuzzReferencePublicBoundary(f *testing.F) { } response := httptest.NewRecorder() fixture.memberHandler.ServeHTTP(response, request) + responseRequestIDs := response.Header().Values("Veer-Request-Id") + if len(responseRequestIDs) != 1 || responseRequestIDs[0] == "" { + t.Fatalf("response request ID values = %q, want exactly one non-empty value", responseRequestIDs) + } if requestID != "" && securityRequestIDPattern.MatchString(requestID) && - response.Header().Get("Veer-Request-Id") != requestID { + responseRequestIDs[0] != requestID { t.Fatalf( "response request ID = %q, want echoed client value %q", - response.Header().Get("Veer-Request-Id"), requestID, + responseRequestIDs[0], requestID, ) } if authorizedFixtureMutation && response.Code == http.StatusForbidden { @@ -101,8 +105,7 @@ func FuzzReferencePublicBoundary(f *testing.F) { } if response.Header().Get("Cache-Control") != "no-store" || - response.Header().Get("X-Content-Type-Options") != "nosniff" || - response.Header().Get("Veer-Request-Id") == "" { + response.Header().Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("security headers = %#v", response.Header()) } switch { diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 6a223eb..7b52635 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -37,6 +37,7 @@ const ( securityBearerCanary = "security-route-bearer+canary" invalidBearerCanary = "invalid-security-route-bearer+canary" securityResourceCanary = "security-resource-confidential-canary" + securityOutsiderCanary = "security-outsider-workspace-confidential-canary" securityPolicyCanary = "security-policy-confidential-canary" securityMemberCanary = "mem_security_confidential_0001" securityIdentityCanary = "security-identity-confidential-canary" @@ -93,6 +94,7 @@ type securityRoute struct { outsiderOutcome string outsiderProblemCode string allRolesDenied bool + workspaceAdminOnly bool staleOutsiderCheck bool assertMemberBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) assertOutsiderBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) @@ -233,6 +235,32 @@ func TestSecurityProblemRetryMetadata(t *testing.T) { } } +func TestSecurityProblemMemberPresence(t *testing.T) { + tests := []struct { + name string + body string + wantErr bool + }{ + {name: "minimal", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test"}`}, + {name: "field violation", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","detail":"safe","errors":[{"field":"/spec","code":"invalid","message":"safe"}]}`}, + {name: "missing required", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","requestId":"req-test"}`, wantErr: true}, + {name: "null required", body: `{"type":"urn:veer:problem:validation-failed","title":null,"status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test"}`, wantErr: true}, + {name: "null detail", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","detail":null}`, wantErr: true}, + {name: "null errors", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","errors":null}`, wantErr: true}, + {name: "null retry", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","retryAfterSeconds":null}`, wantErr: true}, + {name: "missing violation member", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","errors":[{"field":"/spec","code":"invalid"}]}`, wantErr: true}, + {name: "null violation member", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","errors":[{"field":"/spec","code":"invalid","message":null}]}`, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSecurityProblemMembers([]byte(test.body)) + if (err != nil) != test.wantErr { + t.Fatalf("validateSecurityProblemMembers() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + func TestPublicRouteSecurityMatrix(t *testing.T) { routes := publicSecurityRoutes() assertOpenAPIRouteCoverage(t, routes) @@ -300,6 +328,20 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { }) } } + if route.workspaceAdminOnly { + for _, role := range authorization.Roles() { + if role == authorization.RoleWorkspaceAdministrator { + continue + } + role := role + t.Run("administrator-only-"+role.String(), func(t *testing.T) { + roleFixture := newSecurityFixtureForRole(t, role) + denied := securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, denied, roleFixture) + }) + } + } results = append(results, securityRouteResult{ OperationID: route.operationID, Method: route.method, PathTemplate: route.pathTemplate, @@ -332,7 +374,7 @@ func publicSecurityRoutes() []securityRoute { memberStatus: http.StatusOK, memberOutcome: "allowed", outsiderStatus: http.StatusOK, outsiderOutcome: "denied-rows-filtered-before-pagination", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { - assertWorkspacePage(t, response, fixture.workspaceID, "security outsider workspace") + assertWorkspacePage(t, response, fixture.workspaceID, securityOutsiderCanary) }, assertOutsiderBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspacePage(t, response, fixture.outsiderWorkspaceID, securityResourceCanary) @@ -359,6 +401,10 @@ func publicSecurityRoutes() []securityRoute { pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, memberStatus: http.StatusOK, memberOutcome: "allowed", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertWorkspaceObject(t, response, fixture.workspaceID) + assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + }, }, { operationID: "replaceWorkspace", method: http.MethodPut, @@ -367,6 +413,7 @@ func publicSecurityRoutes() []securityRoute { headers: mutationHeaders, memberStatus: http.StatusAccepted, memberOutcome: "allowed", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + workspaceAdminOnly: true, staleOutsiderCheck: true, }, { @@ -375,6 +422,7 @@ func publicSecurityRoutes() []securityRoute { headers: mutationHeaders, memberStatus: http.StatusConflict, memberOutcome: "authorization-allowed-lifecycle-denied", memberProblemCode: "lifecycle-conflict", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + workspaceAdminOnly: true, staleOutsiderCheck: true, }, { @@ -433,7 +481,7 @@ func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) secu outsiderWorkspace, err := service.Create(context.Background(), reference.CreateCommand{ Principal: outsider, Kind: hierarchy.KindWorkspace, CanonicalTarget: "security:bootstrap:outsider-workspace", IdempotencyKey: "security-bootstrap-workspace-0002", - Body: validWorkspaceBody("security outsider workspace"), + Body: validWorkspaceBody(securityOutsiderCanary), }) if err != nil { t.Fatal(err) @@ -718,6 +766,21 @@ func assertWorkspacePage( assertNoResponseCanary(t, response, forbiddenCanary) } +func assertWorkspaceObject(t testing.TB, response *httptest.ResponseRecorder, wantID resource.ID) { + t.Helper() + var workspace struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(response.Body.Bytes(), &workspace); err != nil { + t.Fatalf("decode workspace: %v; body=%s", err, response.Body.String()) + } + if workspace.Metadata.ID != wantID.String() { + t.Fatalf("workspace ID = %q, want %q; body=%s", workspace.Metadata.ID, wantID, response.Body.String()) + } +} + func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { t.Helper() if response.Header().Get("Content-Type") != "application/problem+json" { @@ -729,6 +792,9 @@ func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseReco if err := validateUniqueJSONMembers(response.Body.Bytes()); err != nil { t.Fatalf("runtime problem JSON members: %v; body=%s", err, response.Body.String()) } + if err := validateSecurityProblemMembers(response.Body.Bytes()); err != nil { + t.Fatalf("runtime problem required members: %v; body=%s", err, response.Body.String()) + } decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) decoder.DisallowUnknownFields() var problem securityProblem @@ -804,6 +870,55 @@ func validateSecurityRetryAfter( return nil } +func validateSecurityProblemMembers(data []byte) error { + var problem map[string]json.RawMessage + if err := json.Unmarshal(data, &problem); err != nil { + return err + } + if err := requireNonNullJSONMembers( + problem, + []string{"type", "title", "status", "instance", "code", "requestId"}, + ); err != nil { + return err + } + for _, optional := range []string{"detail", "errors", "retryAfterSeconds"} { + if raw, exists := problem[optional]; exists && isJSONNull(raw) { + return fmt.Errorf("problem member %q is null", optional) + } + } + rawErrors, exists := problem["errors"] + if !exists { + return nil + } + var violations []map[string]json.RawMessage + if err := json.Unmarshal(rawErrors, &violations); err != nil { + return fmt.Errorf("decode problem errors: %w", err) + } + for index, violation := range violations { + if err := requireNonNullJSONMembers(violation, []string{"field", "code", "message"}); err != nil { + return fmt.Errorf("problem errors[%d]: %w", index, err) + } + } + return nil +} + +func requireNonNullJSONMembers(object map[string]json.RawMessage, names []string) error { + for _, name := range names { + raw, exists := object[name] + if !exists { + return fmt.Errorf("required member %q is missing", name) + } + if isJSONNull(raw) { + return fmt.Errorf("required member %q is null", name) + } + } + return nil +} + +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + func validateUniqueJSONMembers(data []byte) error { decoder := json.NewDecoder(bytes.NewReader(data)) if err := scanUniqueJSONValue(decoder, 0); err != nil { From 9c450b577584c3538f9e242c56a52e19e6fa7367 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 14:09:57 -0500 Subject: [PATCH 10/17] test(security): bind operation and auth evidence Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 7 +++ test/security/security_regression_test.go | 53 ++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 4ff2e1b..2b0c722 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -103,6 +103,13 @@ func FuzzReferencePublicBoundary(f *testing.F) { response.Code, retainedRejectionStatus, response.Body.String(), ) } + if response.Code == http.StatusUnauthorized { + expectedChallenge := "" + if retainedRejectionStatus == http.StatusUnauthorized { + expectedChallenge = `Bearer realm="veer", error="invalid_request"` + } + assertSecurityAuthenticationChallenge(t, response, expectedChallenge) + } if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("X-Content-Type-Options") != "nosniff" { diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 7b52635..0ed19b8 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -445,6 +445,9 @@ func publicSecurityRoutes() []securityRoute { }, memberStatus: http.StatusOK, memberOutcome: "allowed", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertOperationObject(t, response, fixture.operationID, fixture.workspaceID, fixture.workspaceID) + }, }, } } @@ -730,8 +733,30 @@ func assertOutputsDoNotContainCanaries(t testing.TB, outputs []string, canaries func assertAuthenticationDenial(t testing.TB, response *httptest.ResponseRecorder, challenge string) { t.Helper() assertSecurityResponse(t, response, http.StatusUnauthorized, "authentication-required") - if response.Header().Get("WWW-Authenticate") != challenge { - t.Fatalf("WWW-Authenticate = %q, want %q", response.Header().Get("WWW-Authenticate"), challenge) + assertSecurityAuthenticationChallenge(t, response, challenge) +} + +func assertSecurityAuthenticationChallenge( + t testing.TB, + response *httptest.ResponseRecorder, + want string, +) { + t.Helper() + challenges := response.Header().Values("WWW-Authenticate") + if len(challenges) != 1 { + t.Fatalf("WWW-Authenticate values = %q, want exactly one", challenges) + } + if want != "" && challenges[0] != want { + t.Fatalf("WWW-Authenticate = %q, want %q", challenges[0], want) + } + if want == "" { + switch challenges[0] { + case `Bearer realm="veer"`, + `Bearer realm="veer", error="invalid_request"`, + `Bearer realm="veer", error="invalid_token"`: + default: + t.Fatalf("WWW-Authenticate = %q, want a canonical bearer challenge", challenges[0]) + } } } @@ -781,6 +806,30 @@ func assertWorkspaceObject(t testing.TB, response *httptest.ResponseRecorder, wa } } +func assertOperationObject( + t testing.TB, + response *httptest.ResponseRecorder, + wantID, wantWorkspaceID, wantResourceID resource.ID, +) { + t.Helper() + var operation struct { + ID string `json:"id"` + WorkspaceID string `json:"workspaceId"` + ResourceID string `json:"resourceId"` + } + if err := json.Unmarshal(response.Body.Bytes(), &operation); err != nil { + t.Fatalf("decode operation: %v; body=%s", err, response.Body.String()) + } + if operation.ID != wantID.String() || operation.WorkspaceID != wantWorkspaceID.String() || + operation.ResourceID != wantResourceID.String() { + t.Fatalf( + "operation identity = id:%q workspace:%q resource:%q, want %q/%q/%q; body=%s", + operation.ID, operation.WorkspaceID, operation.ResourceID, + wantID, wantWorkspaceID, wantResourceID, response.Body.String(), + ) + } +} + func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { t.Helper() if response.Header().Get("Content-Type") != "application/problem+json" { From fac622a15aedfadc082cf76ba26982700c27900a Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 14:33:46 -0500 Subject: [PATCH 11/17] test(security): harden response and auth ordering Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 24 +++++-- test/security/security_regression_test.go | 83 ++++++++++++++++++++--- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 2b0c722..25035c3 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -19,6 +19,12 @@ const ( fuzzWorkspaceResourceMatch = "__security_fixture_resource_version__" ) +var retainedSecurityAuthenticationChallenges = map[string]string{ + "req-fuzz-query-token": `Bearer realm="veer", error="invalid_request"`, + "req-fuzz-missing-auth": `Bearer realm="veer"`, + "req-fuzz-invalid-auth": `Bearer realm="veer", error="invalid_token"`, +} + type securityFuzzSeed struct { method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string body []byte @@ -37,6 +43,8 @@ func FuzzReferencePublicBoundary(f *testing.F) { {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", fuzzWorkspaceResourceMatch, "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65)), http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", fuzzWorkspaceResourceMatch, "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`), http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-oversized-0001", fuzzWorkspaceResourceMatch, "req-fuzz-oversized", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1), http.StatusRequestEntityTooLarge}, + {http.MethodPut, fuzzWorkspaceTarget, "", "application/json", "fuzz-missing-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-missing-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + invalidBearerCanary, "application/json", "fuzz-invalid-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-invalid-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil, 0}, } for _, seed := range seeds { @@ -106,19 +114,21 @@ func FuzzReferencePublicBoundary(f *testing.F) { if response.Code == http.StatusUnauthorized { expectedChallenge := "" if retainedRejectionStatus == http.StatusUnauthorized { - expectedChallenge = `Bearer realm="veer", error="invalid_request"` + var exists bool + expectedChallenge, exists = retainedSecurityAuthenticationChallenges[requestID] + if !exists { + t.Fatalf("retained 401 seed %q has no expected authentication challenge", requestID) + } } assertSecurityAuthenticationChallenge(t, response, expectedChallenge) } - if response.Header().Get("Cache-Control") != "no-store" || - response.Header().Get("X-Content-Type-Options") != "nosniff" { - t.Fatalf("security headers = %#v", response.Header()) - } + assertSingletonHeader(t, response, "Cache-Control", "no-store") + assertSingletonHeader(t, response, "X-Content-Type-Options", "nosniff") switch { case response.Code >= http.StatusOK && response.Code < http.StatusMultipleChoices: - if response.Header().Get("Content-Type") != "application/json" || - response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { + assertSingletonHeader(t, response, "Content-Type", "application/json") + if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { t.Fatalf("success response contract failed: status=%d bytes=%d headers=%#v", response.Code, response.Body.Len(), response.Header()) } case response.Code >= http.StatusBadRequest && response.Code <= 599: diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 0ed19b8..aae3a4d 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -378,7 +378,7 @@ func publicSecurityRoutes() []securityRoute { }, assertOutsiderBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspacePage(t, response, fixture.outsiderWorkspaceID, securityResourceCanary) - assertNoFixtureCanary(t, response, fixture) + assertNoMemberFixtureCanary(t, response, fixture) }, }, { @@ -415,6 +415,10 @@ func publicSecurityRoutes() []securityRoute { outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", workspaceAdminOnly: true, staleOutsiderCheck: true, + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertMutationReceipt(t, response, fixture.workspaceID) + assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + }, }, { operationID: "deleteWorkspace", method: http.MethodDelete, @@ -633,11 +637,11 @@ func assertSecurityResponse( if response.Code != status { t.Fatalf("response status = %d, want %d; body=%s", response.Code, status, response.Body.String()) } + assertSingletonHeader(t, response, "Cache-Control", "no-store") + assertSingletonHeader(t, response, "X-Content-Type-Options", "nosniff") requestIDs := response.Header().Values("Veer-Request-Id") - if response.Header().Get("Cache-Control") != "no-store" || - response.Header().Get("X-Content-Type-Options") != "nosniff" || - len(requestIDs) != 1 || requestIDs[0] == "" { - t.Fatalf("security headers = %#v", response.Header()) + if len(requestIDs) != 1 || requestIDs[0] == "" { + t.Fatalf("Veer-Request-Id values = %q, want exactly one non-empty value", requestIDs) } if !json.Valid(response.Body.Bytes()) { t.Fatalf("response is not JSON: %q", response.Body.Bytes()) @@ -647,8 +651,8 @@ func assertSecurityResponse( if problem.Code != problemCode { t.Fatalf("problem code = %q, want %q; body=%s", problem.Code, problemCode, response.Body.String()) } - } else if response.Header().Get("Content-Type") != "application/json" { - t.Fatalf("Content-Type = %q, want application/json", response.Header().Get("Content-Type")) + } else { + assertSingletonHeader(t, response, "Content-Type", "application/json") } assertNoBearerCanary(t, response) } @@ -658,6 +662,24 @@ func assertNoBearerCanary(t testing.TB, response *httptest.ResponseRecorder) { assertNoResponseCanary(t, response, securityBearerCanary, invalidBearerCanary) } +func assertNoMemberFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + t.Helper() + assertNoResponseCanary( + t, + response, + securityResourceCanary, + securityPolicyCanary, + securityMemberCanary, + securityIdentityCanary, + securityStaleVersionCanary, + fixture.workspaceID.String(), + fixture.resourceVersion, + fixture.operationID.String(), + fixture.policyID.String(), + fixture.memberID.String(), + ) +} + func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { t.Helper() outputs := []string{response.Body.String()} @@ -702,7 +724,9 @@ func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fi securityMemberCanary, securityIdentityCanary, securityStaleVersionCanary, + securityOutsiderCanary, fixture.workspaceID.String(), + fixture.outsiderWorkspaceID.String(), fixture.resourceVersion, fixture.operationID.String(), fixture.policyID.String(), @@ -760,6 +784,18 @@ func assertSecurityAuthenticationChallenge( } } +func assertSingletonHeader( + t testing.TB, + response *httptest.ResponseRecorder, + name, want string, +) { + t.Helper() + values := response.Header().Values(name) + if len(values) != 1 || values[0] != want { + t.Fatalf("%s values = %q, want exactly one %q", name, values, want) + } +} + func assertWorkspacePage( t testing.TB, response *httptest.ResponseRecorder, @@ -830,11 +866,38 @@ func assertOperationObject( } } -func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { +func assertMutationReceipt( + t testing.TB, + response *httptest.ResponseRecorder, + wantResourceID resource.ID, +) { t.Helper() - if response.Header().Get("Content-Type") != "application/problem+json" { - t.Fatalf("Content-Type = %q, want application/problem+json", response.Header().Get("Content-Type")) + var receipt reference.MutationReceipt + decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&receipt); err != nil { + t.Fatalf("decode mutation receipt: %v; body=%s", err, response.Body.String()) } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + t.Fatalf("mutation receipt has trailing JSON: %v; body=%s", err, response.Body.String()) + } + if receipt.ResourceID != wantResourceID { + t.Fatalf("mutation receipt resource ID = %q, want %q; body=%s", receipt.ResourceID, wantResourceID, response.Body.String()) + } + if _, err := resource.ParseID(receipt.OperationID.String()); err != nil { + t.Fatalf("mutation receipt operation ID = %q: %v; body=%s", receipt.OperationID, err, response.Body.String()) + } + assertSingletonHeader( + t, + response, + "Location", + "/api/v1alpha1/operations/"+receipt.OperationID.String(), + ) +} + +func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { + t.Helper() + assertSingletonHeader(t, response, "Content-Type", "application/problem+json") if response.Body.Len() == 0 || response.Body.Len() > maximumSecurityProblemBytes { t.Fatalf("problem body bytes = %d, want 1..%d", response.Body.Len(), maximumSecurityProblemBytes) } From b840355b77968adfe2f48cc4e868e99eb76a8d82 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 14:50:15 -0500 Subject: [PATCH 12/17] test(security): scan successful responses for outsider data Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 1 + test/security/security_regression_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 25035c3..4acbe92 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -131,6 +131,7 @@ func FuzzReferencePublicBoundary(f *testing.F) { if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { t.Fatalf("success response contract failed: status=%d bytes=%d headers=%#v", response.Code, response.Body.Len(), response.Header()) } + assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) case response.Code >= http.StatusBadRequest && response.Code <= 599: assertSecurityProblemContract(t, response) assertNoFixtureCanary(t, response, fixture) diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index aae3a4d..a9f56b2 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -375,6 +375,7 @@ func publicSecurityRoutes() []securityRoute { outsiderStatus: http.StatusOK, outsiderOutcome: "denied-rows-filtered-before-pagination", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspacePage(t, response, fixture.workspaceID, securityOutsiderCanary) + assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, assertOutsiderBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspacePage(t, response, fixture.outsiderWorkspaceID, securityResourceCanary) @@ -451,6 +452,7 @@ func publicSecurityRoutes() []securityRoute { outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertOperationObject(t, response, fixture.operationID, fixture.workspaceID, fixture.workspaceID) + assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, }, } From 65232c7b90850e6e06d51a493655dd99f8b5bcbc Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 15:01:50 -0500 Subject: [PATCH 13/17] test(security): close response oracle bypasses Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 1 + test/security/security_regression_test.go | 30 ++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 4acbe92..9e72559 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -132,6 +132,7 @@ func FuzzReferencePublicBoundary(f *testing.F) { t.Fatalf("success response contract failed: status=%d bytes=%d headers=%#v", response.Code, response.Body.Len(), response.Header()) } assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulFixtureSecrets(t, response, fixture) case response.Code >= http.StatusBadRequest && response.Code <= 599: assertSecurityProblemContract(t, response) assertNoFixtureCanary(t, response, fixture) diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index a9f56b2..7711841 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -187,6 +187,8 @@ func TestSecurityProblemJSONRejectsDuplicateMembers(t *testing.T) { {name: "duplicate top-level", body: `{"detail":"secret","detail":"safe"}`, wantErr: true}, {name: "escaped duplicate", body: `{"detail":"secret","\u0064etail":"safe"}`, wantErr: true}, {name: "duplicate nested", body: `{"errors":[{"message":"secret","message":"safe"}]}`, wantErr: true}, + {name: "case-variant top-level", body: `{"Title":"secret","title":"safe"}`, wantErr: true}, + {name: "case-variant nested", body: `{"errors":[{"Message":"secret","message":"safe"}]}`, wantErr: true}, {name: "malformed", body: `{"detail":`, wantErr: true}, {name: "trailing value", body: `{} {}`, wantErr: true}, } @@ -274,6 +276,8 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { assertSecurityResponse(t, member, route.memberStatus, route.memberProblemCode) if route.memberStatus >= http.StatusBadRequest { assertNoFixtureCanary(t, member, fixture) + } else { + assertNoSuccessfulFixtureSecrets(t, member, fixture) } if route.assertMemberBody != nil { route.assertMemberBody(t, fixture, member) @@ -284,6 +288,8 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { assertSecurityResponse(t, outsider, route.outsiderStatus, route.outsiderProblemCode) if route.outsiderStatus >= http.StatusBadRequest { assertNoFixtureCanary(t, outsider, fixture) + } else { + assertNoSuccessfulFixtureSecrets(t, outsider, fixture) } if route.assertOutsiderBody != nil { route.assertOutsiderBody(t, fixture, outsider) @@ -682,6 +688,20 @@ func assertNoMemberFixtureCanary(t testing.TB, response *httptest.ResponseRecord ) } +func assertNoSuccessfulFixtureSecrets(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + t.Helper() + assertNoResponseCanary( + t, + response, + securityPolicyCanary, + securityMemberCanary, + securityIdentityCanary, + securityStaleVersionCanary, + fixture.policyID.String(), + fixture.memberID.String(), + ) +} + func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { t.Helper() outputs := []string{response.Body.String()} @@ -1058,7 +1078,7 @@ func scanUniqueJSONValue(decoder *json.Decoder, depth int) error { } switch delimiter { case '{': - seen := make(map[string]struct{}) + seen := make([]string, 0) for decoder.More() { keyToken, err := decoder.Token() if err != nil { @@ -1068,10 +1088,12 @@ func scanUniqueJSONValue(decoder *json.Decoder, depth int) error { if !ok { return errors.New("JSON object member name is not a string") } - if _, duplicate := seen[key]; duplicate { - return fmt.Errorf("duplicate JSON member %q", key) + for _, existing := range seen { + if strings.EqualFold(existing, key) { + return fmt.Errorf("duplicate case-insensitive JSON member %q", key) + } } - seen[key] = struct{}{} + seen = append(seen, key) if err := scanUniqueJSONValue(decoder, depth+1); err != nil { return err } From f3530571e64bd8089ae9f7634812b4086428d25f Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 15:29:21 -0500 Subject: [PATCH 14/17] test(security): prove denied mutation integrity Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 15 +- test/security/security_regression_test.go | 214 +++++++++++++++++++--- 2 files changed, 198 insertions(+), 31 deletions(-) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 9e72559..c167066 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -34,6 +34,7 @@ type securityFuzzSeed struct { func FuzzReferencePublicBoundary(f *testing.F) { seeds := []securityFuzzSeed{ {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil, 0}, + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", securityPolicyCanary, nil, 0}, {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + url.QueryEscape(invalidBearerCanary), "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, @@ -45,6 +46,7 @@ func FuzzReferencePublicBoundary(f *testing.F) { {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-oversized-0001", fuzzWorkspaceResourceMatch, "req-fuzz-oversized", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1), http.StatusRequestEntityTooLarge}, {http.MethodPut, fuzzWorkspaceTarget, "", "application/json", "fuzz-missing-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-missing-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + invalidBearerCanary, "application/json", "fuzz-invalid-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-invalid-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-stale-0000001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil, 0}, } for _, seed := range seeds { @@ -65,8 +67,9 @@ func FuzzReferencePublicBoundary(f *testing.F) { seeds, method, target, authorization, contentType, idempotencyKey, ifMatch, requestID, body, ) fixture := newSecurityFixture(t) - authorizedFixtureMutation := method == http.MethodPut && target == fuzzWorkspaceTarget && - authorization == "Bearer "+securityBearerCanary && ifMatch == fuzzWorkspaceResourceMatch + authorizedFixtureTarget := method == http.MethodPut && target == fuzzWorkspaceTarget && + authorization == "Bearer "+securityBearerCanary + authorizedFixtureMutation := authorizedFixtureTarget && ifMatch == fuzzWorkspaceResourceMatch if target == fuzzWorkspaceTarget { target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() } @@ -131,11 +134,15 @@ func FuzzReferencePublicBoundary(f *testing.F) { if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { t.Fatalf("success response contract failed: status=%d bytes=%d headers=%#v", response.Code, response.Body.Len(), response.Header()) } - assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) assertNoSuccessfulFixtureSecrets(t, response, fixture) case response.Code >= http.StatusBadRequest && response.Code <= 599: assertSecurityProblemContract(t, response) - assertNoFixtureCanary(t, response, fixture) + if response.Code == http.StatusPreconditionFailed && authorizedFixtureTarget { + assertNoFixtureCanaryWithCurrentETag(t, response, fixture) + } else { + assertNoFixtureCanary(t, response, fixture) + } default: t.Fatalf("unexpected HTTP status %d", response.Code) } diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 7711841..844ef09 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -111,6 +111,13 @@ type securityFixture struct { memberID resource.ID } +type securityRuntimeSnapshot struct { + workspaceList string + workspace string + workspaceETag string + operations string +} + type securityAccess struct { principal identity.Principal } @@ -272,7 +279,9 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { route := route t.Run(route.operationID, func(t *testing.T) { fixture := newSecurityFixture(t) - member := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + member := securityRequestWithStateInvariant(t, fixture, route, route.memberStatus, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + }) assertSecurityResponse(t, member, route.memberStatus, route.memberProblemCode) if route.memberStatus >= http.StatusBadRequest { assertNoFixtureCanary(t, member, fixture) @@ -284,7 +293,9 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { } fixture = newSecurityFixture(t) - outsider := securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) + outsider := securityRequestWithStateInvariant(t, fixture, route, route.outsiderStatus, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) + }) assertSecurityResponse(t, outsider, route.outsiderStatus, route.outsiderProblemCode) if route.outsiderStatus >= http.StatusBadRequest { assertNoFixtureCanary(t, outsider, fixture) @@ -297,29 +308,35 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { if route.staleOutsiderCheck { t.Run("outsider-stale-precondition", func(t *testing.T) { staleFixture := newSecurityFixture(t) - denied := securityRequestWithHeaderOverrides( - t, - staleFixture.outsiderHandler, - route, - staleFixture, - securityBearerCanary, - map[string]string{ - "If-Match": `"` + securityStaleVersionCanary + `"`, - "Veer-Request-Id": securityResourceCanary, - }, - ) + denied := securityRequestWithStateInvariant(t, staleFixture, route, http.StatusForbidden, func() *httptest.ResponseRecorder { + return securityRequestWithHeaderOverrides( + t, + staleFixture.outsiderHandler, + route, + staleFixture, + securityBearerCanary, + map[string]string{ + "If-Match": `"` + securityStaleVersionCanary + `"`, + "Veer-Request-Id": securityResourceCanary, + }, + ) + }) assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") assertNoFixtureCanary(t, denied, staleFixture) }) } fixture = newSecurityFixture(t) - missing := securityRequest(t, fixture.memberHandler, route, fixture, "") + missing := securityRequestWithStateInvariant(t, fixture, route, http.StatusUnauthorized, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.memberHandler, route, fixture, "") + }) assertAuthenticationDenial(t, missing, `Bearer realm="veer"`) assertNoFixtureCanary(t, missing, fixture) fixture = newSecurityFixture(t) - invalid := securityRequest(t, fixture.memberHandler, route, fixture, invalidBearerCanary) + invalid := securityRequestWithStateInvariant(t, fixture, route, http.StatusUnauthorized, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.memberHandler, route, fixture, invalidBearerCanary) + }) assertAuthenticationDenial(t, invalid, `Bearer realm="veer", error="invalid_token"`) assertNoFixtureCanary(t, invalid, fixture) @@ -328,7 +345,9 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { role := role t.Run("reserved-"+role.String(), func(t *testing.T) { roleFixture := newSecurityFixtureForRole(t, role) - denied := securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + denied := securityRequestWithStateInvariant(t, roleFixture, route, http.StatusForbidden, func() *httptest.ResponseRecorder { + return securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + }) assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") assertNoFixtureCanary(t, denied, roleFixture) }) @@ -342,7 +361,9 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { role := role t.Run("administrator-only-"+role.String(), func(t *testing.T) { roleFixture := newSecurityFixtureForRole(t, role) - denied := securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + denied := securityRequestWithStateInvariant(t, roleFixture, route, http.StatusForbidden, func() *httptest.ResponseRecorder { + return securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + }) assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") assertNoFixtureCanary(t, denied, roleFixture) }) @@ -361,6 +382,32 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { writeOrCompareSecurityMatrix(t, results) } +func TestCrossPrincipalMutationReplayDenied(t *testing.T) { + fixture := newSecurityFixture(t) + var route securityRoute + for _, candidate := range publicSecurityRoutes() { + if candidate.operationID == "replaceWorkspace" { + route = candidate + break + } + } + if route.operationID == "" { + t.Fatal("replaceWorkspace security route is missing") + } + + member := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, member, http.StatusAccepted, "") + receipt := assertMutationReceipt(t, member, fixture.workspaceID) + assertNoSuccessfulFixtureSecrets(t, member, fixture) + + before := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + outsider := securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, outsider, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, outsider, fixture) + after := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + assertSecurityRuntimeStateUnchanged(t, before, after) +} + func publicSecurityRoutes() []securityRoute { workspaceTarget := func(fixture securityFixture) string { return "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() @@ -381,7 +428,7 @@ func publicSecurityRoutes() []securityRoute { outsiderStatus: http.StatusOK, outsiderOutcome: "denied-rows-filtered-before-pagination", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspacePage(t, response, fixture.workspaceID, securityOutsiderCanary) - assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, assertOutsiderBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspacePage(t, response, fixture.outsiderWorkspaceID, securityResourceCanary) @@ -410,7 +457,7 @@ func publicSecurityRoutes() []securityRoute { outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertWorkspaceObject(t, response, fixture.workspaceID) - assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, }, { @@ -424,7 +471,7 @@ func publicSecurityRoutes() []securityRoute { staleOutsiderCheck: true, assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertMutationReceipt(t, response, fixture.workspaceID) - assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, }, { @@ -458,7 +505,7 @@ func publicSecurityRoutes() []securityRoute { outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { assertOperationObject(t, response, fixture.operationID, fixture.workspaceID, fixture.workspaceID) - assertNoResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, }, } @@ -635,6 +682,69 @@ func securityRequestWithHeaderOverrides( return response } +func securityRequestWithStateInvariant( + t testing.TB, + fixture securityFixture, + route securityRoute, + expectedStatus int, + request func() *httptest.ResponseRecorder, +) *httptest.ResponseRecorder { + t.Helper() + if route.method == http.MethodGet || expectedStatus < http.StatusBadRequest { + return request() + } + before := observeSecurityRuntimeState(t, fixture, fixture.operationID) + response := request() + after := observeSecurityRuntimeState(t, fixture, fixture.operationID) + assertSecurityRuntimeStateUnchanged(t, before, after) + return response +} + +func observeSecurityRuntimeState( + t testing.TB, + fixture securityFixture, + operationIDs ...resource.ID, +) securityRuntimeSnapshot { + t.Helper() + get := func(target string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodGet, target, nil) + request.Header.Set("Authorization", "Bearer "+securityBearerCanary) + response := httptest.NewRecorder() + fixture.memberHandler.ServeHTTP(response, request) + assertSecurityResponse(t, response, http.StatusOK, "") + return response + } + + list := get("/api/v1alpha1/workspaces") + workspace := get("/api/v1alpha1/workspaces/" + fixture.workspaceID.String()) + var operations strings.Builder + for _, operationID := range operationIDs { + operationResponse := get("/api/v1alpha1/operations/" + operationID.String()) + fmt.Fprintf(&operations, "%s\x00%s\x00%q\n", operationID, operationResponse.Body.String(), operationResponse.Header().Values("ETag")) + } + return securityRuntimeSnapshot{ + workspaceList: list.Body.String(), + workspace: workspace.Body.String(), + workspaceETag: fmt.Sprintf("%q", workspace.Header().Values("ETag")), + operations: operations.String(), + } +} + +func assertSecurityRuntimeStateUnchanged( + t testing.TB, + before, after securityRuntimeSnapshot, +) { + t.Helper() + switch { + case before.workspaceList != after.workspaceList: + t.Fatal("denied mutation changed the authorized workspace list") + case before.workspace != after.workspace || before.workspaceETag != after.workspaceETag: + t.Fatal("denied mutation changed the canonical workspace or resource version") + case before.operations != after.operations: + t.Fatal("denied mutation changed observed operation state") + } +} + func assertSecurityResponse( t testing.TB, response *httptest.ResponseRecorder, @@ -648,8 +758,8 @@ func assertSecurityResponse( assertSingletonHeader(t, response, "Cache-Control", "no-store") assertSingletonHeader(t, response, "X-Content-Type-Options", "nosniff") requestIDs := response.Header().Values("Veer-Request-Id") - if len(requestIDs) != 1 || requestIDs[0] == "" { - t.Fatalf("Veer-Request-Id values = %q, want exactly one non-empty value", requestIDs) + if len(requestIDs) != 1 || !securityRequestIDPattern.MatchString(requestIDs[0]) { + t.Fatalf("Veer-Request-Id values = %q, want exactly one canonical value", requestIDs) } if !json.Valid(response.Body.Bytes()) { t.Fatalf("response is not JSON: %q", response.Body.Bytes()) @@ -672,7 +782,7 @@ func assertNoBearerCanary(t testing.TB, response *httptest.ResponseRecorder) { func assertNoMemberFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { t.Helper() - assertNoResponseCanary( + assertNoSuccessfulResponseCanary( t, response, securityResourceCanary, @@ -690,7 +800,7 @@ func assertNoMemberFixtureCanary(t testing.TB, response *httptest.ResponseRecord func assertNoSuccessfulFixtureSecrets(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { t.Helper() - assertNoResponseCanary( + assertNoSuccessfulResponseCanary( t, response, securityPolicyCanary, @@ -703,6 +813,29 @@ func assertNoSuccessfulFixtureSecrets(t testing.TB, response *httptest.ResponseR } func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + assertNoFixtureCanaryExceptHeaders(t, response, fixture, nil) +} + +func assertNoFixtureCanaryWithCurrentETag( + t testing.TB, + response *httptest.ResponseRecorder, + fixture securityFixture, +) { + t.Helper() + assertNoFixtureCanaryExceptHeaders( + t, + response, + fixture, + map[string]string{http.CanonicalHeaderKey("ETag"): `"` + fixture.resourceVersion + `"`}, + ) +} + +func assertNoFixtureCanaryExceptHeaders( + t testing.TB, + response *httptest.ResponseRecorder, + fixture securityFixture, + allowedHeaders map[string]string, +) { t.Helper() outputs := []string{response.Body.String()} problemResponse := response.Header().Get("Content-Type") == "application/problem+json" @@ -736,6 +869,12 @@ func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fi if problemResponse && strings.EqualFold(name, "Veer-Request-Id") { continue } + if want, allowed := allowedHeaders[http.CanonicalHeaderKey(name)]; allowed { + if len(values) != 1 || values[0] != want { + t.Fatalf("%s values = %q, want exactly one allowed value %q", name, values, want) + } + continue + } outputs = append(outputs, values...) } assertOutputsDoNotContainCanaries( @@ -756,6 +895,26 @@ func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fi ) } +func assertNoSuccessfulResponseCanary( + t testing.TB, + response *httptest.ResponseRecorder, + canaries ...string, +) { + t.Helper() + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 || !securityRequestIDPattern.MatchString(requestIDs[0]) { + t.Fatalf("Veer-Request-Id values = %q, want exactly one canonical value", requestIDs) + } + outputs := []string{response.Body.String()} + for name, values := range response.Header() { + if strings.EqualFold(name, "Veer-Request-Id") { + continue + } + outputs = append(outputs, values...) + } + assertOutputsDoNotContainCanaries(t, outputs, canaries...) +} + func assertNoResponseCanary(t testing.TB, response *httptest.ResponseRecorder, canaries ...string) { t.Helper() outputs := []string{response.Body.String()} @@ -846,7 +1005,7 @@ func assertWorkspacePage( len(page.Items), firstID, page.NextPageToken, wantID, response.Body.String(), ) } - assertNoResponseCanary(t, response, forbiddenCanary) + assertNoSuccessfulResponseCanary(t, response, forbiddenCanary) } func assertWorkspaceObject(t testing.TB, response *httptest.ResponseRecorder, wantID resource.ID) { @@ -892,7 +1051,7 @@ func assertMutationReceipt( t testing.TB, response *httptest.ResponseRecorder, wantResourceID resource.ID, -) { +) reference.MutationReceipt { t.Helper() var receipt reference.MutationReceipt decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) @@ -915,6 +1074,7 @@ func assertMutationReceipt( "Location", "/api/v1alpha1/operations/"+receipt.OperationID.String(), ) + return receipt } func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { From 64f17426faac22e7ef3e9bb053a30317288c4587 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Sat, 12 Sep 2026 16:09:41 -0500 Subject: [PATCH 15/17] test(security): bind state and response contracts Signed-off-by: Gnani Rahul Nutakki --- test/security/security_fuzz_test.go | 40 +++- test/security/security_regression_test.go | 271 +++++++++++++++++++--- 2 files changed, 265 insertions(+), 46 deletions(-) diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index c167066..9d10044 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -11,11 +11,11 @@ import ( "testing" "github.com/ArdurAI/veer/internal/core/domain/resource" - "github.com/ArdurAI/veer/internal/core/service/reference" ) const ( fuzzWorkspaceTarget = "/__security_fixture_workspace__" + fuzzOperationTarget = "/__security_fixture_operation__" fuzzWorkspaceResourceMatch = "__security_fixture_resource_version__" ) @@ -28,13 +28,17 @@ var retainedSecurityAuthenticationChallenges = map[string]string{ type securityFuzzSeed struct { method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string body []byte - rejectionStatus int + expectedStatus int } func FuzzReferencePublicBoundary(f *testing.F) { seeds := []securityFuzzSeed{ {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil, 0}, + {"", "/api/v1alpha1/workspaces", "BeArer " + securityBearerCanary, "", "0", "0", "0", nil, http.StatusOK}, {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", securityPolicyCanary, nil, 0}, + {http.MethodGet, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-workspace", nil, http.StatusOK}, + {http.MethodGet, fuzzOperationTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-operation", nil, http.StatusOK}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-replace-00001", fuzzWorkspaceResourceMatch, "req-fuzz-replace", validWorkspaceBody("replacement"), http.StatusAccepted}, {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + url.QueryEscape(invalidBearerCanary), "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, @@ -52,6 +56,7 @@ func FuzzReferencePublicBoundary(f *testing.F) { for _, seed := range seeds { f.Add(seed.method, seed.target, seed.authorization, seed.contentType, seed.idempotencyKey, seed.ifMatch, seed.requestID, seed.body) } + successResponseLimits := loadSecuritySuccessResponseLimits(f) f.Fuzz(func( t *testing.T, @@ -63,7 +68,7 @@ func FuzzReferencePublicBoundary(f *testing.F) { len(requestID) > 512 || len(body) > 2*resource.MaxCanonicalBytes { t.Skip() } - retainedRejectionStatus := retainedSecurityFuzzRejectionStatus( + retainedExpectedStatus := retainedSecurityFuzzExpectedStatus( seeds, method, target, authorization, contentType, idempotencyKey, ifMatch, requestID, body, ) fixture := newSecurityFixture(t) @@ -73,6 +78,9 @@ func FuzzReferencePublicBoundary(f *testing.F) { if target == fuzzWorkspaceTarget { target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() } + if target == fuzzOperationTarget { + target = "/api/v1alpha1/operations/" + fixture.operationID.String() + } if ifMatch == fuzzWorkspaceResourceMatch { ifMatch = `"` + fixture.resourceVersion + `"` } @@ -108,15 +116,15 @@ func FuzzReferencePublicBoundary(f *testing.F) { if authorizedFixtureMutation && response.Code == http.StatusForbidden { t.Fatalf("authorized fixture mutation was denied before reaching admission: body=%s", response.Body.String()) } - if retainedRejectionStatus != 0 && response.Code != retainedRejectionStatus { + if retainedExpectedStatus != 0 && response.Code != retainedExpectedStatus { t.Fatalf( - "retained negative seed status = %d, want %d; body=%s", - response.Code, retainedRejectionStatus, response.Body.String(), + "retained seed status = %d, want %d; body=%s", + response.Code, retainedExpectedStatus, response.Body.String(), ) } if response.Code == http.StatusUnauthorized { expectedChallenge := "" - if retainedRejectionStatus == http.StatusUnauthorized { + if retainedExpectedStatus == http.StatusUnauthorized { var exists bool expectedChallenge, exists = retainedSecurityAuthenticationChallenges[requestID] if !exists { @@ -131,8 +139,16 @@ func FuzzReferencePublicBoundary(f *testing.F) { switch { case response.Code >= http.StatusOK && response.Code < http.StatusMultipleChoices: assertSingletonHeader(t, response, "Content-Type", "application/json") - if response.Body.Len() > reference.MaxPageBytes || !json.Valid(response.Body.Bytes()) { - t.Fatalf("success response contract failed: status=%d bytes=%d headers=%#v", response.Code, response.Body.Len(), response.Header()) + operationID := securityOperationIDForRequest(request.Method, request.URL.Path) + maximumBytes, exists := successResponseLimits[operationID] + if operationID == "" || !exists { + t.Fatalf("successful response has no matched OpenAPI operation limit: method=%q path=%q", request.Method, request.URL.Path) + } + if response.Body.Len() > maximumBytes || !json.Valid(response.Body.Bytes()) { + t.Fatalf( + "success response contract failed: operation=%s status=%d bytes=%d maximum=%d headers=%#v", + operationID, response.Code, response.Body.Len(), maximumBytes, response.Header(), + ) } assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) assertNoSuccessfulFixtureSecrets(t, response, fixture) @@ -150,17 +166,17 @@ func FuzzReferencePublicBoundary(f *testing.F) { }) } -func retainedSecurityFuzzRejectionStatus( +func retainedSecurityFuzzExpectedStatus( seeds []securityFuzzSeed, method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string, body []byte, ) int { for _, seed := range seeds { - if seed.rejectionStatus != 0 && method == seed.method && target == seed.target && + if seed.expectedStatus != 0 && method == seed.method && target == seed.target && authorization == seed.authorization && contentType == seed.contentType && idempotencyKey == seed.idempotencyKey && ifMatch == seed.ifMatch && requestID == seed.requestID && bytes.Equal(body, seed.body) { - return seed.rejectionStatus + return seed.expectedStatus } } return 0 diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 844ef09..25cfac5 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -101,17 +101,24 @@ type securityRoute struct { } type securityFixture struct { - memberHandler http.Handler - outsiderHandler http.Handler - workspaceID resource.ID - outsiderWorkspaceID resource.ID - resourceVersion string - operationID resource.ID - policyID resource.ID - memberID resource.ID + memberHandler http.Handler + outsiderHandler http.Handler + workspaceID resource.ID + outsiderWorkspaceID resource.ID + resourceVersion string + outsiderResourceVersion string + operationID resource.ID + outsiderOperationID resource.ID + policyID resource.ID + memberID resource.ID } type securityRuntimeSnapshot struct { + member securityPrincipalRuntimeSnapshot + outsider securityPrincipalRuntimeSnapshot +} + +type securityPrincipalRuntimeSnapshot struct { workspaceList string workspace string workspaceETag string @@ -270,6 +277,19 @@ func TestSecurityProblemMemberPresence(t *testing.T) { } } +func TestSecuritySuccessResponseLimitsCoverPublicRoutes(t *testing.T) { + limits := loadSecuritySuccessResponseLimits(t) + routes := publicSecurityRoutes() + if len(limits) != len(routes) { + t.Fatalf("success response limit operations = %d, want %d public routes", len(limits), len(routes)) + } + for _, route := range routes { + if limits[route.operationID] <= 0 { + t.Fatalf("operation %q has no positive success response limit", route.operationID) + } + } +} + func TestPublicRouteSecurityMatrix(t *testing.T) { routes := publicSecurityRoutes() assertOpenAPIRouteCoverage(t, routes) @@ -456,7 +476,9 @@ func publicSecurityRoutes() []securityRoute { memberStatus: http.StatusOK, memberOutcome: "allowed", outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { - assertWorkspaceObject(t, response, fixture.workspaceID) + assertWorkspaceObject( + t, response, fixture.workspaceID, securityResourceCanary, fixture.resourceVersion, + ) assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, }, @@ -470,7 +492,24 @@ func publicSecurityRoutes() []securityRoute { workspaceAdminOnly: true, staleOutsiderCheck: true, assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { - assertMutationReceipt(t, response, fixture.workspaceID) + receipt := assertMutationReceipt(t, response, fixture.workspaceID) + if receipt.ResourceVersion == fixture.resourceVersion { + t.Fatalf("replacement resource version = %q, want advancement from %q", receipt.ResourceVersion, fixture.resourceVersion) + } + memberWorkspace := securityAuthorizedGet( + t, fixture.memberHandler, "/api/v1alpha1/workspaces/"+fixture.workspaceID.String(), + ) + assertWorkspaceObject(t, memberWorkspace, fixture.workspaceID, "replacement", receipt.ResourceVersion) + outsiderWorkspace := securityAuthorizedGet( + t, fixture.outsiderHandler, "/api/v1alpha1/workspaces/"+fixture.outsiderWorkspaceID.String(), + ) + assertWorkspaceObject( + t, + outsiderWorkspace, + fixture.outsiderWorkspaceID, + securityOutsiderCanary, + fixture.outsiderResourceVersion, + ) assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) }, }, @@ -576,7 +615,8 @@ func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) secu return securityFixture{ memberHandler: memberHandler, outsiderHandler: outsiderHandler, workspaceID: workspace.ResourceID, outsiderWorkspaceID: outsiderWorkspace.ResourceID, - resourceVersion: workspace.ResourceVersion, operationID: workspace.OperationID, + resourceVersion: workspace.ResourceVersion, outsiderResourceVersion: outsiderWorkspace.ResourceVersion, + operationID: workspace.OperationID, outsiderOperationID: outsiderWorkspace.OperationID, policyID: memberPolicy.ResourceID, memberID: resource.ID(securityMemberCanary), } } @@ -690,13 +730,17 @@ func securityRequestWithStateInvariant( request func() *httptest.ResponseRecorder, ) *httptest.ResponseRecorder { t.Helper() - if route.method == http.MethodGet || expectedStatus < http.StatusBadRequest { + if route.method == http.MethodGet { return request() } before := observeSecurityRuntimeState(t, fixture, fixture.operationID) response := request() after := observeSecurityRuntimeState(t, fixture, fixture.operationID) - assertSecurityRuntimeStateUnchanged(t, before, after) + if expectedStatus >= http.StatusBadRequest { + assertSecurityRuntimeStateUnchanged(t, before, after) + } else if route.operationID == "replaceWorkspace" { + assertSecurityPrincipalRuntimeStateUnchanged(t, "outsider", before.outsider, after.outsider) + } return response } @@ -706,23 +750,31 @@ func observeSecurityRuntimeState( operationIDs ...resource.ID, ) securityRuntimeSnapshot { t.Helper() - get := func(target string) *httptest.ResponseRecorder { - request := httptest.NewRequest(http.MethodGet, target, nil) - request.Header.Set("Authorization", "Bearer "+securityBearerCanary) - response := httptest.NewRecorder() - fixture.memberHandler.ServeHTTP(response, request) - assertSecurityResponse(t, response, http.StatusOK, "") - return response + return securityRuntimeSnapshot{ + member: observeSecurityPrincipalRuntimeState( + t, fixture.memberHandler, fixture.workspaceID, operationIDs..., + ), + outsider: observeSecurityPrincipalRuntimeState( + t, fixture.outsiderHandler, fixture.outsiderWorkspaceID, fixture.outsiderOperationID, + ), } +} - list := get("/api/v1alpha1/workspaces") - workspace := get("/api/v1alpha1/workspaces/" + fixture.workspaceID.String()) +func observeSecurityPrincipalRuntimeState( + t testing.TB, + handler http.Handler, + workspaceID resource.ID, + operationIDs ...resource.ID, +) securityPrincipalRuntimeSnapshot { + t.Helper() + list := securityAuthorizedGet(t, handler, "/api/v1alpha1/workspaces") + workspace := securityAuthorizedGet(t, handler, "/api/v1alpha1/workspaces/"+workspaceID.String()) var operations strings.Builder for _, operationID := range operationIDs { - operationResponse := get("/api/v1alpha1/operations/" + operationID.String()) + operationResponse := securityAuthorizedGet(t, handler, "/api/v1alpha1/operations/"+operationID.String()) fmt.Fprintf(&operations, "%s\x00%s\x00%q\n", operationID, operationResponse.Body.String(), operationResponse.Header().Values("ETag")) } - return securityRuntimeSnapshot{ + return securityPrincipalRuntimeSnapshot{ workspaceList: list.Body.String(), workspace: workspace.Body.String(), workspaceETag: fmt.Sprintf("%q", workspace.Header().Values("ETag")), @@ -730,18 +782,38 @@ func observeSecurityRuntimeState( } } +func securityAuthorizedGet(t testing.TB, handler http.Handler, target string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodGet, target, nil) + request.Header.Set("Authorization", "Bearer "+securityBearerCanary) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assertSecurityResponse(t, response, http.StatusOK, "") + return response +} + func assertSecurityRuntimeStateUnchanged( t testing.TB, before, after securityRuntimeSnapshot, +) { + t.Helper() + assertSecurityPrincipalRuntimeStateUnchanged(t, "member", before.member, after.member) + assertSecurityPrincipalRuntimeStateUnchanged(t, "outsider", before.outsider, after.outsider) +} + +func assertSecurityPrincipalRuntimeStateUnchanged( + t testing.TB, + principal string, + before, after securityPrincipalRuntimeSnapshot, ) { t.Helper() switch { case before.workspaceList != after.workspaceList: - t.Fatal("denied mutation changed the authorized workspace list") + t.Fatalf("denied mutation changed the %s workspace list", principal) case before.workspace != after.workspace || before.workspaceETag != after.workspaceETag: - t.Fatal("denied mutation changed the canonical workspace or resource version") + t.Fatalf("denied mutation changed the %s workspace or resource version", principal) case before.operations != after.operations: - t.Fatal("denied mutation changed observed operation state") + t.Fatalf("denied mutation changed the %s operation state", principal) } } @@ -1008,19 +1080,32 @@ func assertWorkspacePage( assertNoSuccessfulResponseCanary(t, response, forbiddenCanary) } -func assertWorkspaceObject(t testing.TB, response *httptest.ResponseRecorder, wantID resource.ID) { +func assertWorkspaceObject( + t testing.TB, + response *httptest.ResponseRecorder, + wantID resource.ID, + wantDisplayName, wantResourceVersion string, +) { t.Helper() var workspace struct { Metadata struct { - ID string `json:"id"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + ResourceVersion string `json:"resourceVersion"` } `json:"metadata"` } if err := json.Unmarshal(response.Body.Bytes(), &workspace); err != nil { t.Fatalf("decode workspace: %v; body=%s", err, response.Body.String()) } - if workspace.Metadata.ID != wantID.String() { - t.Fatalf("workspace ID = %q, want %q; body=%s", workspace.Metadata.ID, wantID, response.Body.String()) + if workspace.Metadata.ID != wantID.String() || workspace.Metadata.DisplayName != wantDisplayName || + workspace.Metadata.ResourceVersion != wantResourceVersion { + t.Fatalf( + "workspace identity = id:%q displayName:%q resourceVersion:%q, want %q/%q/%q; body=%s", + workspace.Metadata.ID, workspace.Metadata.DisplayName, workspace.Metadata.ResourceVersion, + wantID, wantDisplayName, wantResourceVersion, response.Body.String(), + ) } + assertSingletonHeader(t, response, "ETag", strconv.Quote(workspace.Metadata.ResourceVersion)) } func assertOperationObject( @@ -1030,9 +1115,10 @@ func assertOperationObject( ) { t.Helper() var operation struct { - ID string `json:"id"` - WorkspaceID string `json:"workspaceId"` - ResourceID string `json:"resourceId"` + ID string `json:"id"` + WorkspaceID string `json:"workspaceId"` + ResourceID string `json:"resourceId"` + ResourceVersion string `json:"resourceVersion"` } if err := json.Unmarshal(response.Body.Bytes(), &operation); err != nil { t.Fatalf("decode operation: %v; body=%s", err, response.Body.String()) @@ -1045,6 +1131,10 @@ func assertOperationObject( wantID, wantWorkspaceID, wantResourceID, response.Body.String(), ) } + if operation.ResourceVersion == "" { + t.Fatalf("operation resource version is empty; body=%s", response.Body.String()) + } + assertSingletonHeader(t, response, "ETag", strconv.Quote(operation.ResourceVersion)) } func assertMutationReceipt( @@ -1292,6 +1382,119 @@ func safeSecurityProblemText(value string, maximum int, required bool) bool { return true } +func loadSecuritySuccessResponseLimits(t testing.TB) map[string]int { + t.Helper() + data, err := openapi.Load(filepath.Join(repositoryRoot(t), "api/openapi/veer-v1alpha1.json")) + if err != nil { + t.Fatal(err) + } + if err := openapi.Validate(data); err != nil { + t.Fatal(err) + } + type referenceObject struct { + Reference string `json:"$ref"` + } + type operationObject struct { + OperationID string `json:"operationId"` + Responses map[string]referenceObject `json:"responses"` + } + var document struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + Components struct { + Responses map[string]struct { + Content map[string]struct { + Schema referenceObject `json:"schema"` + } `json:"content"` + } `json:"responses"` + Schemas map[string]struct { + MaximumJSONBytes int `json:"x-veer-maximum-json-bytes"` + } `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + + limits := make(map[string]int) + for path, item := range document.Paths { + for method, raw := range item { + if !isHTTPMethod(method) { + continue + } + var operation operationObject + if err := json.Unmarshal(raw, &operation); err != nil { + t.Fatalf("decode %s %s operation: %v", strings.ToUpper(method), path, err) + } + var successReference string + for status, response := range operation.Responses { + statusCode, err := strconv.Atoi(status) + if err != nil || statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + continue + } + if successReference != "" { + t.Fatalf("%s %s has multiple successful response contracts", strings.ToUpper(method), path) + } + successReference = response.Reference + } + responseName, ok := strings.CutPrefix(successReference, "#/components/responses/") + if !ok || responseName == "" || strings.Contains(responseName, "/") { + t.Fatalf("%s %s success response reference = %q", strings.ToUpper(method), path, successReference) + } + response, ok := document.Components.Responses[responseName] + if !ok { + t.Fatalf("%s %s success response component %q is missing", strings.ToUpper(method), path, responseName) + } + media, ok := response.Content["application/json"] + if !ok { + t.Fatalf("%s %s success response %q has no application/json schema", strings.ToUpper(method), path, responseName) + } + schemaName, ok := strings.CutPrefix(media.Schema.Reference, "#/components/schemas/") + if !ok || schemaName == "" || strings.Contains(schemaName, "/") { + t.Fatalf("%s %s success schema reference = %q", strings.ToUpper(method), path, media.Schema.Reference) + } + schema, ok := document.Components.Schemas[schemaName] + if !ok || schema.MaximumJSONBytes <= 0 { + t.Fatalf("%s %s success schema %q has no positive JSON byte limit", strings.ToUpper(method), path, schemaName) + } + if operation.OperationID == "" { + t.Fatalf("%s %s has no operationId", strings.ToUpper(method), path) + } + if _, exists := limits[operation.OperationID]; exists { + t.Fatalf("operationId %q is duplicated", operation.OperationID) + } + limits[operation.OperationID] = schema.MaximumJSONBytes + } + } + return limits +} + +func securityOperationIDForRequest(method, path string) string { + for _, route := range publicSecurityRoutes() { + if route.method == method && securityPathMatches(route.pathTemplate, path) { + return route.operationID + } + } + return "" +} + +func securityPathMatches(template, path string) bool { + templateParts := strings.Split(strings.Trim(template, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(templateParts) != len(pathParts) { + return false + } + for index, templatePart := range templateParts { + parameter := strings.HasPrefix(templatePart, "{") && strings.HasSuffix(templatePart, "}") + if parameter && pathParts[index] != "" { + continue + } + if templatePart != pathParts[index] { + return false + } + } + return true +} + func assertOpenAPIRouteCoverage(t *testing.T, routes []securityRoute) { t.Helper() data, err := openapi.Load(filepath.Join(repositoryRoot(t), "api/openapi/veer-v1alpha1.json")) From 2284345ff6c86af4502b8e6c3410917eb418346a Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Tue, 15 Sep 2026 01:36:20 -0500 Subject: [PATCH 16/17] test(security): close mutation and response regression gaps Record committed writes and identifier allocation around rejected mutations, and validate idempotency keys before allocating state. Bind replacement receipts to persisted operations, scan decoded JSON canaries, and enforce route-specific headers and inherited-role reads. Retain malformed request-ID and authorized stale-precondition regressions. Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 18 +- .../core/domain/reconciliation/idempotency.go | 13 +- .../domain/reconciliation/idempotency_test.go | 27 ++ internal/core/service/reference/service.go | 3 +- test/security/security_fuzz_test.go | 43 ++- test/security/security_regression_test.go | 287 +++++++++++++++++- 6 files changed, 371 insertions(+), 20 deletions(-) diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md index 427fa5d..9e59ac4 100644 --- a/docs/security/regression-suite.md +++ b/docs/security/regression-suite.md @@ -30,6 +30,14 @@ positive path proves that a valid credential reaches the authorization layer; their invariant is a tenant `403` for every value in the closed four-role registry. +Every inheriting role must also pass the scoped list, Workspace read, and +Operation read cases. Rejected mutations compare both principals' visible +state and a recorder covering every committed resource/operation write and +issued identifier/version, including writes under previously unseen IDs. +Accepted replacement receipts bind to the persisted Workspace generation and +version, and to the new Operation's identity, generation, canonical bytes, and +own ETag. Operation versions are independent of Workspace versions. + ## Retained untrusted-input and fuzz corpus The following fuzz targets run their seed corpus during ordinary `go test` and @@ -37,7 +45,7 @@ remain available for time-bounded fuzzing: | Boundary | Target | Retained input classes | | --- | --- | --- | -| Public HTTP routes | `FuzzReferencePublicBoundary` | alternate token carrier, encoded path, duplicate JSON member, unknown credential-like field, trailing JSON, invalid UTF-8, depth bomb, webhook/remote-reference shape, oversized body, invalid credential | +| Public HTTP routes | `FuzzReferencePublicBoundary` | alternate token carrier, encoded path, duplicate JSON member, unknown credential-like field, trailing JSON, invalid UTF-8, depth bomb, webhook/remote-reference shape, oversized body, invalid credential, minimized invalid idempotency key, malformed request ID, disallowed methods on every route family | | Bearer extraction | `FuzzExtractBearer`, `FuzzCredentialTrailerScrubbing` | header/query/cookie/trailer ambiguity, divergent query views, body and trailer aliasing, arbitrary casing | | OIDC/JWKS | deterministic negative corpus in `internal/adapters/identity/oidc` | non-canonical JWT segments, duplicate members, forbidden remote key headers, claim failures, redirect/cookie response state, malformed and over-bound keys | | Resource admission | `FuzzAdmitRaw` | every resource kind, duplicate and unknown fields, invalid Unicode, depth/node/byte bounds, trailing values | @@ -51,7 +59,12 @@ problem response must use unique JSON members and a closed code/status/title tuple, bind its request ID and problem URNs, bind required retry body/header metadata, stay below 1,024 bytes, contain at most one field violation, and respect the field-path and text bounds. Valid client request IDs must be echoed -exactly. The OpenAPI validator independently +exactly; malformed supplied IDs must receive `400 validation-failed`. A `405` +requires one exact route-specific `Allow` header, and an authorized stale +precondition requires the current singleton ETag. Successful JSON must contain +valid UTF-8 and unique members; decoded canonical JSON is scanned alongside +wire bytes so escaped canaries cannot bypass the confidentiality checks. +The OpenAPI validator independently rejects remote servers, remote references, and webhook expansion. No provider response parser exists yet; provider-specific malicious-response corpora belong with the adapters introduced by issues #38 and #41. @@ -84,6 +97,7 @@ matrix, fuzz corpus, and redaction table before merge. ```sh go test ./test/security -count=1 +go test ./test/security -run '^Test' -count=100 go test ./internal/transport/http ./internal/adapters/identity/oidc \ ./internal/core/domain/admission ./internal/core/domain/credential \ ./internal/core/service/credentialbroker ./internal/core/domain/reconciliation \ diff --git a/internal/core/domain/reconciliation/idempotency.go b/internal/core/domain/reconciliation/idempotency.go index a37268d..b746a95 100644 --- a/internal/core/domain/reconciliation/idempotency.go +++ b/internal/core/domain/reconciliation/idempotency.go @@ -153,7 +153,7 @@ func (ledger *IdempotencyLedger) Reserve( fingerprint RequestFingerprint, ) (Reservation, IdempotencyDisposition, error) { if ledger == nil || !ledger.initialized || !scope.initialized || !scope.digest.initialized || - !validIdempotencyKey(key) || !fingerprint.initialized { + ValidateIdempotencyKey(key) != nil || !fingerprint.initialized { return Reservation{}, "", ErrInvalidIdempotency } now, err := normalizeTime(databaseTime) @@ -417,9 +417,14 @@ func validateReservation(value Reservation) error { return nil } -func validIdempotencyKey(value string) bool { - return len(value) >= MinIdempotencyKeyBytes && len(value) <= MaxIdempotencyKeyBytes && - !strings.ContainsAny(value, "\r\n") && idempotencyKeyPattern.MatchString(value) +// ValidateIdempotencyKey enforces the public mutation-key grammar without +// reserving ledger state. +func ValidateIdempotencyKey(value string) error { + if len(value) < MinIdempotencyKeyBytes || len(value) > MaxIdempotencyKeyBytes || + strings.ContainsAny(value, "\r\n") || !idempotencyKeyPattern.MatchString(value) { + return ErrInvalidIdempotency + } + return nil } func validMutationMethod(value string) bool { diff --git a/internal/core/domain/reconciliation/idempotency_test.go b/internal/core/domain/reconciliation/idempotency_test.go index 1927e83..7967378 100644 --- a/internal/core/domain/reconciliation/idempotency_test.go +++ b/internal/core/domain/reconciliation/idempotency_test.go @@ -3,6 +3,7 @@ package reconciliation import ( "errors" "fmt" + "strings" "sync" "testing" "time" @@ -10,6 +11,32 @@ import ( const fixtureIdempotencyKey = "idem-0123456789ABC" +func TestValidateIdempotencyKey(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + value string + valid bool + }{ + {name: "minimum", value: "idem-0123456789A", valid: true}, + {name: "maximum", value: strings.Repeat("a", MaxIdempotencyKeyBytes), valid: true}, + {name: "too short", value: "0"}, + {name: "too long", value: strings.Repeat("a", MaxIdempotencyKeyBytes+1)}, + {name: "space", value: "idem-0123456789 A"}, + {name: "newline", value: "idem-0123456789\nA"}, + } { + t.Run(test.name, func(t *testing.T) { + err := ValidateIdempotencyKey(test.value) + if test.valid && err != nil { + t.Fatalf("ValidateIdempotencyKey(%q) error = %v", test.value, err) + } + if !test.valid && !errors.Is(err, ErrInvalidIdempotency) { + t.Fatalf("ValidateIdempotencyKey(%q) error = %v, want ErrInvalidIdempotency", test.value, err) + } + }) + } +} + func TestIdempotencyWindowIsFixedNonSlidingAtExactBoundary(t *testing.T) { t.Parallel() fixture := newPlanFixture(t, 1, 30, false) diff --git a/internal/core/service/reference/service.go b/internal/core/service/reference/service.go index 803bc07..2389395 100644 --- a/internal/core/service/reference/service.go +++ b/internal/core/service/reference/service.go @@ -628,7 +628,8 @@ func (service *Service) validateMutation( target, key string, ) error { if service == nil || ctx == nil || ctx.Err() != nil || identity.ValidatePrincipal(principal) != nil || - len(target) == 0 || len(target) > reconciliation.MaxEvidenceBytes || len(key) == 0 { + len(target) == 0 || len(target) > reconciliation.MaxEvidenceBytes || + reconciliation.ValidateIdempotencyKey(key) != nil { if ctx != nil && ctx.Err() != nil { return ctx.Err() } diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 9d10044..5843389 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -39,6 +39,12 @@ func FuzzReferencePublicBoundary(f *testing.F) { {http.MethodGet, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-workspace", nil, http.StatusOK}, {http.MethodGet, fuzzOperationTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-operation", nil, http.StatusOK}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-replace-00001", fuzzWorkspaceResourceMatch, "req-fuzz-replace", validWorkspaceBody("replacement"), http.StatusAccepted}, + {http.MethodPut, fuzzWorkspaceTarget, "BeArer " + securityBearerCanary, "applicAtion/json", "0", fuzzWorkspaceResourceMatch, "i", validWorkspaceBody("0"), http.StatusBadRequest}, + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", strings.Repeat("a", 65), nil, http.StatusBadRequest}, + {http.MethodPatch, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-list", nil, http.StatusMethodNotAllowed}, + {http.MethodPatch, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-workspace", nil, http.StatusMethodNotAllowed}, + {http.MethodPatch, fuzzWorkspaceTarget + "/status", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-status", nil, http.StatusMethodNotAllowed}, + {http.MethodPatch, fuzzOperationTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-operation", nil, http.StatusMethodNotAllowed}, {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + url.QueryEscape(invalidBearerCanary), "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, http.StatusBadRequest}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, @@ -51,6 +57,8 @@ func FuzzReferencePublicBoundary(f *testing.F) { {http.MethodPut, fuzzWorkspaceTarget, "", "application/json", "fuzz-missing-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-missing-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + invalidBearerCanary, "application/json", "fuzz-invalid-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-invalid-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-stale-0000001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, + {http.MethodPut, fuzzWorkspaceTarget, "BeArer " + securityBearerCanary, "application/json", "fuzz-stale-case-0001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale-case", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-stale-space-001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale-space", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil, 0}, } for _, seed := range seeds { @@ -72,9 +80,9 @@ func FuzzReferencePublicBoundary(f *testing.F) { seeds, method, target, authorization, contentType, idempotencyKey, ifMatch, requestID, body, ) fixture := newSecurityFixture(t) - authorizedFixtureTarget := method == http.MethodPut && target == fuzzWorkspaceTarget && - authorization == "Bearer "+securityBearerCanary - authorizedFixtureMutation := authorizedFixtureTarget && ifMatch == fuzzWorkspaceResourceMatch + if target == fuzzWorkspaceTarget+"/status" { + target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + "/status" + } if target == fuzzWorkspaceTarget { target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() } @@ -88,6 +96,13 @@ func FuzzReferencePublicBoundary(f *testing.F) { if err != nil { return } + scheme, token, hasScheme := strings.Cut(authorization, " ") + fixtureAuthenticated := hasScheme && strings.EqualFold(scheme, "Bearer") && + strings.TrimLeft(token, " ") == securityBearerCanary + authorizedFixtureTarget := fixtureAuthenticated && request.Method == http.MethodPut && + request.URL.Path == "/api/v1alpha1/workspaces/"+fixture.workspaceID.String() && + request.URL.RawQuery == "" && request.URL.EscapedPath() == request.URL.Path + authorizedFixtureMutation := authorizedFixtureTarget && ifMatch == `"`+fixture.resourceVersion+`"` request.RequestURI = target for name, value := range map[string]string{ "Authorization": authorization, @@ -100,8 +115,20 @@ func FuzzReferencePublicBoundary(f *testing.F) { request.Header.Set(name, value) } } + var before securityRuntimeSnapshot + nonReadRequest := request.Method != http.MethodGet + if nonReadRequest { + before = observeSecurityRuntimeState(t, fixture, fixture.operationID) + } response := httptest.NewRecorder() fixture.memberHandler.ServeHTTP(response, request) + if nonReadRequest && response.Code >= http.StatusBadRequest { + after := observeSecurityRuntimeState(t, fixture, fixture.operationID) + if before.mutationState != after.mutationState { + t.Logf("rejected request status=%d body=%s", response.Code, response.Body.String()) + } + assertSecurityRuntimeStateUnchanged(t, before, after) + } responseRequestIDs := response.Header().Values("Veer-Request-Id") if len(responseRequestIDs) != 1 || responseRequestIDs[0] == "" { t.Fatalf("response request ID values = %q, want exactly one non-empty value", responseRequestIDs) @@ -113,6 +140,9 @@ func FuzzReferencePublicBoundary(f *testing.F) { responseRequestIDs[0], requestID, ) } + if requestID != "" && !securityRequestIDPattern.MatchString(requestID) { + assertSecurityResponse(t, response, http.StatusBadRequest, "validation-failed") + } if authorizedFixtureMutation && response.Code == http.StatusForbidden { t.Fatalf("authorized fixture mutation was denied before reaching admission: body=%s", response.Body.String()) } @@ -152,8 +182,15 @@ func FuzzReferencePublicBoundary(f *testing.F) { } assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) assertNoSuccessfulFixtureSecrets(t, response, fixture) + if operationID == "replaceWorkspace" { + receipt := assertMutationReceipt(t, response, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) + } case response.Code >= http.StatusBadRequest && response.Code <= 599: assertSecurityProblemContract(t, response) + if response.Code == http.StatusMethodNotAllowed { + assertSecurityAllowHeader(t, response, request) + } if response.Code == http.StatusPreconditionFailed && authorizedFixtureTarget { assertNoFixtureCanaryWithCurrentETag(t, response, fixture) } else { diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index 25cfac5..df3d559 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -18,14 +18,17 @@ import ( "sort" "strconv" "strings" + "sync" "testing" "time" + "unicode/utf8" "github.com/ArdurAI/veer/api/openapi" "github.com/ArdurAI/veer/internal/adapters/store/memory" "github.com/ArdurAI/veer/internal/core/domain/authorization" "github.com/ArdurAI/veer/internal/core/domain/hierarchy" "github.com/ArdurAI/veer/internal/core/domain/identity" + "github.com/ArdurAI/veer/internal/core/domain/isolation" "github.com/ArdurAI/veer/internal/core/domain/resource" "github.com/ArdurAI/veer/internal/core/ports" "github.com/ArdurAI/veer/internal/core/service/reference" @@ -111,11 +114,14 @@ type securityFixture struct { outsiderOperationID resource.ID policyID resource.ID memberID resource.ID + runtimeState *securityRuntimeStateRecorder + store ports.ReferenceStore } type securityRuntimeSnapshot struct { - member securityPrincipalRuntimeSnapshot - outsider securityPrincipalRuntimeSnapshot + member securityPrincipalRuntimeSnapshot + outsider securityPrincipalRuntimeSnapshot + mutationState string } type securityPrincipalRuntimeSnapshot struct { @@ -125,6 +131,114 @@ type securityPrincipalRuntimeSnapshot struct { operations string } +type securityRuntimeStateRecorder struct { + mu sync.Mutex + events []string +} + +func (recorder *securityRuntimeStateRecorder) record(events ...string) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + recorder.events = append(recorder.events, events...) +} + +func (recorder *securityRuntimeStateRecorder) snapshot() string { + recorder.mu.Lock() + defer recorder.mu.Unlock() + return strings.Join(recorder.events, "\n") +} + +type securityRecordingStore struct { + delegate *memory.Store + state *securityRuntimeStateRecorder +} + +func (store *securityRecordingStore) View( + ctx context.Context, + scopes isolation.WorkspaceScopeSet, + callback func(ports.ReferenceReader) error, +) error { + return store.delegate.View(ctx, scopes, callback) +} + +func (store *securityRecordingStore) Update( + ctx context.Context, + scope isolation.WorkspaceScope, + callback func(ports.ReferenceTransaction) error, +) error { + if callback == nil { + return store.delegate.Update(ctx, scope, nil) + } + var events []string + err := store.delegate.Update(ctx, scope, func(transaction ports.ReferenceTransaction) error { + return callback(&securityRecordingTransaction{ + ReferenceTransaction: transaction, + events: &events, + }) + }) + if err != nil { + return err + } + store.state.record(append( + []string{"store update workspace=" + scope.WorkspaceID().String()}, events..., + )...) + return nil +} + +type securityRecordingTransaction struct { + ports.ReferenceTransaction + events *[]string +} + +func (transaction *securityRecordingTransaction) PutResource(id resource.ID, value []byte) { + transaction.ReferenceTransaction.PutResource(id, value) + *transaction.events = append(*transaction.events, securityMutationEvent("put resource", id, value)) +} + +func (transaction *securityRecordingTransaction) DeleteResource(id resource.ID) { + transaction.ReferenceTransaction.DeleteResource(id) + *transaction.events = append(*transaction.events, "delete resource id="+id.String()) +} + +func (transaction *securityRecordingTransaction) PutOperation(id resource.ID, value []byte) { + transaction.ReferenceTransaction.PutOperation(id, value) + *transaction.events = append(*transaction.events, securityMutationEvent("put operation", id, value)) +} + +func securityMutationEvent(action string, id resource.ID, value []byte) string { + digest := sha256.Sum256(value) + return fmt.Sprintf("%s id=%s bytes=%d sha256=%s", action, id, len(value), hex.EncodeToString(digest[:])) +} + +type securityRecordingIssuer struct { + delegate reference.Issuer + state *securityRuntimeStateRecorder +} + +func (issuer *securityRecordingIssuer) ResourceID(kind hierarchy.Kind) (resource.ID, error) { + id, err := issuer.delegate.ResourceID(kind) + if err == nil { + issuer.state.record("issue resource kind=" + kind.String() + " id=" + id.String()) + } + return id, err +} + +func (issuer *securityRecordingIssuer) OperationID() (resource.ID, error) { + id, err := issuer.delegate.OperationID() + if err == nil { + issuer.state.record("issue operation id=" + id.String()) + } + return id, err +} + +func (issuer *securityRecordingIssuer) ResourceVersion() (string, error) { + version, err := issuer.delegate.ResourceVersion() + if err == nil { + issuer.state.record("issue resource-version=" + version) + } + return version, err +} + type securityAccess struct { principal identity.Principal } @@ -360,6 +474,17 @@ func TestPublicRouteSecurityMatrix(t *testing.T) { assertAuthenticationDenial(t, invalid, `Bearer realm="veer", error="invalid_token"`) assertNoFixtureCanary(t, invalid, fixture) + if route.method == http.MethodGet { + for _, role := range authorization.Roles() { + t.Run("allowed-read-"+role.String(), func(t *testing.T) { + roleFixture := newSecurityFixtureForRole(t, role) + allowed := securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + assertSecurityResponse(t, allowed, http.StatusOK, "") + assertNoSuccessfulFixtureSecrets(t, allowed, roleFixture) + route.assertMemberBody(t, roleFixture, allowed) + }) + } + } if route.allRolesDenied { for _, role := range authorization.Roles() { role := role @@ -418,6 +543,7 @@ func TestCrossPrincipalMutationReplayDenied(t *testing.T) { member := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) assertSecurityResponse(t, member, http.StatusAccepted, "") receipt := assertMutationReceipt(t, member, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) assertNoSuccessfulFixtureSecrets(t, member, fixture) before := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) @@ -493,6 +619,7 @@ func publicSecurityRoutes() []securityRoute { staleOutsiderCheck: true, assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { receipt := assertMutationReceipt(t, response, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) if receipt.ResourceVersion == fixture.resourceVersion { t.Fatalf("replacement resource version = %q, want advancement from %q", receipt.ResourceVersion, fixture.resourceVersion) } @@ -561,11 +688,14 @@ func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) secu } member := securityPrincipal(t, securityIdentityCanary) outsider := securityPrincipal(t, "security-outsider") + runtimeState := &securityRuntimeStateRecorder{} + store := &securityRecordingStore{delegate: memory.NewStore(), state: runtimeState} + issuer := &securityRecordingIssuer{delegate: &reference.SequentialIssuer{}, state: runtimeState} service, err := reference.New(reference.Config{ - Store: memory.NewStore(), Clock: reference.ClockFunc(func() time.Time { + Store: store, Clock: reference.ClockFunc(func() time.Time { return time.Date(2026, time.September, 12, 12, 0, 0, 0, time.UTC) }), - Issuer: &reference.SequentialIssuer{}, PageTokenKey: bytes.Repeat([]byte{0x28}, 32), + Issuer: issuer, PageTokenKey: bytes.Repeat([]byte{0x28}, 32), MaximumPageTokens: 16, MaximumReplays: 32, }) if err != nil { @@ -618,6 +748,7 @@ func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) secu resourceVersion: workspace.ResourceVersion, outsiderResourceVersion: outsiderWorkspace.ResourceVersion, operationID: workspace.OperationID, outsiderOperationID: outsiderWorkspace.OperationID, policyID: memberPolicy.ResourceID, memberID: resource.ID(securityMemberCanary), + runtimeState: runtimeState, store: store, } } @@ -757,6 +888,7 @@ func observeSecurityRuntimeState( outsider: observeSecurityPrincipalRuntimeState( t, fixture.outsiderHandler, fixture.outsiderWorkspaceID, fixture.outsiderOperationID, ), + mutationState: fixture.runtimeState.snapshot(), } } @@ -797,6 +929,13 @@ func assertSecurityRuntimeStateUnchanged( before, after securityRuntimeSnapshot, ) { t.Helper() + if before.mutationState != after.mutationState { + delta := after.mutationState + if strings.HasPrefix(after.mutationState, before.mutationState) { + delta = strings.TrimPrefix(after.mutationState, before.mutationState) + } + t.Fatalf("denied mutation changed the complete store or issuer mutation state: delta=%q", delta) + } assertSecurityPrincipalRuntimeStateUnchanged(t, "member", before.member, after.member) assertSecurityPrincipalRuntimeStateUnchanged(t, "outsider", before.outsider, after.outsider) } @@ -937,14 +1076,14 @@ func assertNoFixtureCanaryExceptHeaders( } outputs[0] = string(encoded) } + for name, want := range allowedHeaders { + assertSingletonHeader(t, response, name, want) + } for name, values := range response.Header() { if problemResponse && strings.EqualFold(name, "Veer-Request-Id") { continue } - if want, allowed := allowedHeaders[http.CanonicalHeaderKey(name)]; allowed { - if len(values) != 1 || values[0] != want { - t.Fatalf("%s values = %q, want exactly one allowed value %q", name, values, want) - } + if _, allowed := allowedHeaders[http.CanonicalHeaderKey(name)]; allowed { continue } outputs = append(outputs, values...) @@ -961,7 +1100,9 @@ func assertNoFixtureCanaryExceptHeaders( fixture.workspaceID.String(), fixture.outsiderWorkspaceID.String(), fixture.resourceVersion, + fixture.outsiderResourceVersion, fixture.operationID.String(), + fixture.outsiderOperationID.String(), fixture.policyID.String(), fixture.memberID.String(), ) @@ -977,7 +1118,11 @@ func assertNoSuccessfulResponseCanary( if len(requestIDs) != 1 || !securityRequestIDPattern.MatchString(requestIDs[0]) { t.Fatalf("Veer-Request-Id values = %q, want exactly one canonical value", requestIDs) } - outputs := []string{response.Body.String()} + canonical, err := canonicalSecurityJSON(response.Body.Bytes()) + if err != nil { + t.Fatalf("decode successful JSON for confidentiality scan: %v", err) + } + outputs := []string{response.Body.String(), canonical} for name, values := range response.Header() { if strings.EqualFold(name, "Veer-Request-Id") { continue @@ -989,13 +1134,57 @@ func assertNoSuccessfulResponseCanary( func assertNoResponseCanary(t testing.TB, response *httptest.ResponseRecorder, canaries ...string) { t.Helper() - outputs := []string{response.Body.String()} + canonical, err := canonicalSecurityJSON(response.Body.Bytes()) + if err != nil { + t.Fatalf("decode JSON for confidentiality scan: %v", err) + } + outputs := []string{response.Body.String(), canonical} for _, values := range response.Header() { outputs = append(outputs, values...) } assertOutputsDoNotContainCanaries(t, outputs, canaries...) } +func canonicalSecurityJSON(body []byte) (string, error) { + if !utf8.Valid(body) { + return "", errors.New("JSON contains invalid UTF-8") + } + if err := validateUniqueJSONMembers(body); err != nil { + return "", err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return "", err + } + canonical, err := json.Marshal(value) + return string(canonical), err +} + +func TestSecurityJSONCanonicalization(t *testing.T) { + t.Parallel() + for _, canary := range []string{securityPolicyCanary, securityIdentityCanary, securityBearerCanary} { + t.Run(canary, func(t *testing.T) { + escaped := strings.ReplaceAll(canary, "-", `\u002d`) + body := []byte(`{"nested":[{"` + escaped + `":"` + escaped + `"}]}`) + canonical, err := canonicalSecurityJSON(body) + if err != nil || strings.Count(canonical, canary) != 2 { + t.Fatalf("decoded canary is not visible in both member name and value: %q, %v", canonical, err) + } + }) + } + for _, body := range [][]byte{ + {'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, + []byte(`{"x":"secret","\u0078":"safe"}`), + []byte(`{"x":"secret"} {"x":"safe"}`), + } { + if _, err := canonicalSecurityJSON(body); err == nil { + t.Fatalf("accepted malformed or ambiguous JSON: %q", body) + } + } +} + func assertOutputsDoNotContainCanaries(t testing.TB, outputs []string, canaries ...string) { t.Helper() for _, output := range outputs { @@ -1167,6 +1356,84 @@ func assertMutationReceipt( return receipt } +func assertReplacementOperation(t testing.TB, fixture securityFixture, receipt reference.MutationReceipt) { + t.Helper() + if receipt.OperationID == fixture.operationID { + t.Fatal("replacement receipt reused the bootstrap operation") + } + scopes, err := isolation.NewWorkspaceScopeSet(fixture.workspaceID) + if err != nil { + t.Fatal(err) + } + var operationBytes, workspaceBytes []byte + if err := fixture.store.View(context.Background(), scopes, func(reader ports.ReferenceReader) error { + var exists bool + operationBytes, exists = reader.GetOperation(receipt.OperationID) + if !exists { + return errors.New("replacement operation was not persisted") + } + workspaceBytes, exists = reader.GetResource(fixture.workspaceID) + if !exists { + return errors.New("replacement workspace was not persisted") + } + return nil + }); err != nil { + t.Fatal(err) + } + operationResponse := securityAuthorizedGet( + t, fixture.memberHandler, "/api/v1alpha1/operations/"+receipt.OperationID.String(), + ) + assertOperationObject(t, operationResponse, receipt.OperationID, fixture.workspaceID, receipt.ResourceID) + assertNoSuccessfulFixtureSecrets(t, operationResponse, fixture) + if !bytes.Equal(operationResponse.Body.Bytes(), operationBytes) { + t.Fatal("replacement operation response differs from its persisted canonical bytes") + } + var persistedOperation struct { + Generation int64 `json:"generation"` + } + var persistedWorkspace struct { + Metadata struct { + Generation int64 `json:"generation"` + ResourceVersion string `json:"resourceVersion"` + } `json:"metadata"` + } + if err := json.Unmarshal(operationBytes, &persistedOperation); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(workspaceBytes, &persistedWorkspace); err != nil { + t.Fatal(err) + } + if receipt.Generation < 1 || persistedOperation.Generation != receipt.Generation || + persistedWorkspace.Metadata.Generation != receipt.Generation || + persistedWorkspace.Metadata.ResourceVersion != receipt.ResourceVersion { + t.Fatalf("replacement receipt is not bound to persisted workspace and operation: receipt=%+v operation=%+v workspace=%+v", + receipt, persistedOperation, persistedWorkspace) + } +} + +func assertSecurityAllowHeader(t testing.TB, response *httptest.ResponseRecorder, request *http.Request) { + t.Helper() + path := request.URL.Path + // The operation endpoint rejects methods before parsing its identifier. + if strings.HasPrefix(path, "/api/v1alpha1/operations/") { + path = "/api/v1alpha1/operations/{operationId}" + } + var methods []string + for _, route := range publicSecurityRoutes() { + if securityPathMatches(route.pathTemplate, path) { + if route.method == request.Method { + t.Fatalf("405 rejected an allowed method %q on %q", request.Method, request.URL.Path) + } + methods = append(methods, route.method) + } + } + if len(methods) == 0 { + t.Fatalf("405 has no matched public route: %q", request.URL.Path) + } + sort.Strings(methods) + assertSingletonHeader(t, response, "Allow", strings.Join(methods, ", ")) +} + func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { t.Helper() assertSingletonHeader(t, response, "Content-Type", "application/problem+json") From 2cb58268f0e89d1ae3b4d4a2c512b55d83be3db3 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Tue, 15 Sep 2026 02:05:19 -0500 Subject: [PATCH 17/17] test(security): verify rejected mutations preserve reusable keys Retry rejected authorized replacements with the same idempotency key and verify that accepted retries replay without extra writes or allocations. Cover stale same-intent and changed-intent requests, malformed JSON, and malformed request IDs in the dedicated suite and public-boundary fuzzer. Signed-off-by: Gnani Rahul Nutakki --- docs/security/regression-suite.md | 7 +++ test/security/security_fuzz_test.go | 5 ++ test/security/security_regression_test.go | 75 +++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md index 9e59ac4..bdd1ba6 100644 --- a/docs/security/regression-suite.md +++ b/docs/security/regression-suite.md @@ -34,6 +34,13 @@ Every inheriting role must also pass the scoped list, Workspace read, and Operation read cases. Rejected mutations compare both principals' visible state and a recorder covering every committed resource/operation write and issued identifier/version, including writes under previously unseen IDs. +Rejected authorized replacements with a valid key also make a corrected retry +on the same fixture with that exact key, then replay the accepted request. +The retry must create a persisted operation; the replay must return the same +receipt without any new store writes or identifier allocation. This observes +idempotency-ledger and replay-map effects that resource snapshots cannot see. +A dedicated regression covers stale preconditions with unchanged and changed +intent, malformed JSON, and malformed request IDs. Accepted replacement receipts bind to the persisted Workspace generation and version, and to the new Operation's identity, generation, canonical bytes, and own ETag. Operation versions are independent of Workspace versions. diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go index 5843389..421f696 100644 --- a/test/security/security_fuzz_test.go +++ b/test/security/security_fuzz_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/ArdurAI/veer/internal/core/domain/reconciliation" "github.com/ArdurAI/veer/internal/core/domain/resource" ) @@ -200,6 +201,10 @@ func FuzzReferencePublicBoundary(f *testing.F) { t.Fatalf("unexpected HTTP status %d", response.Code) } assertNoBearerCanary(t, response) + if authorizedFixtureTarget && response.Code >= http.StatusBadRequest && + reconciliation.ValidateIdempotencyKey(idempotencyKey) == nil { + assertSecurityIdempotencyKeyReusable(t, fixture, idempotencyKey) + } }) } diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go index df3d559..0b0188f 100644 --- a/test/security/security_regression_test.go +++ b/test/security/security_regression_test.go @@ -554,6 +554,81 @@ func TestCrossPrincipalMutationReplayDenied(t *testing.T) { assertSecurityRuntimeStateUnchanged(t, before, after) } +func TestRejectedReplacementPreservesIdempotencyKey(t *testing.T) { + const key = "security-rejected-key-0001" + cases := []struct { + name, requestID, problemCode string + body []byte + stale bool + status int + }{ + {"stale-same-intent", "req-key-stale-same", "precondition-failed", validWorkspaceBody("replacement"), true, http.StatusPreconditionFailed}, + {"stale-changed-intent", "req-key-stale-changed", "precondition-failed", validWorkspaceBody("rejected-intent"), true, http.StatusPreconditionFailed}, + {"malformed-json", "req-key-invalid-json", "validation-failed", []byte(`{"metadata":`), false, http.StatusBadRequest}, + {"malformed-request-id", strings.Repeat("a", 65), "validation-failed", validWorkspaceBody("replacement"), false, http.StatusBadRequest}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + fixture := newSecurityFixture(t) + route := securityReplacementRetryRoute(key) + route.body = func(securityFixture) []byte { return test.body } + headers := map[string]string{"Veer-Request-Id": test.requestID} + if test.stale { + headers["If-Match"] = strconv.Quote(securityStaleVersionCanary) + } + response := securityRequestWithStateInvariant(t, fixture, route, test.status, func() *httptest.ResponseRecorder { + return securityRequestWithHeaderOverrides(t, fixture.memberHandler, route, fixture, securityBearerCanary, headers) + }) + assertSecurityResponse(t, response, test.status, test.problemCode) + if test.stale { + assertNoFixtureCanaryWithCurrentETag(t, response, fixture) + } else { + assertNoFixtureCanary(t, response, fixture) + } + assertSecurityIdempotencyKeyReusable(t, fixture, key) + }) + } +} + +func securityReplacementRetryRoute(key string) securityRoute { + return securityRoute{ + operationID: "replaceWorkspace", + method: http.MethodPut, + target: func(fixture securityFixture) string { + return "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + }, + body: func(securityFixture) []byte { return validWorkspaceBody("replacement") }, + headers: func(fixture securityFixture) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Idempotency-Key": key, + "If-Match": strconv.Quote(fixture.resourceVersion), + } + }, + } +} + +func assertSecurityIdempotencyKeyReusable(t testing.TB, fixture securityFixture, key string) { + t.Helper() + route := securityReplacementRetryRoute(key) + retry := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, retry, http.StatusAccepted, "") + receipt := assertMutationReceipt(t, retry, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) + assertNoSuccessfulFixtureSecrets(t, retry, fixture) + + before := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + replay := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, replay, http.StatusAccepted, "") + assertMutationReceipt(t, replay, fixture.workspaceID) + assertNoSuccessfulFixtureSecrets(t, replay, fixture) + if !bytes.Equal(replay.Body.Bytes(), retry.Body.Bytes()) { + t.Fatalf("corrected retry replay changed its receipt: first=%s replay=%s", retry.Body.String(), replay.Body.String()) + } + after := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + assertSecurityRuntimeStateUnchanged(t, before, after) +} + func publicSecurityRoutes() []securityRoute { workspaceTarget := func(fixture securityFixture) string { return "/api/v1alpha1/workspaces/" + fixture.workspaceID.String()