Files
Oleksandr Bezdieniezhnykh 8fca6e0209 [AZ-809] F-AZ809-1: cap geofences.polygons at 50 (security audit)
Closes the cycle-8 Medium DoS finding. Without the cap, an
authenticated caller could submit millions of bbox polygons in a
single 500 MiB request (Kestrel global limit) and saturate the
FluentValidation allocator on the validator hot path; each polygon
is ~90 bytes of JSON, so the body limit is not a useful gate.

Realistic use is 1-10 polygons per route — 50 leaves 5x headroom
while bounding the worst-case allocation.

Layers:
- CreateRouteRequestValidator: MaxPolygons = 50 + Must(...) chained
  before RuleForEach so the count error fires at "geofences.polygons"
  (not the leaf path).
- Unit: Validate_GeofencePolygonsTooMany_FailsCountRule.
- Integration: GeofencePolygonsTooMany_Returns400 (51 valid bbox
  polygons -> HTTP 400 + errors["geofences.polygons"]).
- Contract: route-creation.md -> v1.0.1 patch (tightening an
  existing range). New Inv-10, new geofence-polygons-too-many
  test case, changelog row.
- Test spec: BT-29 sub-case 9b + AZ-809 AC-1b row in the
  traceability matrix.
- Security report: F-AZ809-1 marked RESOLVED in cycle 8; verdict
  remains PASS_WITH_WARNINGS (Lows + carry-overs unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:29:10 +03:00

17 KiB

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/LongitudeLat/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)

  1. 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).
  2. 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.
  3. 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.
  4. 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)

  1. 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-cy4Microsoft.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 / LongitudeLat / 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 handlerUavTileUploadHandler.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.