Co-authored-by: Cursor <cursoragent@cursor.com>
9.1 KiB
Security Test Scenarios
SEC-01: SQL Injection via Coordinate Parameters
Trigger: GET /api/satellite/tiles/latlon?lat=1;DROP TABLE tiles--&lon=1&zoom=18 Expected: Request rejected or treated as invalid parameter Pass criterion: HTTP 400 or parameter parsing error; no database damage; tiles table intact
SEC-02: Path Traversal in Tile Serving
Trigger: GET /tiles/18/../../../etc/passwd Expected: Request rejected; no file outside tiles directory served Pass criterion: HTTP 404 or 400; response body does not contain system file content
SEC-03: Oversized Region Request
Trigger: POST /api/satellite/request with sizeMeters=999999999 Expected: Either rejected or handled without resource exhaustion Pass criterion: No OOM; no infinite processing; either error response or bounded processing
SEC-04: Malformed JSON in Route Request
Trigger: POST /api/satellite/route with invalid JSON body (truncated { or non-JSON text).
Expected: HTTP 400 + RFC 7807 ProblemDetails. Post-AZ-809 (cycle 8) the failure surfaces via GlobalExceptionHandler's JsonException branch (System.Text.Json JsonReaderException → BadHttpRequestException → 400). No stack trace leaks; correlationId present per AZ-353.
Pass criterion: HTTP 400; Content-Type: application/problem+json; body matches error-shape.md v1.0.1; response body does NOT contain System. substring; no internal exception type or stack frame in detail.
Cycle 2 — AZ-487 JWT validation baseline
The pre-AZ-487 assumption "no authentication" is superseded by these scenarios. The SEC-01..SEC-04 scenarios above still hold (they probe input handling, not the auth layer), but every authenticated test variant must now attach a Bearer token.
SEC-05: Anonymous Request to Any Authenticated Endpoint Returns 401
Trigger: GET /api/satellite/tiles/latlon?lat=...&lon=...&zoom=18 (or any /api/satellite/* endpoint) with NO Authorization header.
Precondition: API running with JWT_SECRET configured.
Expected: HTTP 401 Unauthorized; WWW-Authenticate: Bearer header present; response body does not leak validation internals.
Pass criterion: status == 401 AND WWW-Authenticate header starts with Bearer.
AC trace: AZ-487 AC-1.
SEC-06: Expired Token Returns 401
Trigger: Same request as SEC-05 carrying a JWT signed with the configured secret but with exp in the past (clock-skew margin already exceeded).
Expected: HTTP 401; the failure reason surfaces via WWW-Authenticate (e.g. error="invalid_token", error_description="The token is expired"), never in the response body.
Pass criterion: status == 401 AND response body does not contain Expires: / NotBefore: / stack traces / internal exception types.
AC trace: AZ-487 AC-2.
SEC-07: Tampered Signature Returns 401
Trigger: Same request as SEC-05 carrying a JWT whose payload was modified after signing so the HMAC no longer verifies. Expected: HTTP 401; body free of cryptographic detail. Pass criterion: status == 401 AND request never reaches downstream handlers (no DB write, no Google Maps fetch). AC trace: AZ-487 AC-3.
SEC-08: Startup Fails on Missing / Short Secret
Trigger: Boot the API container with JWT_SECRET unset, empty, or shorter than 32 bytes.
Observable: Container exit code, stdout error message.
Pass criterion: container exits non-zero within 10s of start; stderr contains a message identifying the missing or short JWT_SECRET and the 32-byte minimum; Kestrel never binds to its port.
AC trace: AZ-487 AC-5. Behavioral test — no input data.
SEC-09: Valid Token Reaches Handler Unchanged
Trigger: GET /api/satellite/tiles/latlon?... with a JWT signed by the configured secret and exp in the future.
Expected: Response is byte-identical (status, body, headers other than Authorization/WWW-Authenticate) to the pre-AZ-487 baseline for the same parameters.
Pass criterion: status == 200 AND response body matches BT-01 expected schema.
AC trace: AZ-487 AC-4. Also exercised by AZ-487 AC-8 / integration smoke parity.
Cycle 2 — AZ-488 UAV upload authorization
SEC-10: Valid Token Without GPS Permission Returns 403 on UAV Upload
Trigger: POST /api/satellite/upload carrying a JWT with permissions: ["FL"] (no GPS); body is an otherwise-valid 1-item batch.
Precondition: AZ-487 in place; AZ-488 endpoint registered with the GPS permission policy.
Expected: HTTP 403 Forbidden; no row in tiles; no file under ./tiles/uav/.
Pass criterion: status == 403 AND SELECT COUNT(*) FROM tiles WHERE source='uav' AND ... == pre-test count AND uploaded file does NOT exist on disk.
AC trace: AZ-488 AC-6.
SEC-11: Reject-Reason Details Do Not Leak Server Internals
Trigger: POST /api/satellite/upload with a batch where item-1 deliberately fails Rule 5 (IMAGE_TOO_UNIFORM) and item-2 deliberately fails Rule 1 (INVALID_FORMAT).
Precondition: Authenticated request with GPS permission.
Expected: HTTP 200 with per-item results; each rejectDetails is short, human-readable, and contains none of: server-side file paths, exception type names, stack traces, internal class names, secrets, or hostnames.
Pass criterion: For every rejected item, rejectDetails matches ^[A-Za-z0-9 .,()<>=:%/-]{0,200}$ AND contains no path separator (/ or \) followed by a directory name from the server image (tiles, src, obj, bin).
AC trace: AZ-488 § Security NFR.
SEC-12: Wrong iss Claim Returns 401
Trigger: Same request as SEC-05 carrying a JWT signed with the configured secret, with valid exp / nbf / signature, and with an aud claim matching JWT_AUDIENCE — but with iss set to https://wrong-issuer.invalid/ (not equal to JWT_ISSUER).
Precondition: AZ-494 in place; API started with JWT_ISSUER + JWT_AUDIENCE env vars both populated (fail-fast contract).
Expected: HTTP 401 Unauthorized; no handler reached; no leaked detail in body.
Pass criterion: status == 401 AND response body contains no iss / aud value or internal exception detail.
AC trace: AZ-494 AC-1.
SEC-13: Wrong aud Claim Returns 401
Trigger: Same request as SEC-05 carrying a JWT signed with the configured secret, with valid exp / nbf / signature, and with iss matching JWT_ISSUER — but with aud set to wrong-audience-not-satellite (not equal to JWT_AUDIENCE).
Precondition: AZ-494 in place; API started with JWT_ISSUER + JWT_AUDIENCE env vars both populated.
Expected: HTTP 401 Unauthorized; no handler reached; no leaked detail in body.
Pass criterion: status == 401 AND response body contains no iss / aud value or internal exception detail.
AC trace: AZ-494 AC-2.
Cycle 10 — AZ-1113 REST 400 error message sanitization
Extends Inv-5 (error-shape.md v1.0.1) to deserializer/binding 400 paths that previously echoed raw JsonException / BadHttpRequestException text. The 5xx sanitization from AZ-353 is unchanged.
SEC-14: Deserializer 400 errors[] Values Are Static (No Framework Type Leak)
Trigger: Authenticated POST /api/satellite/tiles/inventory with body {"tiles":[{"z":18,"x":1,"y":1,"foo":42}]} (unknown nested field per UnmappedMemberHandling.Disallow).
Expected: HTTP 400 + ValidationProblemDetails; errors["tiles[0].foo"][0] equals "The field value is invalid." per error-shape.md v1.0.1 §Information disclosure.
Pass criterion: HTTP 400; response body does NOT contain System.; does NOT contain .NET member; does NOT echo raw JsonException.Message.
AC trace: AZ-1113 AC-1.
Test method: TileInventoryValidationTests.UnknownNestedField_Returns400 (integration); GlobalExceptionHandlerTests.TryHandleAsync_DeserializationFailure_WritesValidationProblemDetailsWithJsonPath_AZ795 (unit).
SEC-15: Non-JSON BadHttpRequestException detail Is Static
Trigger: Authenticated GET /api/satellite/tiles/latlon?lat=fifty&lon=37.64&zoom=18 (query binding failure without inner JsonException).
Expected: HTTP 400 + RFC 7807 ProblemDetails; detail is "The request could not be processed." per error-shape.md v1.0.1.
Pass criterion: HTTP 400; detail does NOT contain Latitude or other framework bind-failure text from BadHttpRequestException.Message.
AC trace: AZ-1113 AC-2.
Test method: GetTileByLatLonValidationTests.LatTypeMismatch_Returns400 (integration); GlobalExceptionHandlerTests.TryHandleAsync_BadHttpRequestExceptionWithoutJson_UsesStaticDetail (unit).
SEC-16: UAV Upload Metadata Parse Error Does Not Leak Exception Message
Trigger: Authenticated POST /api/satellite/upload with metadata form field {not valid json (malformed JSON).
Expected: HTTP 400 + errors["metadata"] equals `metadata` could not be parsed as JSON. per error-shape.md v1.0.1.
Pass criterion: HTTP 400; full response body does NOT contain System. substring.
AC trace: AZ-1113 AC-3 (filter); AC-4 (handler defense-in-depth via unit test).
Test method: UavUploadValidationTests.MetadataNotAnObject_Returns400 (integration); UavTileUploadHandlerTests.HandleAsync_InvalidMetadataJson_ReturnsEnvelopeError (unit).