# 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** — 0 Medium *open* (F-AZ809-1 **RESOLVED in cycle 8** via the Step-14 follow-up — `MaxPolygons = 50` cap added to `CreateRouteRequestValidator` + matching unit + integration tests) + 2 new Lows open (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, test-runtime only). Zero Critical / High. **Verdict (cumulative)**: **PASS_WITH_WARNINGS** — 1 cycle-4 Medium open (D2-cy4, test-runtime only) + 0 cycle-8 Medium open + multiple Lows. ## Summary | Severity | Cycle 7 delta | Cycle 8 delta (audit-time) | Cycle 8 delta (post-follow-up) | Cumulative open | |----------|---------------|----------------------------|--------------------------------|-----------------| | Critical | 0 | 0 | 0 | 0 | | High | 0 | 0 | 0 | 0 | | Medium | 0 | 1 (F-AZ809-1) | **0 — F-AZ809-1 RESOLVED in cycle 8** | 1 (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, F-AZ810-2) | 2 new (unchanged — both filed for cycle 9) | 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 (post-follow-up)** — was PASS_WITH_WARNINGS at audit time | F-AZ809-1 **RESOLVED in cycle 8** via Step-14 follow-up: `MaxPolygons = 50` cap added to `CreateRouteRequestValidator` + new Inv-10 in `route-creation.md` v1.0.1 + matching unit + integration tests | | 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 (**RESOLVED in cycle 8**) | Insecure Design (A04) | `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82` (pre-fix) | Unbounded `geofences.polygons` collection enables an authenticated DoS on `POST /api/satellite/route` — fixed via `MaxPolygons = 50` cap | | 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) — **RESOLVED in cycle 8 (Step-14 follow-up commit, see git log)** - Location: `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-92` (post-fix; was lines 72-82 pre-fix). - Description: The cycle-8 `CreateRouteRequestValidator` chained `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator())` but originally enforced 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 could have submitted ~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 could reach the endpoint. Within the cycle-8 threat model this was contained; promotion risk eliminated by the cap. - **Resolution** (Step-14 follow-up): `MaxPolygons = 50` constant added to `CreateRouteRequestValidator.cs`; chained as `.Must(polygons => polygons is null || polygons.Count <= MaxPolygons).WithMessage("…must contain at most 50 polygons.")` on the `geofences.polygons` rule. Cap chosen at 50 because geofences are AOI-restriction rectangles (per `route-creation.md` v1.0.1 Inv-10) — realistic use is 1-10 polygons per route, 50 gives 5x headroom while bounding the validator's worst-case allocation to ~150 `ValidationFailure` objects (well within normal request-handling overhead). Tests added: `CreateRouteRequestValidatorTests.Validate_GeofencePolygonsTooMany_FailsCountRule` (unit) + `CreateRouteValidationTests.GeofencePolygonsTooMany_Returns400` (integration, asserts 51-polygon array → HTTP 400 with `errors["geofences.polygons"]`). Contract bumped to `route-creation.md` v1.0.1 (patch — tightens an existing range; new Inv-10 + test case). - Status: **resolved**. No cycle-9 follow-up required. **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)~~ — **DONE in Step-14 follow-up (cycle 8)**. Cap set at 50; see Finding Details § F-AZ809-1 § Resolution. ### 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()` for JSON-body endpoints; `WithValidation()` + `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** (post-follow-up) — 0 Medium open (F-AZ809-1 resolved in Step-14 follow-up) + 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 — but cycle 8 fixed its only Medium in-cycle, leaving only the cycle-4 test-runtime carry-over Medium open. Cycle 8 is **safe to release** within its documented threat model (authenticated callers only on every endpoint). The post-follow-up posture is *cleaner* than the cycle-7 baseline because cycle 8 added zero new open Mediums and resolved its in-cycle finding before commit. Cumulative posture: PASS_WITH_WARNINGS (1 cycle-4 Medium open carry-over + 0 cycle-8 Medium open + multiple Lows). Cycle 8 completed an architecturally important hardening epic (100% input-validation coverage) without leaving Medium or higher debt.