mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 06:51:13 +00:00
8fca6e0209
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>
102 lines
5.2 KiB
C#
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).");
|
|
}
|
|
}
|