# 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.