docs+src: complete Steps 1-3 outcomes + auth re-sync baseline

This commit captures everything produced during autodev existing-code
Steps 1 (Document), 2 (Architecture Baseline Scan), and 3 (Test Spec),
together with the targeted auth + CORS re-sync triggered on 2026-05-14
when codebase drift was detected at Step 4 entry. None of this work was
previously committed.

Step 1 (Document) — 50+ _docs/02_document/ files: problem, solution,
architecture, system flows, glossary, module-layout, per-component
specs (01..06), modules, deployment, diagrams, data model, FINAL
report, verification log, discovery.

Step 2 (Architecture Baseline) — architecture_compliance_baseline.md.
Verdict PASS_WITH_WARNINGS (0 Critical, 0 High, 1 Medium, 2 Low). No
High/Critical findings; auto-chained to Step 3 per existing-code flow.

Step 3 (Test Spec) — _docs/02_document/tests/* (67 scenarios across
blackbox, security, resilience, resource-limit, performance), plus
e2e/docker-compose.test.yml, e2e/seed/run.sh, scripts/run-tests.sh,
scripts/run-performance-tests.sh. Coverage 88% over the active scope
(40 of 45 items covered, 6 RB-deferred, 5 documented-as-uncovered).

Targeted auth + CORS re-sync — replaces the deleted in-house token
issuer with a JWKS-verifier model. AuthController and TokenService
removed; JwtExtensions switched from HS256 symmetric to ES256 over
admin's JWKS. ConfigurationResolver and CorsConfigurationValidator
added under src/Infrastructure/. ADR-002 and ADR-006 retired; SEC-01,
SEC-02, SEC-03 marked Closed. One new testability risk recorded in
architecture.md Open Risks Section 6 (JWKS HTTPS gating).

Source changes:
- src/Auth/JwtExtensions.cs (modified) — ES256, JWKS, alg pinning
- src/Program.cs (modified) — DI wiring for ConfigurationResolver
  and CorsConfigurationValidator
- src/Controllers/AuthController.cs (deleted) — no in-service issuance
- src/Services/TokenService.cs (deleted) — same
- src/Infrastructure/ConfigurationResolver.cs (new)
- src/Infrastructure/CorsConfigurationValidator.cs (new)
- .env.example (new) — required env var documentation
- .gitignore (updated)

Cross-repo coordination: _docs/cross-repo/flights_h1_h2_h3_change_spec
captures the change-spec for downstream services that consumed the now
deleted /auth endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 20:19:05 +03:00
parent 08eadc1158
commit 03f879206e
66 changed files with 6006 additions and 133 deletions
+179
View File
@@ -0,0 +1,179 @@
# Security Tests
> Blackbox-level only. Code-level vulnerabilities (e.g., SQL injection at the source level) are out of scope here — they belong to Step 14 (Security Audit). The SEC-XX gap list in `security_approach.md` is the broader inventory; the tests below are the ones that can be exercised through the public surface.
>
> **Auth model assumed by these tests**: annotations is a verifier-only service. Tokens are minted by the e2e harness's mock issuer (see `test-data.md` → "Bearer token harness") using an ES256 key pair whose public half is published at the JWKS URL the service fetches at boot. There is no `/auth/login`, `/auth/refresh`, or `/auth/register` endpoint on this service.
### NFT-SEC-01: JWT signature mismatch
**Summary**: A token signed with a key not published in the SUT's JWKS is rejected.
**Traces to**: AC-F-50, AC-F-52
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint an ES256 token with valid `iss` / `aud` / `exp` and an `ANN` claim, but signed with a private key whose public half is **not** in the JWKS | token is well-formed |
| 2 | `POST /annotations` with that bearer token | HTTP 401; error envelope; `error.code` matches `/auth|unauthor/i` |
**Pass criteria**: 401, no 500, no leaking which key the SUT expected.
**Duration**: 3s.
---
### NFT-SEC-02: JWT expired
**Summary**: An expired ES256 JWT is rejected.
**Traces to**: AC-F-50
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint a JWT with `exp` 1 hour in the past, signed with a key in the JWKS, otherwise valid (`iss`, `aud`, `ANN` claim) | token is well-formed |
| 2 | `POST /annotations` with that bearer token | HTTP 401; error envelope |
**Pass criteria**: 401; SUT does not honor the expired token.
**Duration**: 3s.
---
### NFT-SEC-03: Cross-policy attempt — DATASET token cannot create annotations
**Summary**: Policy `DATASET` cannot reach `/annotations` POST.
**Traces to**: AC-F-52
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint an ES256 token with the `DATASET` claim and `ANN` claim absent | token is well-formed |
| 2 | `POST /annotations` with that bearer | HTTP 403; error envelope |
**Pass criteria**: 403, request rejected before any DB / FS write.
**Duration**: 3s.
---
### NFT-SEC-04: Cross-policy attempt — ANN token cannot mutate settings
**Summary**: Policy `ANN` cannot reach an `[Authorize(Policy = "ADM")]` route.
**Traces to**: AC-F-52
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `PUT /settings/system` with an ES256 token carrying only the `ANN` claim | HTTP 403; error envelope |
**Pass criteria**: 403.
**Duration**: 3s.
---
### NFT-SEC-05: Anonymous access to non-public endpoints
**Summary**: Every endpoint other than `/health` requires authentication.
**Traces to**: AC-F-50, AC-F-52, security_approach.md surface inventory
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /annotations` with no `Authorization` | HTTP 401 |
| 2 | `GET /dataset` with no `Authorization` | HTTP 401 |
| 3 | `GET /classes` with no `Authorization` | HTTP 401 |
| 4 | `GET /settings/system` with no `Authorization` | HTTP 401 |
| 5 | `GET /health` with no `Authorization` | HTTP 200 |
**Pass criteria**: every authenticated endpoint returns 401; only `/health` is anonymous.
**Duration**: 5s.
---
### NFT-SEC-06: Error envelope leaks no stack trace in production-mode env
**Summary**: Triggering a 500 path returns an error envelope with no `stackTrace` / `innerException` fields.
**Traces to**: AC-N-04
**Preconditions**: SUT started with `ASPNETCORE_ENVIRONMENT=Production`.
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Trigger a path that produces a 500 (e.g., NFT-RES-03 step 2 in DB-paused state) OR a malformed multipart body | HTTP 5xx; body is the error envelope |
| 2 | Inspect body | no key matches `/stack/i`, `/inner/i`, `/trace/i` (case-insensitive) |
**Pass criteria**: error envelope present; no stack-trace leakage.
**Duration**: 30s (depends on fault induction).
---
### NFT-SEC-07: Path traversal in image / thumbnail GET routes
**Summary**: Path traversal sequences in the `id` segment do not escape `images_dir`.
**Traces to**: implicit; SEC-05 broader scope
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /annotations/%2E%2E%2Fetc%2Fpasswd/image` (encoded `../etc/passwd`) | HTTP 400 OR HTTP 404 (NOT 200, NOT containing `/etc/passwd` content) |
| 2 | `GET /annotations/..%2F..%2Fetc%2Fpasswd/thumbnail` | same |
**Pass criteria**: SUT rejects or returns 404; no host file content in the response body.
**Duration**: 5s.
---
### NFT-SEC-08: Token claim modification (signature breaks)
**Summary**: An attacker who edits a JWT payload to elevate to ADM but cannot resign sees 401, not 200.
**Traces to**: AC-F-52
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint an ES256 token with the `ANN` claim | original token |
| 2 | Decode payload; replace policy claim with `ADM`; re-encode payload but **keep the original signature** | tampered token |
| 3 | `PUT /settings/system` with the tampered token | HTTP 401; error envelope |
**Pass criteria**: 401 — signature validation catches the tamper.
**Duration**: 5s.
---
### NFT-SEC-09: CORS preflight respects configured allow-list
**Summary**: With the SUT booted under `ASPNETCORE_ENVIRONMENT=Production` and `CorsConfig:AllowedOrigins=["https://app.azaion.local"]`, a preflight from an arbitrary origin is not given a wildcard ACAO header. `CorsConfigurationValidator` already prevents the wide-open default in Production.
**Traces to**: AC-N-CORS (see `restrictions.md` ENV-06)
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `OPTIONS /annotations` with `Origin: https://attacker.example`, `Access-Control-Request-Method: POST` | HTTP 204 with **no** `Access-Control-Allow-Origin: *`; either no ACAO at all, or an ACAO matching the configured allow-list (which the attacker origin is not in) |
| 2 | `OPTIONS /annotations` with `Origin: https://app.azaion.local` | HTTP 204; `Access-Control-Allow-Origin: https://app.azaion.local` |
**Pass criteria**: only configured origins receive a permissive ACAO; arbitrary origins do not.
**Duration**: 3s.
---
### NFT-SEC-10: Algorithm confusion — `alg=HS256` over the public ES256 key
**Summary**: Annotations pins `ValidAlgorithms = [EcdsaSha256]` to block the classic JWKS-confusion attack where an attacker forges an HS256 token using the published ES256 public key as the HMAC secret.
**Traces to**: AC-F-50
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Fetch the SUT's published JWKS; export the ES256 public key bytes | bytes obtained |
| 2 | Mint a JWT with `alg=HS256` and the public key bytes as the HMAC key, with otherwise-valid `iss` / `aud` / `exp` / `ANN` claim | forged token |
| 3 | `GET /annotations` with that bearer token | HTTP 401; error envelope |
**Pass criteria**: 401 — algorithm pinning rejects the forged token.
**Duration**: 5s.