Files
Oleksandr Bezdieniezhnykh 78dea8ebab
ci/woodpecker/push/build-arm Pipeline was successful
chore: update configuration and Docker setup for JWT and test results
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.
2026-05-15 03:23:23 +03:00

14 KiB
Raw Permalink Blame History

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 issJWT_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 audJWT_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 14 → 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 24 → 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).