Files
satellite-provider/_docs/05_security/static_analysis.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

17 KiB
Raw Blame History

Phase 2 — Static Analysis (SAST)

Date: 2026-05-11 Scope: All *.cs files in production projects (Api, Common, DataAccess, Services.*) plus Tests for false-positive triage. Configuration files (appsettings*.json, docker-compose*.yml, Dockerfile, .env). Method: Pattern-based grep + targeted file review.

Patterns checked

Category Pattern(s) Verdict
SQL injection $"SELECT…", + "WHERE", raw CommandText, manual SQL string assembly Clean
Command/process injection Process.Start, ProcessStartInfo, cmd.exe, /bin/sh, UseShellExecute, eval-equivalent Clean
XSS unsanitized user input flowed to HTML or Response.Write N/A — JSON-only API, no HTML rendering
Template injection Razor / scriban / handlebars on user input N/A — none used
Hardcoded credentials password = "…", secret = "…", token = "…", apikey = "…" in source See findings S1, S2
Weak crypto MD5/SHA1 for passwords, RNGCryptoServiceProvider (deprecated), hardcoded keys N/A — no password storage, no crypto code in app
Insecure deserialization BinaryFormatter, pickle, untrusted JSON with type-name handling CleanSystem.Text.Json with default settings; Newtonsoft.Json 13.0.4 used only for outbound serialization to Google session-creation endpoint (line GoogleMapsDownloaderV2.cs), no deserialization of untrusted inbound JSON
Path traversal user input flowed into File.Open, Path.Combine Clean — file paths are computed server-side from validated tile coordinates; no user-supplied path component reaches the filesystem
Sensitive data in logs passwords, API keys, tokens, PII in log statements CleanGlobalExceptionHandler.cs logs only Method, Path, correlationId; client gets a generic 500 + correlationId. CorsConfigurationValidator warning (PermissiveDefaultWarning) does not include secrets. There is a deliberate test fixture GlobalExceptionHandlerTests.cs:23 that uses "Connection string Host=secret-db;Password=hunter2 failed at line 42" to verify the handler does NOT echo exception messages back — this is a positive control, not a finding
Verbose error responses stack traces or internal details returned to clients CleanGlobalExceptionHandler returns RFC 7807 ProblemDetails with Detail = "An unexpected error occurred. Use the correlationId to look up the server log entry."
Input validation numeric ranges, geo coordinates, enum-like strings See finding S3
Hardcoded credentials (cycle 2 delta) Jwt:Secret value in appsettings*.json appsettings.Development.json ships a clearly-tagged DEV-ONLY placeholder; appsettings.json ships "". JWT_SECRET env-var overrides both. See cycle-2 finding F-AUTH-1.
Authentication / authorization (cycle 2 delta) endpoint-level Authorize, custom requirement handlers, claim parsing Program.cs applies .RequireAuthorization() on every existing endpoint and the GPS-permission policy on the new /api/satellite/upload. PermissionsAuthorizationHandler uses string.Equals(..., Ordinal) — no substring / case-confusion bypass. See cycle-2 findings F-AUTH-2 .. F-AUTH-4.
Multipart binary input (cycle 2 delta) uploaded bytes flowing into image decode / file write UavTileQualityGate runs magic-byte check before ImageSharp, wraps decode in scoped try/catch for UnknownImageFormatException / InvalidImageContentException. File path is built from integer coords only via UavTileUploadHandler.BuildUavTileFilePath. See cycle-2 finding F-UAV-1.
Untrusted JSON via claims (cycle 2 delta) JsonDocument.Parse(claim.Value) in PermissionsAuthorizationHandler Tokens are signature-validated before the handler runs, so the JSON parsed here is already framework-validated bytes from a verified token. Token size is bounded by Kestrel header limits. See cycle-2 finding F-UAV-2.

Findings

S1 — Default DB password committed in appsettings.json (Medium)

  • Location: SatelliteProvider.Api/appsettings.json:24
  • Vulnerable code:
    "DefaultConnection": "Host=localhost;Database=satelliteprovider;Username=postgres;Password=postgres"
    
  • Description: The default (non-Development) appsettings file ships with a weak, well-known password (postgres/postgres). In production this string is overridden by ConnectionStrings__DefaultConnection in docker-compose.yml/env, but the file itself becomes the fallback if env-var injection ever fails or is misconfigured (silent connect-as-default behaviour).
  • Impact: If a deployment misconfiguration drops the env override, the app silently falls back to attempting postgres:postgres@localhost. On a developer workstation this connects to the local Postgres container with full superuser; in production it would fail loudly only if the prod DB has different creds. Combined with finding S2 below (matching weak creds in compose file), this normalises a credential pattern that real production deployments may inherit.
  • Remediation:
    • Replace the default value with a deliberately-invalid placeholder such as Host=__set-via-env__;Database=__;Username=__;Password=__ so a misconfiguration fails fast at startup instead of silently falling through.
    • OR remove the ConnectionStrings:DefaultConnection key from appsettings.json entirely and require the env var; Program.cs line 2324 already throws when missing — keep that behaviour.

S2 — Weak Postgres credentials in docker-compose.yml (Medium, dev-only as written)

  • Location: docker-compose.yml:6-7, 30
  • Vulnerable code:
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: postgres
    
    - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres
    
  • Description: Same postgres/postgres credentials as S1. The compose file is labelled Development (ASPNETCORE_ENVIRONMENT=Development), so this is contained — but the file is the only compose artifact in the repo, which means anyone running docker-compose up on a network-reachable host immediately exposes a Postgres-with-default-creds.
  • Impact: Postgres on 0.0.0.0:5432 (port "5432:5432" mapping) with postgres/postgres is one of the most-scanned credential pairs on the public internet. If a developer runs this on a non-laptop host (cloud VM, shared lab, etc.) the DB is trivially compromised within minutes.
  • Remediation:
    • Bind 5432 to 127.0.0.1:5432 rather than 0.0.0.0:5432 so the host firewall isn't the only protection. (Replace "5432:5432" with "127.0.0.1:5432:5432".)
    • Source POSTGRES_USER / POSTGRES_PASSWORD from the same .env file that already supplies GOOGLE_MAPS_API_KEY (line 31 already shows the pattern). Provide an .env.example with placeholder values and document the required vars in the README.
    • The deploy/observability docs at _docs/02_document/deployment/ already describe a secret-manager strategy for staging/prod — fold the same pattern into the dev compose.

S3 — Latitude / longitude inputs not range-validated at the API boundary (Low)

  • Locations:
    • SatelliteProvider.Api/Program.cs:169GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, …)
    • SatelliteProvider.Api/Program.cs:207RequestRegion validates SizeMeters only; request.Latitude / request.Longitude are unchecked
    • SatelliteProvider.Api/Program.cs:237CreateRoute delegates to RouteService which validates names but does not range-check waypoint coordinates
  • Description: Latitude, Longitude, and (for region requests) the implicit MaxRoutePointSpacingMeters boundary are accepted without enforcing valid geographic ranges (-90 ≤ lat ≤ 90, -180 ≤ lon ≤ 180). ZoomLevel IS validated downstream by GoogleMapsDownloaderV2 against MapConfig.AllowedZoomLevels — so it is fine.
  • Impact:
    • Garbage inputs (e.g. lat=999) propagate through GeoUtils.WorldToTilePos and the slippy-map math, eventually producing nonsensical tile coordinates that are persisted to tiles and regions. This is a data-quality issue, not a code-execution issue.
    • No DoS amplification: every tile-download endpoint already enforces zoom against AllowedZoomLevels, so an attacker cannot use lat/lon abuse to multiply outbound Google Maps traffic beyond what zoom already bounds.
  • Remediation: Add explicit guard clauses at the API boundary (matches the existing SizeMeters 100-10000 pattern):
    if (Latitude < -90 || Latitude > 90)  return Results.BadRequest(new { error = "Latitude must be between -90 and 90" });
    if (Longitude < -180 || Longitude > 180) return Results.BadRequest(new { error = "Longitude must be between -180 and 180" });
    
    Apply uniformly to GetTileByLatLon, RequestRegion, and to each waypoint inside CreateRoute.

S4 — .env file on developer filesystem contains an apparently real Google Maps API key (Medium — exposure depends on key reach)

  • Location: .env (workspace root, not tracked — confirmed via git ls-files and .gitignore:10)
  • Description: The local .env contains a 39-character AIzaSy… value matching the Google Maps API key format. The file is correctly excluded from git (line 10 of .gitignore) and git log -- .env returns no history, so the key was never committed to this repository.
  • Impact: No repository exposure. However:
    • If the same key is shared across developers via Slack / email / other repos, it has likely already leaked elsewhere.
    • There is no .env.example template in the repo, which means new contributors typically request the real key via insecure channels rather than generating a fresh one.
    • The key has no per-call attribution; abuse cannot be traced back to a specific developer.
  • Remediation:
    • Rotate the key in the Google Cloud console (out of scope for this audit — the key value is intentionally not echoed into this report).
    • Add .env.example to the repo with GOOGLE_MAPS_API_KEY=replace-with-your-own-key-from-cloud-console and reference it in the README setup section.
    • Configure Google Cloud key restrictions: HTTP referrer allowlist (for browser keys) or IP allowlist (for server keys), and per-API quotas. Optional: per-developer keys.

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

F-AUTH-1 — Dev JWT secret is committed to appsettings.Development.json (Low — accepted by design)

  • Location: SatelliteProvider.Api/appsettings.Development.json:14"Secret": "DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var".
  • Description: A 73-byte placeholder labelled DEV-ONLY ships in the repo. The value is clearly tagged; ResolveSecretOrThrow in AuthenticationServiceCollectionExtensions.cs:43 reads JWT_SECRET from the environment first and only falls back to config when it is unset, so a production deploy with JWT_SECRET set overrides it.
  • Impact: Cosmetic only — the placeholder is not a usable production secret (it is published on every git clone and would be rejected by any token verifier already in the wild). A careless operator who copies the file verbatim into prod and forgets to set JWT_SECRET would still pass the ≥32-byte gate, so the secret would work locally — that is the dependency to monitor.
  • Disposition: Accept. Mitigation: the DEV-ONLY-DO-NOT-USE-IN-PROD prefix is the operator-readable warning; the deploy skill must verify JWT_SECRET is set before promotion.

F-AUTH-2 — JWT issuer / audience are not validated (Medium — by design, until admin team defines values)

  • Location: SatelliteProvider.Api/Authentication/AuthenticationServiceCollectionExtensions.cs:31-32ValidateIssuer = false, ValidateAudience = false.
  • Description: Per the suite contract suite/_docs/10_auth.md, expected iss / aud values are not yet defined. The validator therefore accepts any HS256 token signed with the correct shared secret — including tokens minted by other services in the suite that share the secret. This is a horizontal-trust risk: any service that holds JWT_SECRET can mint tokens accepted by satellite-provider as if they came from the admin API.
  • Impact: Bounded by the secret-distribution policy. Within the trust boundary documented in cycle 1's A01 caveat ("internal/trusted-network service") this is acceptable.
  • Remediation (follow-up, NOT this cycle): When the admin team publishes iss / aud values, flip ValidateIssuer = true + ValidIssuer = "<admin-iss>" and the audience equivalent in AddSatelliteJwt. AZ-487 § Constraints already flags this as a small follow-up.

F-AUTH-3 — No rate limiting on 401-producing paths (Low — recurrence of cycle-1 I3)

  • Location: every /api/satellite/* endpoint after the AZ-487 .RequireAuthorization() middleware.
  • Description: An attacker can flood Authorization: Bearer <random> requests; each one triggers an HMAC verification (cheap, but non-zero) and an HTTP 401 response. This re-uses the cycle-1 I3 finding ("no inbound rate limiting on any HTTP endpoint") — the JWT layer didn't introduce a new vulnerability, but it did add a new cheap-to-trigger 401 surface that magnifies I3.
  • Disposition: Track under existing I3 remediation (wire Microsoft.AspNetCore.RateLimiting). No separate Jira.

F-UAV-1 — ImageSharp decode on attacker-controlled bytes (Medium — exposure increase, mitigations sufficient today)

  • Location: SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs:60-95Image.Identify (Rule 3) and Image.Load<L8> + Mutate(ctx => ctx.Resize) (Rule 5).
  • Description: Pre-AZ-488, ImageSharp only decoded responses from the Google Maps tile CDN (trusted origin). AZ-488 added a second call site that decodes arbitrary POST /api/satellite/upload payloads. Current ImageSharp 3.1.11 is patched (see cycle-2 dependency-scan finding F-DEPS-UAV); the change here is exposure, not a present vulnerability.
  • Mitigations in place:
    • Rule 1 magic-byte gate runs before any ImageSharp call (FF D8 FF prefix required).
    • Rule 2 caps per-item size at 5 MiB; Kestrel + FormOptions cap the envelope at MaxBatchSize × MaxBytes.
    • Decode is wrapped in try { … } catch (UnknownImageFormatException) { … } catch (InvalidImageContentException) { … } — malformed JPEGs produce a structured INVALID_FORMAT reject; no unhandled exception reaches the client.
  • Remediation: Subscribe to SixLabors.ImageSharp GHSA advisories; bump within 7 days of a patch. Sandboxing (separate process / libvips + seccomp) is not warranted at the current trust boundary but should be reconsidered if the endpoint is exposed publicly. Recorded as recurring follow-up.

F-UAV-2 — JsonDocument.Parse invoked on token-supplied claim values (Low — bounded by Kestrel header limits)

  • Location: SatelliteProvider.Api/Authentication/PermissionsRequirement.cs:84-111JsonDocument.Parse(claim.Value) when the permissions claim arrives as a JSON-array string.
  • Description: JsonDocument.Parse has no built-in depth or size limit. A maliciously-shaped permissions claim (e.g. deeply-nested array) would consume CPU/heap during parsing. The token has already passed HS256 signature validation by the time the handler runs, so this is only exploitable by a party that holds JWT_SECRET — i.e. another suite service or an admin-team principal — and only inside the issued-token-size window (bounded by Kestrel's MaxRequestHeadersTotalSize, default 32 KiB).
  • Disposition: Accept. The combination of RequireSignedTokens = true + header-size cap + ordinal-only string comparison makes a practical exploit prohibitive. Future hardening: pass JsonDocumentOptions { MaxDepth = 8 } to JsonDocument.Parse and reject claims longer than e.g. 8 KiB before parsing.

F-UAV-3 — Reject reasons disclose gate structure (Informational — accepted trade-off)

  • Location: SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs — each rule returns a distinct enum code.
  • Description: A client (or attacker who can present a GPS-permission token) can map the gate by probing inputs (1×1 black image → WRONG_DIMENSIONS; 1 KB JPEG → SIZE_OUT_OF_BAND; etc.). The thresholds are also documented in the public contract _docs/02_document/contracts/api/uav-tile-upload.md.
  • Disposition: Accept — UX (helping clients self-correct) outweighs the information-hiding benefit, especially since the contract is public anyway. Flagged to keep operators aware: rule thresholds are NOT a security boundary; do not move secrets into reject details.

Self-verification

  • All production source directories scanned (Api, Common, DataAccess, Services.TileDownloader, Services.RegionProcessing, Services.RouteManagement)
  • Each finding has file path and line number
  • False positives from test files explicitly distinguished (GlobalExceptionHandlerTests.cs:23 "leakySecret" is a positive control)
  • No real secret values printed in this report (S4 is described without echoing the key)