mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 15:31:07 +00:00
78dea8ebab
ci/woodpecker/push/build-arm Pipeline was successful
Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
274 lines
14 KiB
Markdown
274 lines
14 KiB
Markdown
# 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 = <resolved JWT_ISSUER>`. 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 = <resolved JWT_AUDIENCE>`. 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 <expired token>` | `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).
|