Files
satellite-provider/_docs/05_security/infrastructure_review.md
T
Oleksandr Bezdieniezhnykh 5214a4a647 [AZ-487] [AZ-488] security: cycle 2 delta audit (PASS_WITH_WARNINGS)
Step 14 (Security Audit) for cycle 2 — delta scan against the cycle-1
baseline. Verdict remains PASS_WITH_WARNINGS; no Critical/High.

Scope: JWT auth boundary (AZ-487) and UAV multipart upload + ImageSharp
decode of attacker-controlled bytes (AZ-488). Both new packages
(JwtBearer 8.0.21, ImageSharp 3.1.11 in Services.TileDownloader)
checked.

Cycle-2 delta:
* 0 Critical / 0 High
* 2 Medium: F-AUTH-2 (iss/aud not validated — by design until admin
  team publishes values, AZ-487 § Constraints), F-UAV-1 (ImageSharp
  decode now runs on attacker-controlled bytes — mitigations
  sufficient; pin to GHSA subscribe-and-bump policy).
* 4 Low: F-AUTH-1 (DEV-ONLY secret in appsettings.Development.json —
  accepted), F-AUTH-3 (rate-limit gap extends to 401 floods — folds
  into cycle-1 I3), F-UAV-2 (JsonDocument.Parse on signature-validated
  claims — bounded by Kestrel header cap), D3 (JwtBearer shares D1
  patch line).
* 1 Informational: F-UAV-3 (reject reasons disclose gate structure —
  accepted UX trade-off; documented in contract).

OWASP refresh: A01 / A07 move from N/A (with caveat) to
PASS_WITH_WARNINGS (per-tenant authz absent; iss/aud + revocation
gaps tracked).

Pre-deploy operational gate added: deploy pipeline must verify
JWT_SECRET != DEV-ONLY placeholder before promoting api.

Artifacts: dependency_scan.md, static_analysis.md, owasp_review.md,
infrastructure_review.md, security_report.md — all appended with a
"Cycle 2 Delta" section preserving cycle-1 finding IDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:13:58 +03:00

9.5 KiB

Phase 4 — Configuration & Infrastructure Review

Date: 2026-05-11 Scope: Dockerfile (API + integration tests), docker-compose.yml, docker-compose.tests.yml, .dockerignore, .woodpecker/01-test.yml, .woodpecker/02-build-push.yml, appsettings*.json, .env handling.

Findings

I1 — Dockerfile runs as root (Low)

  • Location: SatelliteProvider.Api/Dockerfile (no USER directive)
  • Description: The final stage of the API image inherits root from mcr.microsoft.com/dotnet/aspnet:8.0 (current Microsoft images default to root unless overridden). Any process compromise — even a low-impact one — has uid-0 inside the container.
  • Impact: Container escape primitives (e.g., kernel CVE, sloppy bind-mount of /var/run/docker.sock) become host-root rather than host-uid-1000. The 02-build-push.yml step itself bind-mounts /var/run/docker.sock into the build container — that's a separate concern (build host, not runtime), but it underscores why "least privilege at runtime" matters even on a single-tenant box.
  • Remediation: Add to the final stage:
    RUN adduser --disabled-password --gecos "" --uid 10001 satellite && \
        chown -R satellite:satellite /app
    USER satellite
    
    Also verify ./tiles, ./ready, ./logs host volumes are writable by uid 10001 in deployment manifests.

I2 — No security headers middleware (Low)

  • Location: SatelliteProvider.Api/Program.cs (no app.UseSecurityHeaders() / app.Use(headers …) block)
  • Description: API responses do not set X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, or HSTS (Strict-Transport-Security) — only app.UseHttpsRedirection() is wired. For a JSON-only API this is low impact (no browser is the primary client), but the missing Cache-Control defaults can let proxies cache 5xx responses.
  • Impact: Limited — JSON-only responses, no cookies, no browser session. The Swagger UI (Development-only) does render HTML; a permissive default there is more of a hygiene issue than a real risk.
  • Remediation: Add a tiny middleware to set the standard hardening headers, OR install NWebsec.AspNetCore.Middleware and wire app.UseHsts() + the nosniff / frame-options defaults. Cheap, no behavioural change.

I3 — No rate limiting on any HTTP endpoint (Medium)

  • Location: SatelliteProvider.Api/Program.cs (no app.UseRateLimiter(), no AddRateLimiter())
  • Description: There is internal concurrency control on outbound Google Maps calls (SemaphoreSlim, MaxConcurrentDownloads), but no inbound rate limiting. An attacker can:
    • Submit N POST /api/satellite/request calls in a tight loop, filling the bounded IRegionRequestQueue (capacity 1000) and DoS-ing the background processor.
    • Submit N GET /api/satellite/tiles/latlon calls with novel lat/lon pairs, forcing the upstream Google Maps quota to drain.
  • Impact: Service-degradation DoS. Combined with finding A01-caveat (no auth), the only protection is the network boundary.
  • Remediation: Wire Microsoft.AspNetCore.RateLimiting (built into .NET 8 — no new package). Conservative starting point:
    builder.Services.AddRateLimiter(options =>
    {
        options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
            RateLimitPartition.GetFixedWindowLimiter(
                partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
                factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 60, Window = TimeSpan.FromMinutes(1) }));
    });
    app.UseRateLimiter();
    
    Tune per-endpoint after observing baseline production load.

I4 — No security-event logs / alerting (Low)

  • Location: Logging strategy across Program.cs, GlobalExceptionHandler, CorsConfigurationValidator
  • Description: Operational logs are well-structured (Serilog → file rotation; correlationId propagation), but there are no log entries for what would be security-relevant events: validation failures (BadRequest stream), repeated 4xx from a single IP, malformed input bursts, or migration failures. The migration failure path does throw and crash startup (good signal), but this leaves no trail in the file logs.
  • Impact: No way to detect abuse of the unauthenticated endpoints from logs alone. For an internal-only deployment this is acceptable; if the API ever moves toward a less-trusted network, post-deploy log-mining will not be able to reconstruct attack patterns.
  • Remediation: Defer until/unless the trust boundary changes. When required: add a structured log line for each 400/404 (with Method, Path, RemoteIp, correlationId) and a counter for "validation failures per minute per IP".

I5 — .env is NOT in .dockerignore (Medium)

  • Location: .dockerignore (line by line review — no .env entry); SatelliteProvider.Api/Dockerfile:15 (COPY . .)
  • Description: The Dockerfile's COPY . . step copies the entire build context into /src. The build context starts at the repo root, where .env lives. .env IS in .gitignore so the dev-only Google Maps key never reaches the git repo, but it WILL be baked into the build-stage image layer (and into the final image, since final does COPY --from=publish /app/publish . — only /app/publish survives, but the build stage retains .env and is reachable if anyone introspects an intermediate layer).
  • Impact:
    • Anyone with read access to the registry can docker pull <build-stage-tag> (if exported) and recover the API key from the layer.
    • Even just the final image: BuildKit cache mounts and any future Dockerfile change that does COPY . /app instead of COPY --from=publish would silently include the file.
  • Remediation: Add .env to .dockerignore:
    .env
    .env.*
    !.env.example
    
    This is a one-line fix and complements finding S4.

I6 — docker-compose.yml exposes Postgres on 0.0.0.0:5432 (Medium — duplicate of S2; restated here for infra-domain completeness)

  • Location: docker-compose.yml:9-10
  • Description / Remediation: See S2.

Items checked clean

  • Secrets management in CI: .woodpecker/02-build-push.yml uses from_secret: registry_host / registry_user / registry_token — no plaintext credentials. The docker login step pipes the token via --password-stdin, which avoids leaking the token via process list. ✓
  • Image attribution: build step labels images with org.opencontainers.image.revision, …created, …source — good provenance hygiene. ✓
  • Healthcheck on Postgres: pg_isready -U postgres configured. (Note: relies on the weak default user from S2.)
  • Log volume layout: ./logs:/app/logs mounted; not exposed via the API. ✓
  • Test runner isolation: docker-compose.tests.yml extends the API service (good — same image) but uses restart: "no" so a flapping integration test doesn't loop and amplify load.

Self-verification

  • All Dockerfiles reviewed (Api + IntegrationTests)
  • All CI/CD configs reviewed (.woodpecker/01-test.yml, 02-build-push.yml)
  • All env / config files reviewed (appsettings*.json, .env, docker-compose*.yml)

Cycle 2 Delta (AZ-487 + AZ-488)

Infra changes this cycle

  • docker-compose.yml:32 adds JWT_SECRET=${JWT_SECRET} to the api service environment block (AZ-487). Sourced from the host env (or .env).
  • docker-compose.tests.yml:21 adds the same JWT_SECRET=${JWT_SECRET} to the integration-test runner so tests can mint matching tokens.
  • .env.example:18 adds an empty JWT_SECRET= line as a deploy-time reminder.
  • .env (workspace root, not tracked) ships a DEV-ONLY-CHANGE-ME-… placeholder ≥ 32 bytes for local docker-compose use.
  • No new infrastructure for AZ-488 — the upload endpoint reuses the existing ./tiles/ volume; UAV files land under ./tiles/uav/{z}/{x}/{y}.jpg inside the same mount.

Cycle-2 verdict — clean

  • Secret distribution: JWT_SECRET flows env-var → docker-compose environment → Kestrel IConfigurationAddSatelliteJwt. No checked-in production secret. The DEV-ONLY value in .env is also explicitly labelled and is bound to the cycle-1 S4 follow-up (rotate-and-document workflow). ✓
  • .env in .dockerignore (cycle-1 I5): still tracked under that remediation; cycle 2 does not add a new .env exposure path. The JWT_SECRET mirroring lives in compose, not the Dockerfile, so it doesn't bake into image layers. ✓
  • No new exposed ports: cycle 2 changes are HTTP-layer only — endpoint registration, middleware, and a multipart handler. No new listener.
  • No new external services: ImageSharp decode is in-process; no new outbound network call introduced by AZ-487 / AZ-488.
  • No CI workflow changes: existing .woodpecker/01-test.yml continues to run unit + integration tests; new cycle-2 unit + integration tests run inside the existing workflow.

Cycle-2 operational follow-ups (NOT findings — pre-deploy verification)

  1. The deploy pipeline must verify JWT_SECRET is set to a ≥ 32-byte value distinct from the DEV-ONLY placeholder before promoting api. The application throws at startup if the value is missing or short, so a misconfigured deploy fails fast — but a deploy that promotes the dev placeholder verbatim would still pass the 32-byte gate. Tracked in security_report.md cycle-2 recommendations.
  2. Coordinate with admin team on iss/aud values (F-AUTH-2). When values are defined, both the AddSatelliteJwt call site and .env/compose docs must be updated together.