[AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 security audit

PASS_WITH_WARNINGS. Zero Critical / High.

New cycle-8 findings:
- F-AZ809-1 (Medium / A04 Insecure Design): unbounded
  geofences.polygons enables an authenticated DoS on
  POST /api/satellite/route. Cap candidate: 50 or 500.
- F-AZ810-1 (Low / A09): JsonException.Message echoed in
  UavUploadValidationFilter (new instance of cycle-7 F-AZ795-1
  pattern in a second code path).
- F-AZ810-2 (Low / Informational): UavTileMetadata.CapturedAt
  typed DateTime not DateTimeOffset; freshness window drifts in
  non-UTC dev environments. Zero impact in UTC-deployed prod.

Carry-overs (cycle 7): F-AZ795-1, F-AZ795-2, D-AZ795-1 still
open. Cycle 4 D2-cy4 still open (test-runtime Medium).

Cycle-8 architectural wins recorded: per-endpoint validation
reached 100% coverage; three approved validation paths
formalised; OSM wire-format normalisation under strict mode
(AZ-812); UAV-handler defence-in-depth retained.

Highest-priority cycle-9 follow-up: F-AZ809-1 polygon cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-23 15:17:31 +03:00
parent 6207ab7c27
commit ac40a8b352
6 changed files with 613 additions and 4 deletions
+125
View File
@@ -0,0 +1,125 @@
# Security Audit Report (Cycle 8)
**Date**: 2026-05-23
**Scope**: Cycle-8 delta over the cycle-7 audit (`_docs/05_security/security_report_cycle7.md`). Cycle-8 surface = AZ-808 (region POST validator) + AZ-809 (route POST validator + per-point + per-polygon) + AZ-810 (UAV upload metadata validator + custom `UavUploadValidationFilter`) + AZ-811 (lat/lon GET validator + `RejectUnknownQueryParamsEndpointFilter`) + AZ-812 (region-API `Latitude`/`Longitude``Lat`/`Lon` wire rename — OSM convention).
**Trigger**: `/autodev` Step 14 (Security Audit) — feature cycle 8, post-implementation, post-test-spec-sync, post-docs-update.
**Verdict (cycle-8 delta)**: **PASS_WITH_WARNINGS** — 1 Medium (F-AZ809-1) + 2 new Lows (F-AZ810-1, F-AZ810-2) + 2 cycle-7 Low carry-overs (F-AZ795-1, F-AZ795-2) + 1 cycle-7 dependency Low carry-over (D-AZ795-1) + 1 cycle-4 dependency Medium carry-over (D2-cy4). Zero Critical / High.
**Verdict (cumulative)**: **PASS_WITH_WARNINGS** — 1 cycle-4 Medium (D2-cy4, test-runtime only) + 1 cycle-8 Medium (F-AZ809-1, auth-gated DoS) + multiple Lows.
## Summary
| Severity | Cycle 7 delta | Cycle 8 delta | Cumulative |
|----------|---------------|---------------|------------|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 0 |
| Medium | 0 | **1 NEW** (F-AZ809-1 — unbounded `geofences.polygons` enables an authenticated DoS on `POST /api/satellite/route`) | 2 (F-AZ809-1 cycle-8 + D2-cy4 cycle-4 carry — `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks`; test-runtime exposure only) |
| Low | 3 (F-AZ795-1, F-AZ795-2, D-AZ795-1) | **2 NEW** (F-AZ810-1 — `JsonException.Message` echo in `UavUploadValidationFilter`; F-AZ810-2 — `DateTime` vs `DateTimeOffset` in `UavTileMetadata.CapturedAt`) | 5+ (3 cycle-7 carry + 2 cycle-8 new) |
## OWASP Top 10:2021 Assessment
| Category | Status (cycle-8 delta) | Findings |
|----------|------------------------|----------|
| A01 — Broken Access Control | PASS | — |
| A02 — Cryptographic Failures | N/A | No crypto in cycle 8 |
| A03 — Injection | PASS | — (cycle 8 strengthens — strict deserialization now backed by per-endpoint range checks at all four newly-validated endpoints) |
| A04 — Insecure Design | PASS_WITH_WARNINGS | F-AZ809-1 (Medium — unbounded `geofences.polygons` cap absence) |
| A05 — Security Misconfiguration | PASS | (Informational note re: global Kestrel 500 MiB body limit; not a finding) |
| A06 — Vulnerable Components | PASS_WITH_WARNINGS | D-AZ795-1 (Low — cycle-7 carry; FluentValidation 12.0.0 → 12.1.1 hardening still available) |
| A07 — Auth Failures | PASS | — (JWT unchanged; every cycle-8 endpoint retains `RequireAuthorization()`) |
| A08 — Data Integrity Failures | N/A | No CI/CD or artifact-signing surface in cycle 8 |
| A09 — Logging Failures | PASS_WITH_WARNINGS | F-AZ810-1 (Low — new instance of F-AZ795-1 pattern in `UavUploadValidationFilter`) + F-AZ795-1 + F-AZ795-2 (Low cycle-7 carry-overs) + F-AZ810-2 (Low / Informational time-handling) |
| A10 — SSRF | N/A | No URL-input fields in cycle 8 |
## Findings
| # | Severity | Category | Location | Title |
|---|----------|----------|----------|-------|
| F-AZ809-1 | **Medium** | Insecure Design (A04) | `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82` | Unbounded `geofences.polygons` collection enables an authenticated DoS on `POST /api/satellite/route` |
| F-AZ810-1 | Low | Information Disclosure (A09) | `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84` | `JsonException.Message` propagated to client in new code path (parallel to cycle-7 F-AZ795-1) |
| F-AZ810-2 | Low | Time-handling correctness (A09 adjacent) | `SatelliteProvider.Common/DTO/UavTileMetadata.cs:30` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60` | `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset` — freshness window drifts by host TZ in non-UTC dev environments |
| F-AZ795-1 | Low | Information Disclosure (A09) | `SatelliteProvider.Api/GlobalExceptionHandler.cs:108-117` | (cycle-7 carry) `JsonException.Message` propagated to client in 400 response — still open |
| F-AZ795-2 | Low | Information Disclosure (A09) | `SatelliteProvider.Api/GlobalExceptionHandler.cs:88-93` | (cycle-7 carry) Generic `BadHttpRequestException.Message` propagated as `Detail` for non-JSON 400 paths — still open |
| D-AZ795-1 | Low | Vulnerable & Outdated Components (A06) | NuGet | (cycle-7 carry) `FluentValidation` + `FluentValidation.DependencyInjectionExtensions` 12.0.0 → 12.1.1 (hardening release; no published CVE) — still open |
| D2-cy4 | Medium | Vulnerable & Outdated Components (A06) | NuGet (test runtime) | (cycle-4 carry) `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks` — test-runtime exposure only — still open |
### Finding Details
**F-AZ809-1: Unbounded `geofences.polygons` collection enables an authenticated DoS** (Medium / A04 — Insecure Design)
- Location: `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82`
- Description: The cycle-8 `CreateRouteRequestValidator` chains `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator())` but enforces only `NotEmpty` on the collection — no upper bound on `Geofences.Polygons.Count`. The sibling `Points` collection IS capped at 500; the global `KestrelServerOptions.Limits.MaxRequestBodySize` is set to 500 MiB to accommodate the UAV upload endpoint and applies to every route. With ~90 bytes per minimum-shape polygon JSON, an authenticated caller can submit ~5.8 million polygons in a single request; each invalid polygon yields ~3 `ValidationFailure` allocations, for ~17 million `ValidationFailure` objects in worst case — sufficient to saturate the LOH and trigger a full GC pass.
- Impact: Medium. Auth-gated (`RequireAuthorization()` at `Program.cs:267`) — only tenant operators with a valid JWT can reach the endpoint. Within the cycle-8 threat model this is contained; promotion risk if the route endpoint is later exposed to an untrusted-tenant audience.
- Remediation: Add `Must(p => p is null || p.Count <= MaxPolygons)` with a defensible upper bound. Reasonable cap candidates: `50` (consistent with the historical use case — geofence rectangles for AOI restriction); `500` (consistent with the sibling `Points` cap on the same DTO). The pattern across the API is "cap every collection field" — one collection slipped past in cycle 8.
- Status: filed for cycle 9 as the **highest-priority** follow-up under AZ-809 (or a sibling child ticket).
**F-AZ810-1: `JsonException.Message` propagated to client in `UavUploadValidationFilter`** (Low / A09 — Information Disclosure)
- Location: `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84` (the `catch (JsonException ex)` block of the metadata parse)
- Description: The cycle-8 `UavUploadValidationFilter` echoes the raw `JsonException.Message` directly to the client as the value of `errors["metadata"]` — the SAME information-disclosure pattern as cycle-7 F-AZ795-1 (in `GlobalExceptionHandler.cs`), introduced in a second code path that bypasses the global exception handler (the filter intercepts and returns `Results.ValidationProblem(...)` directly).
- Impact: Low. Auth-gated (`.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` at `Program.cs:238`) — only callers holding a valid JWT *with* the `GPS` permission claim can reach the filter. Leaks `System.*` type names + JSON parse positions — content already inferable from the OpenAPI spec.
- Remediation: Sanitise the response message to a generic string (e.g. `"`metadata` could not be parsed as JSON. See the server log for details."`) while continuing to log the raw `ex.Message` server-side under the request's `correlationId`. Lock-step with F-AZ795-1's remediation. Add an integration-test assertion in `UavUploadValidationTests` that no `System.*` substring appears in the response body's `errors[]` value.
- Status: filed for cycle 9 as a child of the same F-AZ795-1 ticket — both call sites get the sanitiser at once.
**F-AZ810-2: `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset`** (Low / Informational — Time-handling correctness)
- Location: `SatelliteProvider.Common/DTO/UavTileMetadata.cs:30` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60`
- Description: When `System.Text.Json` deserializes an ISO-8601 string without a `Z` / `+HH:MM` suffix into a `DateTime`, `Kind = Unspecified`. `DateTime.ToUniversalTime()` treats `Unspecified` values as **local time** — the freshness comparison drifts by the host's timezone offset. In a UTC-deployed prod container the local offset is zero; in a developer's local environment with `TZ=Europe/Kyiv` (UTC+02:00 or +03:00) a `capturedAt` value of `"2026-05-22T12:00:00"` would be treated as `2026-05-22T10:00:00Z` in summer, shifting the freshness window by the offset.
- Impact: Low / Informational. Zero observable impact in the supported deployment configuration (Docker containers run in UTC; the cycle-2 `docker-compose.yml` does not override `TZ`). No security exploit: an attacker cannot force the server's TZ. Reported as a defence-in-depth correctness item.
- Remediation (two options): (1) Change the DTO type to `DateTimeOffset` + custom JSON converter rejecting offset-less ISO-8601. (2) Add a FluentValidation rule rejecting `DateTime.Kind == DateTimeKind.Unspecified`. Option 2 is the minimum behaviour-preserving fix; Option 1 is correct for v2.0 of `uav-tile-upload.md`.
- Status: filed for cycle 9 as Low; not release-blocking — every documented client and every integration-test fixture sends the `Z` suffix.
**F-AZ795-1 / F-AZ795-2 / D-AZ795-1**: see `_docs/05_security/security_report_cycle7.md` § "Finding Details" — all three carry-overs are unchanged at cycle-8 tip. The cycle-8 F-AZ810-1 finding is a NEW instance of the F-AZ795-1 pattern in a different code path; both surfaces must be sanitised in lock-step.
**D2-cy4**: see `_docs/05_security/dependency_scan_cycle4.md` — unchanged at cycle-8 tip. Test-runtime exposure only.
## Dependency Vulnerabilities
| Package | CVE | Severity | Fix Version | Status |
|---------|-----|----------|-------------|--------|
| FluentValidation 12.0.0 | — (hardening only) | Low | 12.1.1 | D-AZ795-1 cycle-7 carry — still open |
| FluentValidation.DependencyInjectionExtensions 12.0.0 | — (hardening only) | Low | 12.1.1 | D-AZ795-1 cycle-7 carry — still open (same item) |
| Microsoft.NET.Test.Sdk 17.8.0 (transitive `NuGet.Frameworks`) | — (cycle-4 carry) | Medium | TBD (next Test SDK refresh cycle) | D2-cy4 cycle-4 carry — still open |
No new dependency vulnerabilities in cycle 8. Cycle 8 added zero new packages.
## Recommendations
### Immediate (Critical/High)
None.
### Short-term (Medium)
1. **Add `MaxPolygons` cap to `CreateRouteRequestValidator`** (F-AZ809-1) — single `.Must(...)` chain on `geofences.polygons`. Recommended `MaxPolygons = 50` (use-case-driven) or `500` (sibling-collection-consistent — matches `Points` cap on the same DTO). One-line code change + matching unit test. **Highest-priority cycle-9 follow-up**.
### Long-term (Low / Hardening)
2. **Sanitise client-visible 400 messages** (F-AZ795-1 + F-AZ795-2 + F-AZ810-1) — single sanitiser applied to BOTH `GlobalExceptionHandler.WriteClientErrorAsync` AND `UavUploadValidationFilter.InvokeAsync`. Pre-existing-class instance in `UavTileUploadHandler.cs:80` must also be sanitised at the same time so the defence-in-depth path doesn't continue leaking. Add matching test assertions in `TileInventoryValidationTests` + `UavUploadValidationTests`. Estimated 2 hours. File as a small follow-up child of AZ-795 (epic).
3. **Bump FluentValidation 12.0.0 → 12.1.1** (D-AZ795-1) — single `.csproj` edit + regression test pass. No API surface change in 12.0.0 → 12.1.1 per the upstream changelog.
4. **Add `DateTimeKind.Unspecified` rejection rule to `UavTileMetadataValidator`** (F-AZ810-2 Option 2) — single `.Must(capturedAt => capturedAt.Kind != DateTimeKind.Unspecified)` rule. Doesn't break any documented client (all examples send `Z` suffix). One-line code change + matching unit test.
5. **Per-endpoint Kestrel body-size narrowing** (informational hardening) — apply `.WithMetadata(new RequestSizeLimitMetadata { MaxRequestBodySize = … })` to each non-upload endpoint so the 500 MiB global is constrained for JSON endpoints (e.g. 1 MiB for region/route, 10 MiB for inventory). Reduces F-AZ809-1's exploit surface even after the polygon cap is applied. Tracker child of AZ-795 or a standalone hardening task.
### Pre-existing-class follow-up (Low / Informational)
6. **Drop the `try/catch (ArgumentException)` block in the `CreateRoute` handler** (`Program.cs:387-399`) — pre-cycle-8 inconsistency that cycle 8 reduced the reachability of but did not eliminate. The validator now intercepts most `ArgumentException` cases; the catch leaks `ex.Message` in a non-conformant `{error: string}` shape. Fold into the same cycle-9 follow-up as #2 above.
### Cumulative reminders (carry-overs)
- **D2-cy4** — `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks` Medium, test-runtime only. Owned by the next Test SDK refresh.
## Cycle-8 Architectural Wins
The audit specifically wants to record four improvements introduced this cycle:
1. **Per-endpoint input-validation completion** — the AZ-795 strict-validation epic introduced in cycle 7 reached **100% coverage** in cycle 8. Every public-facing input endpoint (region POST, route POST, lat/lon GET, inventory POST, UAV upload) now runs a validator behind the same `error-shape.md` v1.0.0 contract. Future drift visibility is high; the architecture doc's coverage table is now complete.
2. **Three approved validation paths formalised** — the architecture doc (`_docs/02_document/architecture.md` § 9) now codifies three approved validation paths: `WithValidation<T>()` for JSON-body endpoints; `WithValidation<T>()` + `RejectUnknownQueryParamsEndpointFilter` for query-string endpoints; custom `IEndpointFilter` for non-standard wire formats. Future endpoints have a clear pattern to follow; an audit can verify any new endpoint via the chosen-path criterion.
3. **Wire-format normalisation under strict mode** (AZ-812) — `Latitude` / `Longitude``Lat` / `Lon` on the region endpoint aligns with OSM convention used everywhere else in the API. Under `UnmappedMemberHandling.Disallow` (cycle 7 global), legacy `Latitude` / `Longitude` payloads are now rejected at the deserializer with a structured 400 — no silent coercion, no compatibility-shim layer. This is a hard breaking change for any consumer still on the old shape, but it is intentional and documented as the post-cycle-8 contract.
4. **Defence-in-depth retained for the UAV upload handler**`UavTileUploadHandler.HandleAsync` retains every envelope check it had pre-cycle-8 (`metadata` presence, JSON parse, `Items.Count == 0`, `Items.Count != files.Count`, `Items.Count > MaxBatchSize`). The new `UavUploadValidationFilter` is *additive* — it intercepts the primary endpoint path but does not weaken the service-layer guard for direct callers (e.g. unit tests). The two layers' shape choices are mutually compatible.
## Verdict
**PASS_WITH_WARNINGS** — 1 Medium (F-AZ809-1, auth-gated DoS on the route endpoint via unbounded `geofences.polygons`) + 2 cycle-8 Lows + 3 cycle-7 Low carry-overs + 1 cycle-4 Medium carry-over (test-runtime only). Zero Critical / High.
Per the skill's verdict-logic, Medium severity yields PASS_WITH_WARNINGS. Cycle 8 is **safe to release** within its documented threat model (authenticated callers only on every endpoint). F-AZ809-1 must be the highest-priority cycle-9 follow-up because the missing collection cap is the only inconsistency across the API's otherwise-uniform "cap every collection field" pattern, and the global Kestrel body limit means the validator is the sole gate against an unbounded submission today.
Cumulative posture: PASS_WITH_WARNINGS (1 cycle-4 Medium carry-over + 1 cycle-8 Medium + multiple Lows). No regression of the cycle-7 PASS_WITH_WARNINGS posture; cycle 8 added one new Medium but completed an architecturally important hardening epic.