Files
satellite-provider/SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs
T
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

102 lines
5.2 KiB
C#

using FluentValidation;
using SatelliteProvider.Common.DTO;
namespace SatelliteProvider.Api.Validators;
// AZ-809: FluentValidation rules for POST /api/satellite/route. Wired
// through ValidationEndpointFilter<CreateRouteRequest> at endpoint
// registration time (.WithValidation<CreateRouteRequest>() in Program.cs).
// Failures are converted to RFC 7807 ValidationProblemDetails per
// _docs/02_document/contracts/api/error-shape.md v1.0.0.
//
// Required-field detection is handled at the deserializer level via
// [JsonRequired] on CreateRouteRequest, RoutePoint, GeofencePolygon, and
// GeoPoint, plus JsonSerializerOptions.UnmappedMemberHandling.Disallow
// (AZ-795 global). This validator covers post-deserialization business
// rules: non-zero id, name + description length, range checks on size /
// zoom / points-count, per-point lat/lon ranges (via RoutePointValidator),
// per-polygon corner ranges + NW-of-SE invariant (via GeofencePolygonValidator),
// and the cross-field createTilesZip-implies-requestMaps rule.
public sealed class CreateRouteRequestValidator : AbstractValidator<CreateRouteRequest>
{
private const double MinRegionSizeMeters = 100.0;
private const double MaxRegionSizeMeters = 10000.0;
private const int MinZoom = 0;
private const int MaxZoom = 22;
private const int MinPoints = 2;
private const int MaxPoints = 500;
private const int MaxNameLength = 200;
private const int MaxDescriptionLength = 1000;
// Geofences are axis-aligned bbox rectangles used for AOI restriction
// during route planning (see route-creation.md). Realistic use is 1-10
// polygons per route; cap at 50 to give 5x headroom while bounding the
// validator's worst-case allocation. The global Kestrel body limit
// (500 MiB, sized for the UAV upload endpoint) is not a useful gate
// here because polygon JSON is small (~90 bytes per minimum-shape
// polygon); without this cap a single authenticated request could
// submit millions of polygons and saturate the LOH.
private const int MaxPolygons = 50;
public CreateRouteRequestValidator()
{
RuleFor(req => req.Id)
.NotEmpty()
.WithMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
RuleFor(req => req.Name)
.NotEmpty()
.WithMessage("`name` is required and must not be empty or whitespace.")
.MaximumLength(MaxNameLength)
.WithMessage($"`name` must be at most {MaxNameLength} characters.");
RuleFor(req => req.Description)
.MaximumLength(MaxDescriptionLength)
.When(req => req.Description is not null)
.WithMessage($"`description` must be at most {MaxDescriptionLength} characters.");
RuleFor(req => req.RegionSizeMeters)
.InclusiveBetween(MinRegionSizeMeters, MaxRegionSizeMeters)
.WithMessage($"`regionSizeMeters` must be between {MinRegionSizeMeters} and {MaxRegionSizeMeters} meters.");
RuleFor(req => req.ZoomLevel)
.InclusiveBetween(MinZoom, MaxZoom)
.WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
RuleFor(req => req.Points)
.NotNull().WithMessage("`points` is required.")
.Must(p => p is null || p.Count >= MinPoints)
.WithMessage($"`points` must contain at least {MinPoints} entries.")
.Must(p => p is null || p.Count <= MaxPoints)
.WithMessage($"`points` must contain at most {MaxPoints} entries.");
RuleForEach(req => req.Points)
.SetValidator(new RoutePointValidator());
// Geofences are optional; per-polygon rules apply only when present.
// FluentValidation's default property-name policy drops the parent
// chain on deep expressions like `req.Geofences!.Polygons` — it emits
// only the leaf `polygons`. We OverridePropertyName explicitly so the
// wire-format error keys match the JSON path callers actually post:
// `errors["geofences.polygons"]` and `errors["geofences.polygons[i].…"]`.
When(req => req.Geofences is not null, () =>
{
RuleFor(req => req.Geofences!.Polygons)
.NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
.NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
.Must(polygons => polygons is null || polygons.Count <= MaxPolygons)
.WithMessage($"`geofences.polygons` must contain at most {MaxPolygons} polygons.")
.OverridePropertyName("geofences.polygons");
RuleForEach(req => req.Geofences!.Polygons)
.SetValidator(new GeofencePolygonValidator())
.OverridePropertyName("geofences.polygons");
});
// Cross-field invariant: cannot zip what wasn't downloaded.
RuleFor(req => req)
.Must(req => !(req.CreateTilesZip && !req.RequestMaps))
.WithName("createTilesZip")
.WithMessage("`createTilesZip` requires `requestMaps` to be true (can't zip what wasn't downloaded).");
}
}