# Security Tests > **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14), revised in cycle-update mode (drift findings Phase 2) for the ECDSA+JWKS JWT model. > **Naming**: post-rename target. Security tests focus on the JWT bearer + Authz boundary defined in AC-5 and AC-9. > **Auth model**: ECDSA-SHA256 with JWKS retrieved from `admin` (mocked via `jwks-mock` in tests), iss + aud + alg-pin validated, 30s clock skew. The CMMC L2 row 3 (`iss`/`aud`) finding is now structurally fixed in code; the corresponding NFT-SEC scenarios now assert REJECTION, not acceptance. --- ### NFT-SEC-01: Missing Authorization header → 401 **Summary**: Verifies AC-5.4 — every protected endpoint rejects requests without an `Authorization` header. **Traces to**: AC-5.4 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | `GET /vehicles` with no `Authorization` header | `401` | | 2 | `GET /missions` with no `Authorization` header | `401` | | 3 | `GET /missions/{any}/waypoints` with no `Authorization` header | `401` | | 4 | `POST /vehicles` with no `Authorization` header + valid body | `401` (no row written — verify via side-channel `count` unchanged) | **Pass criteria**: every protected endpoint returns 401; no DB side-effect. --- ### NFT-SEC-02: Invalid signature → 401 **Summary**: Verifies AC-5.5 — token whose ECDSA signature doesn't verify against any cached JWKS public key is rejected. **Traces to**: AC-5.5 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Acquire valid signed token `T_good` from `jwks-mock POST /sign`, then flip a single byte in the token's signature segment to produce `T_bad` | — | | 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` | | 3 | Acquire token from a SEPARATE ECDSA keypair not present in the mock's published JWKS (via an out-of-band test helper) and call `GET /vehicles` | `401` | **Pass criteria**: both bad-signature cases return `401`. --- ### NFT-SEC-03: Expired token outside skew → 401; inside skew → 200 **Summary**: Verifies AC-5.6 + AC-5.2 (**30s** clock skew, tighter than .NET's 5-min default and the legacy 1-min setting). **Traces to**: AC-5.2, AC-5.6 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Request token via `POST /sign { "exp_offset_seconds": -60 }` (exp = now - 60s; outside the 30s skew) | mock returns signed JWT | | 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` | | 3 | Request token via `POST /sign { "exp_offset_seconds": -15 }` (exp = now - 15s; inside the 30s skew) | mock returns signed JWT | | 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` | **Pass criteria**: `T_exp` rejected; `T_skew` accepted. --- ### NFT-SEC-04: Token with `iss` ≠ `JWT_ISSUER` → 401 **Summary**: Verifies AC-5.11 — `ValidateIssuer = true` with `ValidIssuer = `. This is the structural fix for CMMC L2 row 3 row (issuer validation half). **Traces to**: AC-5.3, AC-5.11 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Request token via `POST /sign { "iss": "https://attacker.example.com" }` (every other claim valid) | mock returns signed JWT — the signature is correct, only `iss` is wrong | | 2 | `GET /vehicles` with that token | `401` | | 3 | Request token via `POST /sign { }` (iss defaults to the mock's `JWT_ISSUER` env, which matches `missions`'s configured `ValidIssuer`) | mock returns signed JWT | | 4 | `GET /vehicles` with that token | `200` | **Pass criteria**: wrong-`iss` rejected with 401; matching-`iss` accepted. --- ### NFT-SEC-04b: Token with `aud` ≠ `JWT_AUDIENCE` → 401 **Summary**: Verifies AC-5.12 — `ValidateAudience = true` with `ValidAudience = `. This is the structural fix for CMMC L2 row 3 (audience validation half). **Traces to**: AC-5.3, AC-5.12 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Request token via `POST /sign { "aud": "wrong-audience" }` (every other claim valid) | mock returns signed JWT | | 2 | `GET /vehicles` with that token | `401` | **Pass criteria**: wrong-`aud` rejected with 401. --- ### NFT-SEC-05: Missing `permissions` claim → 403 **Summary**: Verifies AC-5.8 — valid signature + lifetime is not enough; the `permissions=FL` claim is required. **Traces to**: AC-5.8, AC-9.1 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Mint token with no `permissions` claim, valid otherwise | | | 2 | `GET /vehicles` | `403` | **Pass criteria**: `403`. --- ### NFT-SEC-06: Wrong `permissions` claim value → 403; multi-permission token accepted **Summary**: Verifies AC-9.1 + AC-9.2 — the policy is `RequireClaim("permissions", "FL")` (contains-match, not exact body-match). Wrong values → 403; a multi-permission token where one value equals `"FL"` → 200. **Traces to**: AC-9.1, AC-9.2 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Acquire token with `permissions="ADMIN"`, valid otherwise | mock returns signed JWT | | 2 | `GET /vehicles` | `403` | | 3 | Acquire token with `permissions="fl"` (lowercase) | mock returns signed JWT | | 4 | `GET /vehicles` | `403` | | 5 | Acquire token with `permissions="FLight"` | mock returns signed JWT | | 6 | `GET /vehicles` | `403` | | 7 | Acquire token with `permissions: ["FL", "ADMIN"]` (multi-permission array) | mock returns signed JWT | | 8 | `GET /vehicles` | `200` (contains-match accepts `"FL"` among the values) | **Pass criteria**: rows 2, 4, 6 → 403; row 8 → 200. --- ### NFT-SEC-07: Health endpoint exempt from auth **Summary**: Verifies AC-7.1, AC-9.4 (contrast) — `/health` is anonymous. **Traces to**: AC-7.1, AC-9.4 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | `GET /health` with no `Authorization` | `200` | | 2 | `GET /health` with `Authorization: Bearer ` | `200` (auth not evaluated) | **Pass criteria**: `200` in both cases. --- ### NFT-SEC-08: Stack trace not leaked in 500 body **Summary**: Verifies AC-8.6 + AC-10.3 — internal exception details stay in the log, not the HTTP body. **Traces to**: AC-8.6, AC-10.3 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Force a `500` (drop `vehicles` table mid-test, then `GET /vehicles/{any}`) | | | 2 | Inspect response body | `body == { "statusCode":500, "message":"Internal server error" }` exactly; NO key matching `stack`, `stackTrace`, `exception`, `inner`, `trace`; NO file path; NO type name in the body | | 3 | `docker logs missions \| grep "Unhandled exception"` | At least one matching line; line contains the file path of the throw site OR the exception type name (the log-side info is private to operators) | **Pass criteria**: response body contains only `statusCode`, `message`; log contains stack info. --- ### NFT-SEC-09: SQL injection guard via parameterised queries **Summary**: Defensive — verifies linq2db's parameterised query path is in effect for filter strings. **Traces to**: AC-1.6 (filter), AC-2.3 (filter), defensive **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | `GET /vehicles?name='%20OR%20'1'%3D'1` (URL-encoded `' OR '1'='1`) | `200`; `body.length == 0` (no row matches the literal `' OR '1'='1` string against `BR-01` etc.) | | 2 | `GET /missions?name=%3B%20DROP%20TABLE%20vehicles%3B%20--` (URL-encoded `; DROP TABLE vehicles; --`) | `200`; `body.TotalCount == 0`; side-channel verifies `vehicles` table still exists | **Pass criteria**: filter inputs are treated as literal strings; no SQL execution; no DDL side-effect. --- ### NFT-SEC-10: Algorithm-pin defends against HS256-confusion → 401 **Summary**: Verifies AC-5.1 + AC-5.10 — `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` defends against the HS256-confusion attack where an attacker who learns a JWKS public key (which is, by definition, public) attempts to forge a token signed with that public key as the HMAC secret under `alg: HS256`. **Traces to**: AC-5.1, AC-5.10 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | Acquire the published JWKS public key bytes from `GET https://jwks-mock:8443/.well-known/jwks.json` (or use the mock's helper that returns those bytes for the test) | — | | 2 | Acquire token via `POST /sign { "alg_override": "HS256" }` — the mock signs the JWT body with the public-key bytes as the HMAC secret (mimicking an attack) | mock returns HS256-signed JWT | | 3 | `GET /vehicles` with `Authorization: Bearer T_hs256` | `401` | | 4 | Acquire token via `POST /sign { "alg_override": "none" }` (mock emits unsigned JWT) | mock returns unsigned JWT | | 5 | `GET /vehicles` with that token | `401` | **Pass criteria**: both HS256-confusion attack and unsigned token are rejected with `401`. --- ### NFT-SEC-11: Unknown `kid` (rotation lag) → 401 until JWKS refresh **Summary**: Verifies AC-5.7 — a token signed with a key whose `kid` is not in the cached JWKS is rejected; once the JWKS refreshes and includes the new `kid`, the same `kid` becomes accepted. **Traces to**: AC-5.7 **Preconditions**: - `missions` has a warm JWKS cache (any previous protected request succeeded). - `jwks-mock` `OldKeyGraceSeconds = 5`. **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | `POST https://jwks-mock:8443/rotate-key {}` | mock returns `{ "newKid": "new-kid" }` | | 2 | Immediately request token via `POST /sign {}` (signs with NEW kid, before `missions` JWKS cache refreshes) | mock returns signed JWT with header `kid: new-kid` | | 3 | `GET /vehicles` with that token | `401` (cached JWKS doesn't yet contain `new-kid`) | | 4 | Wait for `missions`'s JWKS cache to refresh (≤ 90s; the mock sets `Cache-Control: max-age=60` so the refresh tick is at most ~60s) | — | | 5 | `GET /vehicles` with the same token (still valid lifetime) | `200` | | 6 | Request token signed with the PREVIOUS `kid` within the `OldKeyGraceSeconds=5` window | mock signs with the old key | | 7 | `GET /vehicles` with that token | `200` (both keys are in the JWKS during grace) | | 8 | Wait > 5s, then request token signed with the OLD `kid` — mock should refuse (key already evicted from its sign-pool) | mock returns 400/410 | **Pass criteria**: rotation completes transparently within the cache-refresh window; tokens minted with the new `kid` are rejected during the lag, accepted after. **Max execution time**: 120s. --- ### NFT-SEC-12: Missing `JWT_JWKS_URL`/`JWT_ISSUER`/`JWT_AUDIENCE` → startup throws **Summary**: Verifies AC-6.1 / AC-6.2 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` fail-fast for each of the four required env vars eliminates the legacy "silent dev fallback" failure mode. **Traces to**: AC-6.1, AC-6.2, E1, E3 **Steps** (run as four separate `docker run` invocations): | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | `docker run` `missions` with `DATABASE_URL` unset and the three JWT vars set | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `DATABASE_URL` (or `Database:Url`) | | 2 | `docker run` with `JWT_ISSUER` unset | container exits non-zero; logs mention `JWT_ISSUER` (or `Jwt:Issuer`) | | 3 | `docker run` with `JWT_AUDIENCE` unset | container exits non-zero; logs mention `JWT_AUDIENCE` (or `Jwt:Audience`) | | 4 | `docker run` with `JWT_JWKS_URL` unset | container exits non-zero; logs mention `JWT_JWKS_URL` (or `Jwt:JwksUrl`) | | 5 | `docker run` with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS) and the other three set | container STARTS (config resolution passes); first protected request returns 500 with a log line mentioning HTTPS / `RequireHttps` | **Pass criteria**: rows 1–4 → process exits before HTTP server binds; row 5 → process starts but the first protected request fails at JWKS fetch time. **Max execution time**: 60s (4 docker-run cycles). --- ### NFT-SEC-13: CORS Production-gate fail-fast (E9 lock test) **Summary**: Verifies AC-6.11 — in `ASPNETCORE_ENVIRONMENT=Production` with empty `CorsConfig:AllowedOrigins` and `CorsConfig:AllowAnyOrigin != true`, `CorsConfigurationValidator.EnsureSafeForEnvironment` throws and the process exits non-zero. **Traces to**: AC-6.11, E9 **Steps**: | Step | Consumer Action | Expected Response | |------|----------------|------------------| | 1 | `docker run` `missions` with `ASPNETCORE_ENVIRONMENT=Production` and **no** `CorsConfig` env vars | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `CorsConfig` / `AllowedOrigins` / Production | | 2 | Same as 1 but with `CorsConfig__AllowAnyOrigin=true` set | container starts; logs contain a warning that CORS is permissive in Production (recommend listing explicit origins) but NO throw | | 3 | Same as 1 but with `CorsConfig__AllowedOrigins__0=https://operator.example.com` set | container starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo | | 4 | Same as 3, preflight from `https://attacker.example.com` | preflight responds without the allow-origin echo (the policy refuses the disallowed origin) | | 5 | `docker run` with `ASPNETCORE_ENVIRONMENT=Development` (or unset → defaults to `Production`-no, actually unset defaults to `Production` per ASP.NET Core; use `Test` here) and no `CorsConfig` | container starts; logs contain `PermissiveDefaultWarning` | **Pass criteria**: row 1 → fail-fast; rows 2–4 → start with the expected CORS posture; row 5 → start with the documented permissive fallback + warning. **Max execution time**: 90s. --- ## Notes - Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests. - NFT-SEC-04 / NFT-SEC-04b / NFT-SEC-10 / NFT-SEC-11 / NFT-SEC-12 / NFT-SEC-13 are NEW scenarios added in the 2026-05-14 drift re-verification cycle. They replace / extend the old "permissive iss/aud" + "shared-secret rotation" + "dev-fallback footgun" assumptions of the pre-revision spec. - No fuzz testing today (recommended follow-up under a separate refactor cycle).