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>
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/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
CreateRouteRequestValidatorchainedRuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator())but originally enforced onlyNotEmptyon the collection — no upper bound onGeofences.Polygons.Count. The siblingPointscollection IS capped at 500; the globalKestrelServerOptions.Limits.MaxRequestBodySizeis 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 ~3ValidationFailureallocations, for ~17 millionValidationFailureobjects in worst case — sufficient to saturate the LOH and trigger a full GC pass. - Impact: Medium. Auth-gated (
RequireAuthorization()atProgram.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 = 50constant added toCreateRouteRequestValidator.cs; chained as.Must(polygons => polygons is null || polygons.Count <= MaxPolygons).WithMessage("…must contain at most 50 polygons.")on thegeofences.polygonsrule. Cap chosen at 50 because geofences are AOI-restriction rectangles (perroute-creation.mdv1.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 ~150ValidationFailureobjects (well within normal request-handling overhead). Tests added:CreateRouteRequestValidatorTests.Validate_GeofencePolygonsTooMany_FailsCountRule(unit) +CreateRouteValidationTests.GeofencePolygonsTooMany_Returns400(integration, asserts 51-polygon array → HTTP 400 witherrors["geofences.polygons"]). Contract bumped toroute-creation.mdv1.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(thecatch (JsonException ex)block of the metadata parse) - Description: The cycle-8
UavUploadValidationFilterechoes the rawJsonException.Messagedirectly to the client as the value oferrors["metadata"]— the SAME information-disclosure pattern as cycle-7 F-AZ795-1 (inGlobalExceptionHandler.cs), introduced in a second code path that bypasses the global exception handler (the filter intercepts and returnsResults.ValidationProblem(...)directly). - Impact: Low. Auth-gated (
.RequireAuthorization(SatellitePermissions.UavUploadPolicy)atProgram.cs:238) — only callers holding a valid JWT with theGPSpermission claim can reach the filter. LeaksSystem.*type names + JSON parse positions — content already inferable from the OpenAPI spec. - Remediation: Sanitise the response message to a generic string (e.g.
"metadatacould not be parsed as JSON. See the server log for details.") while continuing to log the rawex.Messageserver-side under the request'scorrelationId. Lock-step with F-AZ795-1's remediation. Add an integration-test assertion inUavUploadValidationTeststhat noSystem.*substring appears in the response body'serrors[]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.Jsondeserializes an ISO-8601 string without aZ/+HH:MMsuffix into aDateTime,Kind = Unspecified.DateTime.ToUniversalTime()treatsUnspecifiedvalues 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 withTZ=Europe/Kyiv(UTC+02:00 or +03:00) acapturedAtvalue of"2026-05-22T12:00:00"would be treated as2026-05-22T10:00:00Zin 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.ymldoes not overrideTZ). 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 rejectingDateTime.Kind == DateTimeKind.Unspecified. Option 2 is the minimum behaviour-preserving fix; Option 1 is correct for v2.0 ofuav-tile-upload.md. - Status: filed for cycle 9 as Low; not release-blocking — every documented client and every integration-test fixture sends the
Zsuffix.
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)
Add— DONE in Step-14 follow-up (cycle 8). Cap set at 50; see Finding Details § F-AZ809-1 § Resolution.MaxPolygonscap toCreateRouteRequestValidator(F-AZ809-1)
Long-term (Low / Hardening)
- Sanitise client-visible 400 messages (F-AZ795-1 + F-AZ795-2 + F-AZ810-1) — single sanitiser applied to BOTH
GlobalExceptionHandler.WriteClientErrorAsyncANDUavUploadValidationFilter.InvokeAsync. Pre-existing-class instance inUavTileUploadHandler.cs:80must also be sanitised at the same time so the defence-in-depth path doesn't continue leaking. Add matching test assertions inTileInventoryValidationTests+UavUploadValidationTests. Estimated 2 hours. File as a small follow-up child of AZ-795 (epic). - Bump FluentValidation 12.0.0 → 12.1.1 (D-AZ795-1) — single
.csprojedit + regression test pass. No API surface change in 12.0.0 → 12.1.1 per the upstream changelog. - Add
DateTimeKind.Unspecifiedrejection rule toUavTileMetadataValidator(F-AZ810-2 Option 2) — single.Must(capturedAt => capturedAt.Kind != DateTimeKind.Unspecified)rule. Doesn't break any documented client (all examples sendZsuffix). One-line code change + matching unit test. - 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)
- Drop the
try/catch (ArgumentException)block in theCreateRoutehandler (Program.cs:387-399) — pre-cycle-8 inconsistency that cycle 8 reduced the reachability of but did not eliminate. The validator now intercepts mostArgumentExceptioncases; the catch leaksex.Messagein 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.0transitiveNuGet.FrameworksMedium, 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:
- 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.mdv1.0.0 contract. Future drift visibility is high; the architecture doc's coverage table is now complete. - 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>()+RejectUnknownQueryParamsEndpointFilterfor query-string endpoints; customIEndpointFilterfor non-standard wire formats. Future endpoints have a clear pattern to follow; an audit can verify any new endpoint via the chosen-path criterion. - Wire-format normalisation under strict mode (AZ-812) —
Latitude/Longitude→Lat/Lonon the region endpoint aligns with OSM convention used everywhere else in the API. UnderUnmappedMemberHandling.Disallow(cycle 7 global), legacyLatitude/Longitudepayloads 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. - Defence-in-depth retained for the UAV upload handler —
UavTileUploadHandler.HandleAsyncretains every envelope check it had pre-cycle-8 (metadatapresence, JSON parse,Items.Count == 0,Items.Count != files.Count,Items.Count > MaxBatchSize). The newUavUploadValidationFilteris 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.