# SatQuery AI — Security Model, Trust Boundary and Threat Posture **Status of this document.** This is the security chapter of the public SatQuery AI release. It is written against the shipped code and the shipped configuration, and every non-obvious claim carries a file reference. It is deliberately explicit about **what the system does not defend against**, because the single most common failure mode in a security document is to describe a design intent as if it were an enforced control. **Status vocabulary used throughout** (see `DOCS_STYLE_GUIDE.md` §2): `IMPLEMENTED` · `VERIFIED` · `MEASURED` · `ATTEMPTED` · `NOT RUN` · `BLOCKED` · `DEFERRED` · `REJECTED` · `OPEN` · `RESOLVED` · `CLOSED`. **Grounding rule.** Every field name, limit, count, code excerpt and command in this document was read from a file. Where a value could not be established it is written `UNKNOWN — not established from the available evidence`. **A note on the word "gateway".** The backend-contract reference is [`DEPLOYMENT_ARCHITECTURE.md`](architecture/02-deployment-topology.md) §2, which describes a *gateway* whose job is validation, limits, CORS, request ids, timeouts, secret custody and error translation. In the **active** topology that role is played by the Render orchestrator (`deploy/render/main.py`), and the older `gateway/app.py` remains in the tree as the reference implementation of the same contract. This document names the specific file whenever the distinction matters. --- ## Table of contents - [0. How to read this document](#0-how-to-read-this-document) - [1. Scope, trust model and explicit non-goals](#1-scope-trust-model-and-explicit-non-goals) - [2. Secret custody](#2-secret-custody) - [3. CORS: the allowlist rule](#3-cors-the-allowlist-rule) - [4. Size and resource limits](#4-size-and-resource-limits) - [5. Rate limiting is a FAIRNESS control, not a security control](#5-rate-limiting-is-a-fairness-control-not-a-security-control) - [6. The `/v1/assets` endpoint: fail-closed behaviour and the handle-as-capability](#6-the-v1assets-endpoint-fail-closed-behaviour-and-the-handle-as-capability) - [7. Input validation and modality inference](#7-input-validation-and-modality-inference) - [8. The error-translation contract: what may reach a client](#8-the-error-translation-contract-what-may-reach-a-client) - [9. The outbound tunnel: no inbound firewall hole](#9-the-outbound-tunnel-no-inbound-firewall-hole) - [10. What is explicitly NOT defended against](#10-what-is-explicitly-not-defended-against) - [11. Release-side security discipline: no secrets in any published file](#11-release-side-security-discipline-no-secrets-in-any-published-file) - [12. Reporting a vulnerability](#12-reporting-a-vulnerability) - [13. Open, blocked and not-run items](#13-open-blocked-and-not-run-items) - [14. Evidence index](#14-evidence-index) --- ## 0. How to read this document ### 0.1 Three kinds of statement, and why the distinction is load-bearing SatQuery AI is a research prototype with a **narrow, deliberate security scope**. The scope was set by the project's own architecture plan, which excludes authentication, multi-tenancy, queues and autoscaling from v1 (`docs/API_CONTRACT.md` §7; `docs/DEPLOYMENT_ARCHITECTURE.md` §6). The consequence is that some of what a reader expects a "security chapter" to contain is **absent by design**, and the honest way to present that is to separate three classes of statement: | Class | Meaning | Example in this document | |---|---|---| | **Enforced control** | Code that runs on every request and refuses something | the CORS allowlist (§3); the body-size cap (§4) | | **Fairness / operational control** | Code that bounds a resource, but which a hostile caller can defeat | the per-IP rate limiter (§5) | | **Non-goal** | A property the system does not claim, and is not built to provide | authentication (§1.3); audit logging (§10) | A document that blurred these three would let a reader size a deployment's abuse protection on a control that cannot carry it. The rate limiter is the sharpest case, and §5 quotes the owner ruling that settles it. ### 0.2 The four findings that shape this chapter Four measured findings from the project's layer-crossing audit are the spine of the document, because each one is a security property that was **asserted, then measured, then corrected** — which is the only kind of claim worth publishing: | Finding | One-line summary | Section | |---|---|---| | **F-2** | The CORS allowlist was enforced on the *request* leg only; an upstream CORS header was relayed verbatim on the *response* leg, bypassing the allowlist. Measured, then fixed. | §3.5 | | **F-5** | The per-IP rate limiter keys on a client-supplied header, so a caller varying it produces **no** `429` at all. Measured; ruled a fairness control, not a protection control. | §5 | | **F-6 / F-9** | The body-size cap was *declarative* — it measured a `Content-Length` header. A client omitting the header was never measured, and the whole body was buffered. Measured at both layers, then fixed with a streaming reader. | §4.4 | | **F-15** | A construction failure's exception string — containing a **server-side checkpoint path** — reached client-visible fields. Ruled: sanitize client-facing messages, retain full detail server-side. | §8.4 | ### 0.3 What "public release" changes This release publishes documentation, model artifacts and tooling to a public GitHub repository and a public Hugging Face model repository. Publishing changes the security question in one specific way: the **documentation itself becomes an attack surface for information disclosure**, because it is written from the inside and naturally quotes paths, environment variable names and configuration. §11 records the discipline that governs this, and the scan that enforces it. --- ## 1. Scope, trust model and explicit non-goals ### 1.1 What the system is SatQuery AI is a geospatial vision-language analysis service. A user uploads one or two satellite images, asks a natural-language question, and receives a structured `ResultEnvelope` containing an answer, evidence, calibrated confidence and an execution trace. Six task capabilities are served: `vqa`, `caption`, `grounding`, `change`, `change_vqa` and `optical_sar` (`CURRENT_RELEASE_STATE.md` §1, `GET /api/capabilities` — six entries, all `available: true`). The deployed topology is four tiers (`docs/architecture/02-deployment-topology.md` §1): ``` Browser │ HTTPS ▼ Cloudflare Pages (static frontend, https://satquery.pages.dev) │ HTTPS JSON /api/* ▼ Render orchestration hub (satquery-orchestrator; CORS, wake flow, limits) │ outbound long-poll POST /tunnel/agent ▼ GitHub Codespace (FastAPI inference, CPU, port 8000) │ specialist models (SmolVLM · RemoteCLIP · STANet-change · CROMA-fusion · MiniLM router) ``` ### 1.2 What each tier holds — the trust table The architecture document states this as a per-tier table (`docs/architecture/02-deployment-topology.md` §2, "Holds secrets? / Holds state?"): | Tier | Host / identity | Runs | Holds secrets? | Holds state? | |---|---|---|---|---| | Frontend | Cloudflare Pages, `https://satquery.pages.dev` | static site (`frontend/`) | **no** | no | | Orchestrator / gateway | Render, `` | `deploy/render/main.py` | **`GITHUB_TOKEN` only** | no — stateless proxy | | Inference | GitHub Codespace `potential-space-trout-r4ppw969w45j2pvvw`, port `8000` | `app/space_app.py::build_space_app()` via `deploy/codespace/serve.py` | no gateway secrets | ephemeral asset store only | | Model hosting | Hugging Face (project card + pinned model references) | — | no | no | The security-relevant reading of this table: **the only tier that holds a credential is the orchestrator, and the browser talks to the orchestrator, not to the inference host.** §2 develops this. ### 1.3 The explicit non-goals These are not omissions. They are exclusions recorded in the architecture plan and restated in the contract. Each is quoted from the file that records it. | Non-goal | Where it is recorded | Exact statement | |---|---|---| | **No authentication** | `docs/API_CONTRACT.md` §7 | *"There is no authentication in v1. This is a recorded boundary, not an oversight."* | | **No user accounts / multi-tenancy** | `docs/DEPLOYMENT_ARCHITECTURE.md` §6 (from plan §73/§74) | *"No multi-tenant isolation, no auth, no user accounts."* | | **No database, no persistence, no session store** | `docs/DEPLOYMENT_ARCHITECTURE.md` §2.2 | *"No persistence. No database, no Redis, no session store."* | | **No PII store** | consequence of the above: there is no user model, no account, no session, and the only retained bytes are ephemeral image uploads with a bounded TTL (§6) | — | | **No request queue** | `docs/DEPLOYMENT_ARCHITECTURE.md` §2.2 | *"No request queue (plan §73 forbids Redis-cluster/queue infrastructure)."* | | **No retries on `/v1/analyze`** | `docs/DEPLOYMENT_ARCHITECTURE.md` §2.2 | *"No retries on `POST /v1/analyze`. A retry would consume GPU quota a second time; the client must decide."* | | **No model inference at the gateway** | `docs/DEPLOYMENT_ARCHITECTURE.md` §2.2 | *"No model inference."* | | **No asset storage at the gateway** | `docs/DEPLOYMENT_ARCHITECTURE.md` §2.2 | *"The gateway relays upload bytes; it does not retain them."* | The orchestrator's own module docstring states the same three absences in one sentence (`deploy/render/main.py`): *"It holds no model, no state, no database, and performs **no auth** (per plan §73/§74)."* ### 1.4 The trust model, stated plainly ```mermaid flowchart TB subgraph Untrusted["UNTRUSTED — the public internet"] U[Anonymous browser] end subgraph Edge["EDGE — allowlisted origin only"] CF["Cloudflare Pages
static site · no secrets"] end subgraph Boundary["SECURITY BOUNDARY — holds the credential"] R["Render orchestrator
GITHUB_TOKEN · CORS · size caps
request ids · error translation"] end subgraph Infer["INFERENCE — no inbound path"] C["GitHub Codespace
FastAPI :8000 · ephemeral store"] end U -->|HTTPS| CF CF -->|"HTTPS JSON /api/*
(Origin checked)"| R C -.->|"dials OUT · long-poll
POST /tunnel/agent"| R R -->|"response delivered on the open poll"| C ``` Two properties follow, and both are stated in the source rather than inferred: 1. **The trust boundary is the orchestrator.** The architecture document says the inference host *"cannot hold the security boundary"* and that rate limiting, size caps, CORS and secret custody *"belong outside it"* (`docs/DEPLOYMENT_ARCHITECTURE.md` §1.1). The frontend guide states the same from the client side: the frontend talks only to the gateway and never calls the inference host directly — *"it is not the security boundary and its CORS will not welcome you"* (`docs/FRONTEND_INTEGRATION.md` §7, quoted in `docs/architecture/02-deployment-topology.md` §2.7). 2. **There is no identity to escalate from.** Because v1 has no auth, an unauthenticated caller *is* the only kind of caller. `docs/DEPLOYMENT_ARCHITECTURE.md` §5.2 argues this explicitly: the rate-limiter weakness *"does **not** mean the service is insecure in the sense of a privilege escalation: §6 below excludes auth by design and `API_CONTRACT.md` §7 states 'there is no auth in v1', so an unauthenticated caller cannot escalate from a position of nothing."* What it *does* mean is that the limiter cannot protect a metered resource — which is §5. ### 1.5 What "the request-side boundary" enforces The gateway is described as the request-side boundary for exactly three things (`docs/DEPLOYMENT_ARCHITECTURE.md` §5.2, final bullet): **shape, size and content type**. Rate is explicitly *not* one of them. §4 and §7 cover shape and size; §4.6 covers content type. --- ## 2. Secret custody ### 2.1 The rule > **The gateway holds credentials that must never reach the browser.** This is the first of the eight gateway responsibilities in `docs/DEPLOYMENT_ARCHITECTURE.md` §2.1, whose row reads: *"Secret custody | HF token lives here only | The browser never sees it."* The contract restates it as a frontend obligation (`docs/API_CONTRACT.md` §7): *"The frontend **must not** embed an HF token, an API key, or any secret. It talks only to the gateway."* ### 2.2 Where each secret lives The architecture document's env-var vocabulary table (`docs/DEPLOYMENT_ARCHITECTURE.md` §4) assigns each variable to a layer: | Variable | Where it lives | Purpose | |---|---|---| | `HF_TOKEN` | **gateway only** (Railway in the design; Render in the active topology, *if used*) | Authenticate gateway → inference host. Never sent to the browser | | `SATQUERY_SPACE_URL` / `SATQUERY_UPSTREAM_URL` | gateway | Upstream inference URL | | `SATQUERY_ALLOWED_ORIGINS` | gateway | CORS allowlist (the frontend origin) | | `PORT` | platform | Supplied by the platform | | `SATQUERY_DEVICE` | inference host | `cpu` \| `cuda` \| `mps` \| `null` | | `SATQUERY_ASSET_ENABLED` / `SATQUERY_ASSET_DIR` | inference host | Enables `POST /v1/assets`; **both** required | | `SATQUERY_MAX_FILE_BYTES` | **both** | Per-file cap, read by gateway *and* inference host from one variable | | `SATQUERY_MAX_BODY_BYTES` | gateway | Whole-request body cap | | `SATQUERY_UPSTREAM_TIMEOUT_S` | gateway | Gateway → inference timeout | | `SATQUERY_RATE_LIMIT_PER_IP` / `SATQUERY_RATE_LIMIT_WINDOW_S` | gateway | Per-IP count and window (fairness; §5) | | `SATQUERY_ASSET_MAX_FILES` / `SATQUERY_ASSET_TTL_S` | inference host | Optional handle capacity / lifetime | **No secret is committed.** `docs/DEPLOYMENT_ARCHITECTURE.md` §4 states it directly: *"**No secret is committed.** `configs/deploy.yaml` contains no token, and the gateway must read its own from the platform environment."* The `.gitignore` enforces the same for the artifact and data classes that could carry one: it excludes `checkpoints/`, `artifacts/`, `*.pt`, `*.pth`, `*.safetensors`, `*.bin`, `data/`, `benchmark/`, `outputs/`, `runs/`, `*.log`, `.deploy/`, `.kaggle/`, `kaggle.json`, and `.cache/` (`.gitignore`). The `kaggle.json` and `.kaggle/` rules are specifically credential-shaped. ### 2.3 The live configuration, as measured The live Render configuration was measured on 2026-09-25 from `GET /api/health`, and the result is recorded in the header note of `docs/DEPLOYMENT_TOPOLOGY.md`: > *"Verified against `GET /api/health`: env vars are > `CODESPACE_NAME=potential-space-trout-r4ppw969w45j2pvvw`, `CODESPACE_PORT=8000`, > `SATQUERY_ALLOWED_ORIGINS=https://satquery.pages.dev`, `SATQUERY_DEVICE=cpu`, > `SATQUERY_TRANSPORT=auto`, `SATQUERY_TUNNEL_TIMEOUT_S=150`, `SATQUERY_WAKE_TIMEOUT_S=120`, > `SATQUERY_UPSTREAM_TIMEOUT_S=90`, `GITHUB_TOKEN`. > There is **no** `SATQUERY_UPSTREAM_URL` and **no** `HF_TOKEN` in the live config."* The health payload reports the token **as a boolean, never as a value** (`deploy/render/main.py`): ```python "has_github_token": bool(os.environ.get("GITHUB_TOKEN")), ``` The measured payload confirms `"has_github_token": true` (`CURRENT_RELEASE_STATE.md` §1, live health probe). Two consequences a reader should draw: 1. **The deployment does not hold an `HF_TOKEN`.** The token the design assumed for the HF proxy path is absent, because the active transport is the outbound tunnel (§9) and the model host is a Codespace, not a Space. The one credential present is `GITHUB_TOKEN`, used only by the GitHub-API wake path. 2. **A boolean is the correct disclosure for a health endpoint.** The endpoint tells an operator whether a credential is configured without publishing it. This is the pattern the whole of §2 is about. ### 2.4 The token-injection path, and why `Authorization` is dropped When the gateway (reference implementation `gateway/app.py`) forwards a request, the upstream headers are built by `GatewayPolicy.upstream_headers` (`gateway/policy.py`). The docstring states the rule: > *"Strips hop-by-hop headers (a proxy must not forward them) and injects the bearer token. **The token > never travels back to the client** — `docs/DEPLOYMENT_ARCHITECTURE.md` section 4 keeps it here."* The implementation drops two header classes and then injects: ```python out: dict[str, str] = {} for key, value in incoming.items(): if key.lower() in DEFAULT_HOP_BY_HOP: continue if key.lower() in ("host", "authorization"): # `authorization` is dropped rather than overwritten so a client # cannot smuggle a credential toward the Space. continue out[key] = value if token: out["Authorization"] = f"Bearer {token}" return out ``` Three properties are worth naming, because each is a deliberate choice rather than an implementation detail: 1. **The client's own `Authorization` header is dropped, not overwritten.** A client cannot smuggle a credential toward the inference host. The comment says so: *"`authorization` is dropped rather than overwritten so a client cannot smuggle a credential toward the Space."* 2. **`host` is dropped.** Forwarding the client's `Host` to a different upstream would be a request-routing hazard. 3. **An empty token sends no `Authorization` header at all.** The docstring records the measurement: *"The `Authorization` header is omitted entirely when `token` is empty. Sending `Authorization: Bearer ` (with an empty credential) is rejected by httpx itself with `LocalProtocolError: Illegal header value`, which surfaced as a 502 whose `detail` blamed the upstream -- even though the upstream had not been contacted."* An unauthenticated deployment is legitimate (`HF_TOKEN` is optional), so the correct behaviour is to send no credential rather than an empty one. The hop-by-hop set is RFC 9110 §7.6.1 (`gateway/policy.py`): ```python DEFAULT_HOP_BY_HOP: frozenset[str] = frozenset( { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", } ) ``` ### 2.5 The response leg: credential-shaped headers are stripped A gateway that injects a credential on the way out must also refuse to relay credential-shaped headers on the way back. `GatewayPolicy.response_headers` (`gateway/policy.py`) strips, in addition to hop-by-hop: | Stripped on the response leg | Why | |---|---| | `content-length`, `content-encoding` | the ASGI layer re-frames the body, so a forwarded length would be wrong and a forwarded encoding applied twice | | `authorization`, `www-authenticate`, `proxy-authorization` | *"a caller could pass this method the *request* headers by mistake, and the token we injected upstream is a credential. Stripping them here makes that mistake non-disclosing rather than catastrophic."* | | `set-cookie`, `cookie` | the gateway is stateless and has no cookie to set; forwarding one would create a session the architecture does not have | | **every `access-control-*`** | the CORS decision is the gateway's alone (see §3.5) | The `authorization` row is the one that matters for secret custody: it makes a plausible programming error — passing the request headers where the response headers were meant — **non-disclosing**, because the credential-shaped headers are removed on both legs regardless of which dictionary is passed in. ### 2.6 The browser never sees a token — what that means concretely Stated as observable facts rather than as an assurance: | Observable | Value | Source | |---|---|---| | The frontend is a static site with no backend | *"No backend, no secrets, no API calls of any kind"* on the static pages | `docs/DEPLOYMENT_TOPOLOGY.md` §3.1 | | The frontend calls only the orchestrator | `/api/health`, `/api/infer`, `/api/capabilities`, `/api/assets` | `docs/DEPLOYMENT_TOPOLOGY.md` §3.2 | | The token is reported to a client as a boolean | `"has_github_token": true` | `deploy/render/main.py`; `CURRENT_RELEASE_STATE.md` §1 | | The token never appears in a response body | the health payload's `config` block carries `codespace_name`, `codespace_port`, `has_github_token`, `allowed_origins`, `production_origins`, `dev_origins_enabled`, `wake_timeout_s`, `upstream_timeout_s`, `device` — no token value | `deploy/render/main.py` `health()` | **What is not established:** whether the *deployed* `SatQuery-Backend` build (private repo, revision `89d80eaddec5`) differs from the `deploy/render/main.py` in this monorepo. The monorepo's `deploy/` is recorded as *"stale/untracked and is NOT the deployed source"* (`docs/FINAL_DELIVERY_REPORT.md` §2, `CURRENT_RELEASE_STATE.md` §6). So the statements above are grounded in the monorepo reference implementation and in the **live** health payload, which is measured — not in the private deployment's source, which is `UNKNOWN — not established from the available evidence`. --- ## 3. CORS: the allowlist rule ### 3.1 The rule > **`SATQUERY_ALLOWED_ORIGINS=https://satquery.pages.dev`, and never `*`.** The architecture document states it as a gateway responsibility with a one-word prohibition (`docs/DEPLOYMENT_ARCHITECTURE.md` §2.1): *"CORS | Explicit allowlist of the frontend origin | Never `*`."* The measured live value is exactly the production origin (`docs/DEPLOYMENT_TOPOLOGY.md` header note). ### 3.2 The decision function CORS is decided by a function, not by a middleware configuration, so the decision is assertable without a server (`gateway/policy.py::build_cors_headers`). The docstring explains why: > *"The middleware is correct, but the *decision* is the thing under test, and > `docs/DEPLOYMENT_ARCHITECTURE.md` section 2.1 requires an explicit allowlist that is never `*`. Stating > the decision in a function makes it assertable without an ASGI server."* ```python def build_cors_headers( origin: str | None, allowed: Sequence[str], *, request_headers: str = "" ) -> dict[str, str]: if not origin or origin not in allowed: return {} return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": request_headers or "Content-Type, X-Request-Id", "Access-Control-Max-Age": "600", "Vary": "Origin", } ``` The load-bearing line is the first one. A disallowed origin receives **no CORS headers at all** — the docstring states why that is the point: *"A disallowed origin receives **no CORS headers at all**, which is what makes the browser block the response. Echoing the origin back with `Access-Control-Allow-Origin: ` regardless would defeat the allowlist entirely."* Note also `Vary: Origin`, which prevents a shared cache from serving one origin's CORS answer to another. ### 3.3 Startup refuses a wildcard The allowlist is validated **at construction**, so a misconfigured deployment fails fast rather than serving traffic with an open policy (`gateway/policy.py::GatewayConfig.__post_init__`): ```python if "*" in self.allowed_origins: raise ValueError( "allowed_origins must not contain '*'. A wildcard exposes the " "Space to any origin (docs/DEPLOYMENT_ARCHITECTURE.md 2.1)" ) ``` and, immediately before it: ```python if not self.allowed_origins: raise ValueError( "allowed_origins must be non-empty. An empty allowlist would " "either block every browser or, if the app fell back to '*', " "expose the Space. Refuse to start rather than guess." ) ``` The empty-list case is the subtle one and the message says why: an empty allowlist has two possible "fixes" in a hurried implementation — block everything, or fall back to `*` — and the second is a silent security failure. The validator refuses both by refusing to start. ### 3.4 The development origins are enumerated, never a pattern The orchestrator adds local development origins so a frontend developer can test without deploying (`deploy/render/main.py::_DEV_ORIGINS`). The list is an explicit enumeration: ```python _DEV_ORIGINS: tuple[str, ...] = tuple( f"http://{host}:{port}" for host in ("localhost", "127.0.0.1") for port in ("3000", "5500", "5173", "8000", "8080") ) _PRODUCTION_ORIGINS: tuple[str, ...] = ("https://satquery.pages.dev",) ``` Two design decisions are recorded in the docstrings, and both are security-relevant: 1. **Production is listed in code, not only in the environment.** *"Listed here rather than only in the environment so that a deployment which forgets `SATQUERY_ALLOWED_ORIGINS` still serves the real frontend -- an empty allowlist would otherwise take the live site down, which is a worse failure than the one this guards."* 2. **The dev list is explicit, and cannot be reached from a remote host.** *"This list is deliberately EXPLICIT, never a wildcard or a suffix match. It cannot be used to reach the deployment from an arbitrary host: only a browser running on the developer's own machine can send `Origin: http://localhost:*`."* The enumerated host:port pairs — and the fact that `localhost` and `127.0.0.1` are *distinct browser origins* — are the reason both spellings appear. The dev origins can be switched off for a production deployment via `SATQUERY_ALLOW_DEV_ORIGINS` (`deploy/render/main.py::_dev_origins_enabled`), and the health payload reports the **effective** list so an operator can prove from outside that they were turned off (`allowed_origins`, `dev_origins_enabled`). ### 3.5 F-2: the response-leg bypass, measured and fixed This is the most instructive CORS item in the project, because the allowlist was **correct on the request leg and bypassed on the response leg**. It is recorded in `gateway/policy.py::response_headers`'s docstring and in `docs/DEPLOYMENT_ARCHITECTURE.md` §5. **The defect.** Until 2026-09-22 the CORS decision was made only on the *request* leg (`build_cors_headers`, via `admit()`), and the *response* leg rebuilt the upstream's headers wholesale. So a CORS header the inference host emitted on its own was relayed to the browser verbatim, on top of the gateway's allowlist answer. **The measurement.** With the allowlist set to `["https://app.example.com"]` and the upstream answering with `Access-Control-Allow-Origin: *` (`gateway/policy.py`, verbatim): | Request | Result | |---|---| | `GET /v1/health` from `https://evil.example.net` | returned **two** values for `access-control-allow-origin`, `*` and the allowlisted origin | | `POST /v1/analyze` from the same disallowed origin | returned `200` with `Access-Control-Allow-Origin: *` **and** `Access-Control-Allow-Credentials: true` | **Why the `*` case is worse than a permissive echo.** The docstring argues it: *"a duplicated `ACAO` is not a value a browser can match to an allowlisted origin -- it makes the gateway's own correct headers unreliable for allowed callers while the `*` still admits everyone."* The bypass therefore degrades the service for legitimate callers **and** admits everyone — two failures in one header. **The fix** is a **prefix rule**, not an enum: ```python and not key.lower().startswith("access-control-") ``` The docstring records why a prefix rather than a list: *"RFC 6648 discourages new `Access-` headers, but the CORS family has grown (`-Allow-Credentials`, `-Expose-Headers`, `-Max-Age`, `-Allow-Methods`, `-Allow-Headers`) and an enum would silently miss whichever is added next. Nothing the Space may legitimately return starts with this prefix, because the CORS answer is the gateway's to give."* **A pinned assertion, not a trusted helper.** `gateway/app.py::_proxy` asserts the filter rather than relying on it: ```python assert not any(_is_cors_header(k) for k in out_headers) or decision.headers, ( "a CORS header reached the response without a policy decision; the " "upstream's headers are no longer filtered (see F-2)" ) out_headers.update(decision.headers) ``` The comment states the intent: *"The CORS answer is decided in ONE place. If `response_headers` ever stops filtering, the upstream's headers reach a disallowed origin -- so this pins the filter rather than trusting it."* **Verified by a test.** `tests/unit/test_gateway_app.py::test_an_upstream_cors_header_cannot_bypass_the_allowlist` (`gateway/app.py`). The helper `_is_cors_header` is deliberately written without the literal prefix — `_CORS_HEADER_PREFIX = "access-" + "control-"` — *"so that `tests/unit/test_gateway_app.py`'s 'the app must not build an access-control-\* header itself' assertion is testing the app's behaviour rather than tripping over this helper's spelling."* ### 3.6 Preflight is answered by the gateway, never forwarded `GatewayPolicy.admit` step 1 (`gateway/policy.py`): ```python # 1. Preflight is answered here, never forwarded. The Space has no CORS # configuration and forwarding OPTIONS would waste a round trip. if method == "OPTIONS": return PolicyDecision.allowed(request_id, headers) ``` This closes the preflight failure recorded as a deployment blocker: *"Hand-rolled CORS; `OPTIONS` raised `405`, so browser preflight failed"* (`docs/DEPLOYMENT_TOPOLOGY.md` §4, blocker #3). --- ## 4. Size and resource limits ### 4.1 The limit table Every limit below is read from a file. The gateway defaults are `GatewayConfig` dataclass defaults (`gateway/policy.py`); the image geometry values are from the frozen configuration registry (`configs/base.yaml`). | Limit | Value | Where | Enforced by | |---|---|---|---| | Whole-request body cap | `8 * 1024 * 1024` = **8,388,608 bytes (8 MiB)** | `GatewayConfig.max_body_bytes` (`gateway/policy.py`) | `SATQUERY_MAX_BODY_BYTES` | | Per-file cap | `4 * 1024 * 1024` = **4,194,304 bytes (4 MiB)** | `GatewayConfig.max_file_bytes` (`gateway/policy.py`) | `SATQUERY_MAX_FILE_BYTES`, read by **both** layers | | Max image pixels | **25,000,000** | `image.max_pixels` (`configs/base.yaml`) | `OversizedImageError` (`core/errors.py`) | | Max tiles examined | **64** | `image.max_tiles` (`configs/base.yaml`) | tile policy (`docs/MASTER_ARCHITECTURE_PLAN.md` §9.1) | | Tile size / overlap | **512** / **128** | `image.tile_size`, `image.tile_overlap` (`configs/base.yaml`) | preprocessing | | Top-K tiles through a specialist | **4** | `image.top_k_tiles` (`configs/base.yaml`) | tile policy | | Per-IP rate limit | **10** requests / **60.0 s** | `GatewayConfig.rate_limit_per_ip` / `_window_s` | fairness only (§5) | | Upstream timeout | **90.0 s** | `GatewayConfig.upstream_timeout_s` | gateway → inference | | Agent budget (inference host) | **120 s** | `agent.timeout_seconds` (`configs/base.yaml`) | controller | | Asset handle capacity | **32** files | `AssetStore.max_files` default | inference host | | Asset handle TTL | **900.0 s (15 min)** | `AssetStore.ttl_seconds` default | inference host | | Evidence items | **32** | `evidence.max_items` (`configs/base.yaml`) | evidence engine | ### 4.2 Why there are two byte caps The per-file cap is below the body cap **by construction**, and the ordering is validated at startup (`gateway/policy.py`): ```python if self.max_file_bytes > self.max_body_bytes: raise ValueError( "max_file_bytes exceeds max_body_bytes; the per-file cap would " "be unreachable and the body check would fire first" ) ``` The reason for two caps rather than one is in `docs/DEPLOYMENT_ARCHITECTURE.md` §4: `SATQUERY_MAX_BODY_BYTES` is the *whole-request* cap, *"above the per-file cap so one legal file is never refused for framing overhead."* A multipart envelope around a legal 4 MiB file must not be refused because the envelope pushed the total past 4 MiB. ### 4.3 The image-geometry budget The pixel budget is a **resource** control in the same family as the byte caps, and it lives in the frozen config registry rather than in the gateway (`configs/base.yaml`): ```yaml image: max_pixels: 25000000 tile_size: 512 tile_overlap: 128 max_tiles: 64 # plan section 9.1 tile policy: whole-image thumbnail first, then top-K tiles. # max_tiles is the hard ceiling on tiles *examined*; top_k_tiles is how many # are actually sent through a specialist. top_k_tiles: 4 ``` The plan's tile policy is the reason `max_tiles` and `top_k_tiles` are separate: *"Do not send every tile through the VLM"* (`docs/MASTER_ARCHITECTURE_PLAN.md` §9.1). `max_tiles: 64` bounds what is *examined*; `top_k_tiles: 4` bounds what is *sent through a model*. A maliciously large image therefore cannot turn into 64 model calls. **Status of the pixel budget.** The registry values are frozen and hashed (`config_hash == 78f1e3700da15aa1`), and `OversizedImageError` exists with the code `oversized_image` (`core/errors.py`, mapped to HTTP `413` in `gateway/policy.py::_CODE_STATUS`). Whether the *deployed* inference host actually enforces `max_pixels` on every path is `UNKNOWN — not established from the available evidence`: the live validation (§14) exercised real uploads but did not include an over-pixel-budget probe. ### 4.4 F-6 / F-9: the cap must be enforced *while reading*, at both layers This is the finding that turned a declared limit into an enforced one, and it is the clearest example in the project of a control that *looked* present and was not. **The defect (F-6, gateway).** `GatewayPolicy.admit` step 3 refuses an oversized body from the `Content-Length` **header alone**, and it has to: it runs before the body is read, so the header is the only evidence available. That makes the cap **declarative** — a client that omits the header is never measured. The measured consequence, through the real ASGI stack with `max_body_bytes` at 8 MiB and a 12 MiB body (`gateway/app.py`, verbatim): | Case | Result | Peak allocation | Bytes read | |---|---|---|---| | `Content-Length` **declared**, 12 MiB | `413 oversized_image` | **0.2 MiB** | **0** (the cap worked) | | `Content-Length` **omitted**, 12 MiB | `502 model_unavailable` | **13.9 MiB** | **12 MiB** (the cap was **SKIPPED**) | and *"allocation tracked the body size exactly with no ceiling -- 1/8/16/32/64 MiB in produced 3.0/8.1/16.0/32.0/64.0 MiB allocated."* The source's own summary: *"the header check protects the common case and bounds nothing in the hostile one."* **The fix.** A streaming reader that refuses the moment the accumulated total exceeds the limit (`gateway/assets.py::read_body_bounded`, called by `gateway/app.py::_read_body_bounded`): ```python chunks: list[bytes] = [] total = 0 async for chunk in request.stream(): total += len(chunk) if total > limit: # Stop reading immediately. Doing so lets the server close the # connection without the client delivering the rest of the body, # which is the point of a streaming cap. return b"", True chunks.append(chunk) return b"".join(chunks), False ``` The docstring records why `Content-Length` is *not* consulted here even as a fast path: *"The F-6 measurement is the reason: a declared length drew `413` with 0.2 MiB peak, but an **omitted** one drew `502` with a 13.9 MiB peak, so anything keyed on that header holds only for clients that tell the truth. A client that lies low is caught by the accumulation check; a client that omits the header is measured like any other."* The bound is therefore `limit + one chunk`, *"rather than by whatever the client chose to send."* **The defect (F-9, the second layer).** The inference host had the *same* `await request.body()` line and therefore the same defect. Measured with the cap at 1 MiB (`gateway/assets.py`, verbatim): *"a **64 MiB** body produced a peak allocation of **128 MiB**, and a 16 MiB body 32 MiB, tracking body size **linearly with no ceiling**. It answered `413` eventually, but only after buffering everything. The gateway's fix had protected one caller of two."* **The fix, and the anti-drift reasoning.** The helper was **moved down** to the module both layers already import, rather than copied. The docstring states the reason as the F-7 finding restated: *"Fixing the Space by copying the helper would have created two copies of a security control -- and two copies drift, which is the F-7 finding restated."* `gateway/assets.py` is dependency-free (no fastapi, no starlette, no httpx, no torch), which is what lets the inference host import it without pulling the web stack. **Two lines, both needed.** `docs/DEPLOYMENT_ARCHITECTURE.md` §4 states the resulting posture: *"Both layers are needed and neither replaces the other: the header check is the one that saves memory in the common case, and the streaming check is the one that cannot be evaded. **Operators should not treat the header check as the protection** — it protects the gateway's memory against honest clients, not against hostile ones."* ### 4.5 F-7: one variable is not one value `SATQUERY_MAX_FILE_BYTES` is read by **both** layers — the gateway validates it, and the inference host builds its `AssetStore` from it (`app/space_app.py::_asset_max_file_bytes`). Until the fix, they parsed it differently, and the docstrings asserted they *"cannot disagree"*. Measured on 2026-09-22 with the same value through both parsers (`gateway/policy.py`, verbatim): | Input | Gateway | Inference host | |---|---|---| | `'0'` | **ACCEPTED** `max_file_bytes=0` | raised `ValueError` | | `'-1'` | **ACCEPTED** `max_file_bytes=-1` | raised `ValueError` | | `'abc'` | raised at startup | **silently defaulted** to 4 MiB | | `'4e6'` | raised at startup | **silently defaulted** to 4 MiB | The source identifies the dangerous case: *"A cap of 0 is the dangerous case rather than a harmless typo: the gateway admits the request (its own check is against `max_body_bytes`) and the Space then refuses EVERY upload, because `len(data) > 0` is true for any non-empty file. The operator sees '413 on every upload' against a cap they believe they never set."* Both layers now refuse an unparsable **or non-positive** value and **name the variable**: ```python max_file_bytes = _int("SATQUERY_MAX_FILE_BYTES", 4 * 1024 * 1024) if max_file_bytes <= 0: raise ValueError( f"SATQUERY_MAX_FILE_BYTES={max_file_bytes} is not positive. A " f"non-positive per-file cap would make every upload fail on the " f"Space while the gateway kept admitting it; the Space's AssetStore " f"rejects the same value, so it is refused here to fail at startup " f"with the variable named rather than on the first upload" ) ``` The inference host's parser is deliberately **stricter than a silent default** and **not a bare exception**, and its docstring says why: *"A silent default is the worse of the first two: it means a deployment whose operator typed a malformed cap keeps accepting uploads against a limit nobody chose, and nothing anywhere says so."* The direction of the strictness is argued, too: *"This makes the gateway's parse stricter, which is the safe direction -- it cannot refuse a value the Space would have accepted, because the Space refuses these too."* **Cross-layer agreement is tested.** `docs/API_CONTRACT.md` §2.5 records that *"a cross-layer agreement test drives the whole matrix through both real parsers."* ### 4.6 The content-type allowlist The upload path accepts a **closed list of exactly five types** (`gateway/policy.py::GatewayConfig`): ```python allowed_content_types: tuple[str, ...] = ( "image/tiff", "image/geotiff", "image/png", "image/jpeg", "application/octet-stream", ) ``` Three behaviours are specified and each is a security choice: 1. **An absent type is refused, not defaulted.** `_normalise_content_type` maps `None` to `""` so the allowlist check rejects it: *"defaulting an absent type to `application/octet-stream` would make the allowlist unenforceable for exactly the clients that omit the header"* (`gateway/assets.py`). The contract's framing: *"A request with **no** declared type is **refused rather than defaulted** — defaulting is how a PDF reaches a raster reader"* (`docs/API_CONTRACT.md` §2.5). 2. **Media-type parameters are ignored**, so `image/tiff; charset=binary` is accepted (`_normalise_content_type` splits on `;`). 3. **The type is enforced independently at both layers.** The inference host keeps its own copy — `_ALLOWED_ASSET_CONTENT_TYPES` in `app/space_app.py` — with a stated reason: *"Mirrors `GatewayConfig.allowed_content_types`; the Space's copy exists because the Space validates independently (defence in depth) rather than trusting that the gateway is the only caller."* This is the one place where a duplicated list is **deliberate** rather than a drift hazard, and the docstring says so. The contract notes the practical consequence for a client: *"`image/tiff` is the type the geospatial specialists need — a client that uploads only PNG/JPEG can serve the VQA, caption and grounding tasks but not the change or optical/SAR ones"* (`docs/API_CONTRACT.md` §2.5). ### 4.7 Timeout ordering is a validated invariant The upstream timeout must sit between the longest per-task GPU duration and the agent's own budget, and both bounds are enforced at construction (`gateway/policy.py`): ```python if self.upstream_timeout_s <= 45: raise ValueError(...) # would kill a legitimate grounding/optical_sar call if self.upstream_timeout_s >= 120: raise ValueError(...) # would hold a connection past the point the Space has given up ``` The two messages state the failure each prevents: a too-short timeout *"would kill a legitimate grounding/optical_sar call and report it as an upstream failure"*; a too-long one *"would hold a connection past the point the Space has given up, turning an upstream timeout into a client-side hang."* The frozen per-task durations are `vqa`/`caption` 20 s, `grounding` 45 s, `change` 30 s, `optical_sar` 45 s, `change_vqa` 30 s (`configs/deploy.yaml`; `app/space_app.py::GPU_DURATIONS`), and `agent.timeout_seconds` is 120 s (`configs/base.yaml`). --- ## 5. Rate limiting is a FAIRNESS control, not a security control ### 5.1 The ruling, quoted in full This is the section where the document must be most careful, because the temptation is to describe a rate limiter as "protection". The owner ruling of 2026-09-23 settled the question, and `docs/DEPLOYMENT_ARCHITECTURE.md` §5.2 records it verbatim: > **✅ RULED 2026-09-23 (owner ruling): the limiter is RETAINED as a fairness / rate-control mechanism > only, and it is explicitly NOT a security or abuse-prevention boundary.** > > **No code change.** The ruling settles the question this section left open — whether the limiter > should be hardened into a protection control — and the answer is **no**. It stays as back-pressure > against accidental loops and honest clients. Consequences a deployment must honour: > > * **Do not size abuse protection on this limiter.** It is not that control, and treating it as one > would leave the `5 GPU-minutes/day` ZeroGPU quota unprotected. > * **A `429` is a fairness signal, not a security signal**, and its ABSENCE is not evidence that no > abuse occurred. A caller varying `X-Forwarded-For` produces no `429` at all (measured below). > * The gateway remains the request-side boundary (`§1.1`) for **shape, size and content type** — the > things it can actually enforce. Rate is not one of them. > > This is a **documentation ruling**: it changes what this document *claims*, not what the code does. ### 5.2 The mechanism The limiter is an in-memory fixed-window counter keyed on client identity (`gateway/policy.py::RateLimiter`): ```python def check(self, identity: str) -> tuple[bool, int, float]: now = self.now() state = self._state.get(identity) if state is None or now - state.window_start >= self.window_s: state = RateLimitState(window_start=now, count=0) self._state[identity] = state if state.count >= self.limit: retry_after = self.window_s - (now - state.window_start) return False, 0, max(0.0, retry_after) state.count += 1 return True, self.limit - state.count, 0.0 ``` Three properties, each deliberate: 1. **A denied request does not increment the counter** — *"otherwise a client hammering the endpoint would push its own reset further away on every rejected attempt."* 2. **The clock is injected** (`now: Callable[[], float] = field(default=time.monotonic)`), so tests are deterministic without sleeping. 3. **The identity key is the IP alone** (`ClientIdentity.key`): *"Including the user agent would let one client obtain an unbounded number of buckets by varying it."* The limiter is applied only to routes marked `COSTLY_ROUTES` (`gateway/app.py`): ```python COSTLY_ROUTES: tuple[str, ...] = ("/v1/analyze", "/v1/assets") ``` The docstring explains the membership: `/v1/analyze` costs GPU quota, and *"`POST /v1/assets` does not touch the GPU, but it does write to the Space's disk and consume one of a bounded number of handles (`gateway/assets.py`), so an unthrottled upload loop is a cheap denial of service against a 5-GPU-minute deployment."* `COSTLY_ROUTES` is deliberately a **separate allowlist** from `PROXIED_ROUTES`: *"the two answer different questions -- 'may this reach the Space at all?' and 'does it cost a metered resource?' -- and collapsing them would make the rate limiter's coverage depend on the proxy allowlist."* Rate limiting is step 4 of the admit ladder, after method allowlist and body size, and the ordering is justified: *"Rate-limiting health checks would make the frontend's load probe fail for no gain"* (`gateway/policy.py::admit`). ### 5.3 F-5: the measurement that forces the narrow claim The rate-limit key is derived from the **first hop of `X-Forwarded-For`** (`gateway/app.py::_client_ip`): ```python forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() ``` That header is client-supplied. The code's own docstring already noted the value is attacker-controlled and is *"a rate-limit key, not an identity"*; what was missing was the **measured consequence**. Measured in-process, limit set to 3 requests / 60 s, 8 requests sent (`docs/DEPLOYMENT_ARCHITECTURE.md` §5.2): | Case | Statuses | Throttled | |---|---|---| | One client, no `X-Forwarded-For` | `502 502 502 429 429 429 429 429` | **5 / 8** | | A fresh spoofed `X-Forwarded-For` per request | `502 502 502 502 502 502 502 502` | **0 / 8** | So a caller willing to vary one header produces **no `429` at all**. This is the measurement that makes "the limiter is a fairness control" a *fact* rather than a preference. ### 5.4 Why there is no code fix — and why that is deliberate `docs/DEPLOYMENT_ARCHITECTURE.md` §5.2: > **Why there is no code fix here yet, and why that is deliberate.** Correctly trusting > `X-Forwarded-For` requires knowing how many proxy hops the platform inserts — a **deployment fact** > that cannot be verified from the build host, where nothing is deployed. Hard-coding an assumption > would replace a *documented* weakness with an *undocumented* one, which is exactly the error C-4 > made. The fix belongs in the deployment step and needs a live gateway to measure against. ### 5.5 What a client should do with a `429` A `429` is a normal state, not a bug (`docs/API_CONTRACT.md` §6, obligation 4: *"Handle `429` and `503` as normal states, not as bugs"*). The response honours `Retry-After`, computed as `max(1, int(retry_after + 0.999))` (`gateway/policy.py::admit`), and `recoverable: true` is set on the envelope. The frontend obligation is explicit: *"Serialize analyses. Do not retry `429`/`503` in a tight loop"* (`docs/API_CONTRACT.md` §9, item 7). ### 5.6 The `rate_limited` code is gateway-origin, and the sets stay disjoint `rate_limited` is **not** in `core/errors.py`. It is declared in `gateway/policy.py` as a gateway-origin code, because `docs/API_CONTRACT.md` §5.1 maps HTTP 429 to "Rate limited" while the `core/errors.py` taxonomy — which covers the *analysis* pipeline, not the proxy — assigns that status no code: ```python GATEWAY_ORIGIN_CODES: frozenset[str] = frozenset({"rate_limited"}) _CODE_STATUS["rate_limited"] = 429 ``` The rule that keeps the taxonomy honest: *"the gateway never invents a code for an error that ORIGINATED in the Space. Those pass through unchanged."* Two tests hold the line (`docs/API_CONTRACT.md` §5.3): `tests/unit/test_gateway_responsibilities.py` asserts §5.2 and `core/errors.py` are in **exact one-to-one correspondence** (23 codes), and `tests/unit/test_gateway_policy.py` asserts a gateway-origin code may **never** shadow a taxonomy code. ### 5.7 The four client headers that reach the inference host untrusted Recorded because it is a latent, not a live, exposure (`docs/DEPLOYMENT_ARCHITECTURE.md` §5.2): `cookie`, `x-forwarded-host`, `x-real-ip` and `x-forwarded-for` are forwarded as the client sent them. The inference host reads none of them today — *"its only header read is `content-type` (`app/space_app.py`)"* — so the exposure is latent. `Authorization` is **not** in this set: it is dropped on the request leg and replaced with the gateway's own token, and stripped again on the response leg (§2.4, §2.5). --- ## 6. The `/v1/assets` endpoint: fail-closed behaviour and the handle-as-capability ### 6.1 The fail-closed gate `POST /v1/assets` is **off unless explicitly enabled**, and it requires **two** environment variables (`app/space_app.py::_asset_store_available`): ```python def _asset_store_available() -> bool: import os return bool(os.environ.get("SATQUERY_ASSET_ENABLED", "")) and bool( os.environ.get("SATQUERY_ASSET_DIR") ) ``` The docstring states the design: *"Off by default in a deployment that has not set `SATQUERY_ASSET_DIR`, and ON when it has -- so turning on the fourth endpoint is an explicit operator action rather than something that starts writing to a temp directory unbidden."* `docs/DEPLOYMENT_ARCHITECTURE.md` §4 states the same as a contract: *"**Both this and `SATQUERY_ASSET_DIR` must be set** or the route answers `503` — it fails closed rather than defaulting to a temp directory."* ### 6.2 The refusal is a conforming `503` When the store is not configured, the handler returns a `503` envelope that names the switch (`app/space_app.py::assets`): ```python if not _asset_store_available(): status, body = translate_error( "model_unavailable", "Asset upload is not enabled on this deployment.", detail=( "Set SATQUERY_ASSET_ENABLED=1 and SATQUERY_ASSET_DIR to a " "writable path to enable POST /v1/assets. See " "docs/DEPLOYMENT_ARCHITECTURE.md." ), recoverable=False, ) return JSONResponse(status_code=status, content=body) ``` A deployment that has not enabled uploads **says so, with the reason and the switch**, rather than accepting bytes it cannot keep. `docs/API_CONTRACT.md` §5.1 records the status: `503` when *"the asset store is unconfigured or full."* **An honest gap: the two `503` causes are indistinguishable.** A saturated store and an unconfigured store produce the same status and the same envelope shape. `docs/DEPLOYMENT_ARCHITECTURE.md` §5.1 records this as finding F-11, and — unusually — records the *removal* of the diagnostic that would have separated them: the counters (`_refusals`, `_sweeps`) and `AssetStore.stats()` were computed on every request and read by **nothing**, so they were deleted rather than given a consumer. The section's own summary: *"**⚠️ Removing the counter did NOT remove the ambiguity.** The two `503` causes remain indistinguishable from a response, and the deployment still **does not expose** an instrument that tells them apart."* Confirming which cause applies requires inspecting the deployment. ### 6.3 The handle is the access control `POST /v1/assets` returns an opaque handle, and the opacity is the *only* access control the endpoint has (`gateway/assets.py::_new_handle`): ```python def _new_handle() -> str: """An opaque, unguessable handle. `secrets`, not `random`: the handle is this endpoint's only access control (`API_CONTRACT.md` section 7 -- there is no auth in v1). 128 bits from `token_hex(16)` is not brute-forceable, and the prefix keeps a handle recognisable in a log without making it derivable. """ return f"asset_{secrets.token_hex(16)}" ``` The module docstring states the two consequences, and they are the security argument for the whole design: > * **A client cannot enumerate the store.** `asset_0`, `asset_1` or a hash of the content would let a > caller who guessed one handle reach another user's upload. There is no auth in v1 > (`API_CONTRACT.md` section 7), so the handle IS the capability: unguessable is not a nicety, it is > the only access control the endpoint has. > * **A handle does not leak a server path.** The stored filename is the handle plus a sanitised suffix > taken from the *content type*, never from the client-supplied name -- so a name like > `../../etc/passwd` cannot become a path. The original name is not stored at all, because it is not > needed and storing it would be storing attacker-controlled text for no reason. ### 6.4 Path safety: the suffix comes from the type, never the filename The stored suffix is derived from a fixed mapping keyed on the **normalised content type** (`gateway/assets.py`): ```python _SUFFIXES: Mapping[str, str] = { "image/tiff": ".tif", "image/geotiff": ".tif", "image/png": ".png", "image/jpeg": ".jpg", "application/octet-stream": ".bin", } ``` The docstring states the property: *"Derived from the TYPE, never from the client-supplied filename, so a hostile name cannot influence a path."* The stored path is `self.root / f"{asset_id}{suffix}"` where `asset_id` is the opaque handle — so the only client-influenced component is the content type, and it is validated against the allowlist (§4.6) before the suffix is chosen. **What is not established:** whether the normalised content type is validated against `_SUFFIXES` as well as against the allowlist before `_suffix_for` runs. `_suffix_for` returns `.bin` for an unknown type, and `.bin` is a fixed string, so an unknown type cannot produce an attacker-chosen extension — but the allowlist check in `put()` runs *before* `_suffix_for`, so in the shipped path an unknown type is refused outright. The `.bin` fallback is therefore `IMPLEMENTED` but unreachable on the shipped configuration; whether it is reachable on any other configuration is `UNKNOWN — not established from the available evidence`. ### 6.5 Atomic writes: no partial upload is ever observable `AssetStore.put` writes via a temporary file and `os.replace` (`gateway/assets.py`): ```python fd, tmp_name = tempfile.mkstemp(dir=str(self.root), suffix=".part") try: with os.fdopen(fd, "wb") as handle: handle.write(data) os.replace(tmp_name, path) except BaseException: try: os.unlink(tmp_name) except OSError: pass raise ``` The docstring states the property: *"Write via a temporary file and `os.replace`, so a reader can never observe a partially written upload. `os.replace` is atomic on both POSIX and Windows when source and destination share a volume."* The `except BaseException` clause leaves nothing behind on failure and *"does not mask the original exception if the cleanup itself fails."* ### 6.6 Expiry is checked on read, not by a sweeper `AssetStore.get` evaluates expiry itself (`gateway/assets.py`): ```python if stamp >= record.expires_at: self._forget(asset_id) raise UnknownAssetError(f"unknown or expired asset handle {asset_id!r}") ``` The docstring states why: *"Expiry is evaluated here rather than trusted to a sweeper: a handle past its deadline is unknown even if nothing has run `sweep()`. That is what makes the TTL a guarantee instead of a housekeeping hope."* The deadline is **monotonic**, so *"a system clock adjustment cannot extend or truncate a TTL"* (`AssetHandle.expires_at`). Two more fail-closed behaviours in `get`: | Condition | Behaviour | Reason | |---|---|---| | Record missing or past deadline | `UnknownAssetError` | lapsed handle refused even if nothing swept it | | Record present but the file is gone | `UnknownAssetError`, and the stale record is dropped | *"an operator cleared the directory, or a container restarted with a fresh volume. Reported as unknown -- the handle is not usable, which is what the client needs to know"* | ### 6.7 Expired and never-issued are one error, on purpose `UnknownAssetError` covers *expired* as well as *never issued*, and the docstring states the reason: *"a client cannot act on the difference (both mean 'upload again'), and distinguishing them would report whether a handle had ever existed -- a small information leak about other clients' uploads, which matters precisely because there is no auth."* This is a deliberate refusal to provide an oracle. ### 6.8 The response never discloses a path `AssetHandle.to_response` returns four fields, and `path` is deliberately absent (`gateway/assets.py`): ```python def to_response(self) -> dict[str, Any]: return { "asset_id": self.asset_id, "content_type": self.content_type, "bytes": self.bytes, "expires_at": self.expires_at_iso, } ``` The docstring: *"`path` is deliberately absent. Returning it would hand a client a server-side filesystem location -- an information disclosure, and an invitation to construct a path directly instead of via a handle."* The contract repeats the guarantee for the frontend: *"It is not a path, and the response never discloses one"* (`docs/API_CONTRACT.md` §2.5). ### 6.9 No idempotency key — a stated answer, not an omission `gateway/assets.py` states it as a decision: *"There is **no** idempotency key, and that is a deliberate answer rather than an omission. A retried upload is a *new* handle, not the same one, because the two requests are indistinguishable at the server: no plan-specified key exists, and inventing one would be inventing contract. The cost is a leaked handle, which `AssetStore.capacity` bounds and `AssetStore.sweep` reclaims."* The contract states the client obligation: *"A client that retries must therefore use the *latest* handle, and should expect the abandoned one to occupy a slot until it expires"* (`docs/API_CONTRACT.md` §2.5). ### 6.10 F-12: a misconfigured asset root is a bounded defect `_asset_store_available()` is a **presence** check — it asks whether the two variables are *set*, never whether the directory is *usable*. Everything that can go wrong with the configured value therefore happens later, at `get_asset_store()`, which sits **outside** the `try` that wraps `store.put()`. Measured with the real ASGI app, root set to a path that exists but is a **file** (`docs/DEPLOYMENT_ARCHITECTURE.md` §5.1.1): | `SATQUERY_ASSET_DIR` | Inference host directly | Through the gateway | |---|---|---| | unset (the presence gate) | `503` + envelope | `503` + envelope | | a writable directory (control) | `201` | `201` | | **an existing file** | **`500 text/plain` `Internal Server Error`** | `502` + envelope | The third row is a hole in the "every non-2xx carries the envelope" promise, **but only on the inference host's own public URL**. The gateway refuses to relay a non-JSON upstream body and substitutes a conforming `502`, so *"a client that goes through the gateway never sees it."* The same holds for the inference host's framework errors (`GET /v1/assets` → `{"detail":"Method Not Allowed"}`), recorded as F-12b. The disposition is *"recorded and not repaired"*: *"changing a route's error surface is not an audit's call, and the Space that would carry it is **not yet deployed**."* Operational guidance: *"prefer a `SATQUERY_ASSET_DIR` you have confirmed is creatable and writable, and treat a `500` from the Space's own URL as a configuration fault rather than a crash to debug."* --- ## 7. Input validation and modality inference ### 7.1 Two layers validate, and the schema is authoritative Validation happens at the gateway (`validate_analyze_body`) and again at the inference host (`AnalysisRequest.model_validate(payload)`). The gateway's function is deliberately **not** a re-implementation of the model (`gateway/policy.py`): > *"Deliberately NOT a re-implementation of `AnalysisRequest`: it checks only what the contract's > documented error envelope can carry. Two obligations it DOES take on, because forwarding either would > cost a round trip (and possibly GPU quota) for a request the Space will certainly reject: every field the > contract marks required is present and well-typed; no field outside the model's own field set is present, > because `AnalysisRequest` uses `extra="forbid"`."* The **schema's** validation is authoritative (`extra="forbid"`, Pydantic). The gateway's job is to answer **cheaply**, before importing the serving stack, *"so that a malformed body never costs a model load."* ### 7.2 The checks the gateway performs `validate_analyze_body` (`gateway/policy.py`) rejects, each with a conforming envelope: | Check | Failure | HTTP | |---|---|---| | Body is valid UTF-8 JSON | not valid JSON | `400` `input_error` | | Body is a JSON object | e.g. a JSON array or scalar | `422` `invalid_request` | | No unknown fields | any key outside `AnalysisRequest.model_fields` | `422` `invalid_request` | | `assets` is a non-empty array of non-empty strings | missing, empty, or containing a non-string | `422` `invalid_request` | | `query` is present and a string | missing or wrong type | `422` `invalid_request` | | `force_task` is `null` or a valid `Task` value | wrong type or unknown value | `422` `invalid_request` | | `run_id` is `null` or a string | wrong type | `422` `invalid_request` | Two of these are derived from the source of truth rather than hard-coded, which is the anti-drift property: ```python def _analyze_request_fields() -> frozenset[str]: try: from core.schemas import AnalysisRequest return frozenset(AnalysisRequest.model_fields) except Exception: # pragma: no cover - only in a broken install return frozenset({"assets", "query", "force_task", "run_id"}) ``` ```python def _task_values() -> frozenset[str] | None: try: from core.schemas import Task return frozenset(member.value for member in Task) except Exception: # pragma: no cover - only in a broken install return None ``` The **asymmetry** between the two fallbacks is deliberate and security-relevant (`gateway/policy.py::_task_values`): *"An unknown-field check needs a field set, and the documented four field names are a short, stable list ... The `Task` values are a different case: they are the router's vocabulary and the schema is explicit that it may grow (`core/schemas.py`). A stale copy here would silently reject a newly-added task at the gateway, before the Space could accept it -- **a gate that fails closed on valid input, which is worse than no gate.** The correct degradation is to forward, since the Space's own Pydantic model is authoritative anyway."* **The enum check was once missing, and the omission was not harmless** (`gateway/policy.py`): *"a body with `force_task: "nonsense"` was forwarded to the Space, whose schema rejected it -- so the client received a 502/upstream error for a defect entirely local to the request. That both mis-states the fault and spends a GPU-quota round trip on a body the Space cannot accept."* **Cross-checked in both directions.** `tests/unit/test_gateway_policy.py` *"cross-checks this function's verdicts against the real model in BOTH directions, which is what caught the originally-missing unknown-field rule."* ### 7.3 A malformed `Content-Length` cannot bypass the size check `gateway/app.py::_proxy` treats an unparseable `Content-Length` as **oversized**, not as absent: ```python try: content_length = int(raw_length) if raw_length is not None else None except ValueError: # A non-numeric Content-Length is malformed; treat it as unparseable # rather than as absent, so it cannot be used to bypass the size check. content_length = cfg.max_body_bytes + 1 ``` This is a small, precise anti-bypass: a client sending `Content-Length: abc` is refused rather than measured as having no length. The streaming reader (§4.4) then measures the body regardless. ### 7.4 Modality inference — where it happens, and where it does not The gateway does **not** infer modality. The contract is explicit that `assets` values are **handles**, not bytes and not URLs (`docs/API_CONTRACT.md` §2.4): *"Values are **asset handles returned by the upload step** (§2.5), not base64 and not URLs."* The inference host is the one place the translation `handle → AssetStore.get() → path` happens (`app/space_app.py::analyze`), and the docstring states why it is done there rather than earlier: > *"A handle that is unknown or expired is refused HERE, with a named error, rather than being passed to > the controller as a path that does not exist -- which would surface as a raster read failure and name the > wrong cause."* The refusal is an `input_error` envelope: ```python except UnknownAssetError as exc: status, body = _translate( "input_error", "One or more asset handles are unknown or have expired.", detail=exc.detail, ) ``` **All-or-nothing resolution.** `AssetStore.resolve_many` resolves every handle and raises on the first failure: *"All-or-nothing: a partial resolution would let an analysis start with one of a required pair missing, which the specialists would then reject with a pairing error that names the wrong cause. Failing here names the real one."* The `AnalysisRequest` is then **rebuilt**, not mutated: *"`model_copy` rather than mutating, because `AnalysisRequest` is the contract's model and a handler must not rewrite a validated request in place."* **Modality** itself is a property the specialists and the planner reason about, not the gateway. The registry is authoritative for what each capability requires — the gateway *"must not answer 'what can this deployment do?' from its own data"* (`docs/DEPLOYMENT_ARCHITECTURE.md` §2.2), because the authoritative sources are `core.planner.CAPABILITY_ASSETS` and `SpecialistSpec.requires_assets`, and *"duplicating that logic here would give two places to disagree."* ### 7.5 The method allowlist `GatewayPolicy.admit` step 2 refuses a method the contract does not define (`gateway/policy.py`): ```python allowed_methods = {"GET", "HEAD", "POST", "OPTIONS"} if method not in allowed_methods: status, body = translate_error( "invalid_request", f"Method {method} is not supported.", detail=f"allowed: {', '.join(sorted(allowed_methods))}", request_id=request_id, ) return PolicyDecision.refused(request_id, status, body, headers) ``` The comment: *"A method the contract does not define is a 405 from the gateway, not a 404 from the Space."* ### 7.6 Request-id handling is a collision defence An inbound `X-Request-Id` is **not trusted verbatim** (`gateway/policy.py::accept_request_id`): ```python _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9_\-]{8,64}$") def accept_request_id(self, inbound: str | None, *, seed: str | None = None) -> str: if inbound and _REQUEST_ID_RE.match(inbound): return inbound return self._request_id_factory(seed) ``` The docstring states the threat: *"Trusting an arbitrary inbound value lets a client collide with, or poison, another request's correlation id in the Space's logs. The format is therefore constrained (`req_`-less, 8-64 chars of `[A-Za-z0-9_-]`), and a rejected value is **replaced rather than sanitised** -- a mangled client id is more confusing than a fresh one."* The constraint is both a length bound (no log-flooding payload) and a charset bound (no control characters or newlines into a log line). Generated ids are deterministic-from-seed when a seed is given, which is the same reproducibility technique used elsewhere in the project: *"the same technique the rest of this repository uses for reproducibility"* (`new_request_id`). --- ## 8. The error-translation contract: what may reach a client ### 8.1 The envelope Every non-2xx response body has one shape (`docs/API_CONTRACT.md` §5): ```json { "error": { "code": "pair_misaligned", "message": "The images are not sufficiently co-registered for spatial analysis.", "detail": "RMSE 4.21 px exceeds the 2.0 px budget", "recoverable": false, "request_id": "req_01H...", "run_id": "9f2c1c0e-..." } } ``` `code` is **stable** and comes from `core/errors.py`. `message` is operator-safe (`SatQueryError.user_message`). `detail` is technical and may be absent. The gateway builds this with `translate_error` (`gateway/policy.py`). ### 8.2 The gateway may not invent or remap a code `translate_error` passes the code through **unchanged**. The rule is stated twice — `docs/DEPLOYMENT_ARCHITECTURE.md` §2.3 and `gateway/policy.py`'s docstring: > *"The `code` is passed through **unchanged**. The gateway must not invent codes: the taxonomy in > `core/errors.py` is the single source of truth, and a gateway that remapped it would make the frontend's > error handling unpredictable."* An **unrecognised** code is mapped to `satquery_error` / `500` — never to a success status: ```python known = code in _CODE_STATUS status = _CODE_STATUS.get(code, 500) if not known: detail = f"unmapped error code {code!r}" + (f"; {detail}" if detail else "") ``` The comment: *"Keep the original code visible in `detail` for diagnosis, but present a code the contract defines. Swallowing it entirely would hide a real defect behind a generic one."* And the mapping is **total**: *"every code either appears here or falls through to 500, and `tests/unit/test_gateway_policy.py` asserts that no code silently maps to the wrong class. A gateway that let an unknown code produce a 200 would turn a defect into a success."* The status map has 23 taxonomy codes plus `rate_limited` (24 entries). Defect codes — *"codes that indicate the *system* is broken, not the request"* — are a separate frozenset (`model_load_error`, `schema_validation_error`, `coordinate_error`, `confidence_range_error`, `leakage_violation`), and the frontend is instructed to surface them rather than swallow them (`gateway/policy.py::DEFECT_CODES`; `docs/API_CONTRACT.md` §5.2). ### 8.3 Framework-raised 404/405 also carry the envelope `docs/API_CONTRACT.md` §5.1: *"**`404` and `405` carry this same envelope**, which is worth stating because they are the two statuses a proxy framework raises before any handler runs."* This was made true by finding **F-3** (`docs/DEPLOYMENT_ARCHITECTURE.md` §5), after the gateway previously returned the framework's own `{"detail": "Not Found"}` for both — which *"broke any client that assumed §5"*. The handler is registered in **both** `gateway/app.py` and `app/space_app.py` (the latter as F-12b), so a client hitting either host gets the contract shape. A related footgun is documented rather than hidden (`docs/API_CONTRACT.md` §5.1): a trailing slash answers **`307`**, and *"The `Location` is built from the gateway's own host, **not** from the client's request URL, so a redirect followed naively after a `POST` may not land where the caller expects."* ### 8.4 F-15: the path-scrubbing ruling This is the central disclosure finding. A construction failure's exception string — containing a **server-side checkpoint path** — reached client-visible fields. Measured through the real inference host with a request body containing **no path at all** (`docs/DEPLOYMENT_ARCHITECTURE.md` §5.5): ``` "message": "vqa was planned but could not be constructed: encoder weights_path does not exist: C:/srv/satquery/artifacts/change/stanet_encoder_v3.pt" ``` The section states why this is worse than the trace-path findings: *"This is **strictly worse than F-13/F-14**: the asset path was derived from something the client supplied, whereas a checkpoint path is purely server-side, and §7 records that *there is no auth in v1*. It is also reachable on the **first** request -- the deployment condition, not a warm-process edge case."* **The ruling (2026-09-23):** *"sanitize all client-facing exception messages; retain full exception details only in server-side diagnostics."* Two halves, and *"both are load-bearing — the second is what stops the fix from destroying operability."* **The carrier count was wrong, and the ruling is what exposed it.** Re-measuring with a whole-body walk of **156** string fields found **four** carriers rather than three: | # | Carrier | Why it was missed | |---|---|---| | 1 | `result.warnings[0]` | named in the original finding | | 2 | `result.evidence[0].payload["message"]` | named | | 3 | `trace.parameters.registry.built[optical_sar].detail` | named (the warm-process case) | | 4 | `result.execution_trace.parameters.registry.built[optical_sar].detail` | **not named** — `core/controller.py:379`/`:736` set `result.execution_trace = trace`, so the trace object is serialized **twice** | Measured improvement: **8/11 → 14/14** path-free fields. **The fix is a basename reduction, not a replacement** (`core/errors.py::scrub_paths`). It reduces absolute paths (Windows drive, UNC, POSIX) to their final component and is applied at the producer (`_failure_entry`), at the seam (`RegistryEntry.to_trace`), and at both controller sites (`_failure_evidence`, `_warnings`). The raw string is logged server-side (`satquery.registry` WARNING with `exc_info`). ```python _WINDOWS_DRIVE_PATH = re.compile( r"(?|:*?]+[\\/])*[^\\/\s\"'<>|:*?]*" ) _UNC_PATH = re.compile(r"\\\\[^\\/\s\"'<>|:*?]+(?:\\[^\\/\s\"'<>|:*?]+)+") _POSIX_PATH = re.compile(r"(?|:*?]+/)*[^/\s\"'<>|:*?]+") ``` **Three properties of the scrubber that are deliberate, and each argued in the docstring:** 1. **Relative paths are deliberately NOT matched.** *"A rule broad enough to catch `artifacts/change/head.pt` also catches `and/or` and the path segments of a URL, and a scrubber that mangles ordinary prose is a worse defect than the disclosure it fixes. The measured leaks are all absolute."* 2. **URLs are left intact on purpose.** *"`https://github.com/antofuller/CROMA` appears inside one of the very messages this scrubs, and mangling it would be a worse defect than the one being repaired."* The POSIX pattern's `(? **Measured 2026-09-25 (live).** Transport is an **outbound tunnel**, not a polled forwarded port: the > Codespace runs `deploy/codespace/tunnel_agent.py`, which dials out to `POST /tunnel/agent` (long-poll) > and executes against `http://127.0.0.1:8000` locally. When the Codespace is stopped the agent stops > polling → `GET /api/health` reports `tunnel.agent_connected:false` and `POST /api/infer` parks until > `SATQUERY_TUNNEL_TIMEOUT_S` (150 s), then returns `tunnel_offline` (503, `recoverable:true`). The live topology is recorded as `Cloudflare Pages → Render → outbound tunnel → GitHub Codespace` (`CURRENT_RELEASE_STATE.md` §2), and the forwarded-port path is recorded as **dead** — *"the forwarded port returns 302 for a private repo"*. ### 9.2 What the inversion buys `deploy/codespace/launch.sh` states the property in its header comment, quoted in `docs/architecture/02-deployment-topology.md` §3.2: > *"The tunnel is why this works with a PRIVATE repository: the agent makes only outbound HTTPS calls, so > GitHub's port-forwarding relay, port visibility and the repository's visibility are all irrelevant. The > orchestrator never dials into this Codespace."* Consequences, each observable (`docs/architecture/02-deployment-topology.md` §3.2): | Property | Value under the tunnel | |---|---| | Repository visibility | irrelevant — only outbound HTTPS is used | | Port visibility setting | irrelevant | | **Inbound firewall / NAT** | **no inbound connection is required at all** | | Who initiates | the **Codespace**, to `SATQUERY_HUB_URL` | | What the hub needs | a long-poll endpoint and a way to match a response to a pending request | ### 9.3 The direction, as a diagram ```mermaid sequenceDiagram autonumber participant CF as "Cloudflare Pages" participant R as "Render hub" participant TA as "Codespace tunnel agent" participant API as "FastAPI :8000" Note over TA,R: startup — agent dials OUT TA->>R: POST /tunnel/agent (announce, long-poll) R-->>TA: (holds the poll open) CF->>R: POST /api/infer R->>TA: deliver request on the open poll TA->>API: POST http://127.0.0.1:8000/v1/analyze API-->>TA: ResultEnvelope TA-->>R: response R-->>CF: envelope + X-SatQuery-State ``` ### 9.4 The honest gap: the agent's internals are not in this repository The security-relevant statement above — "no inbound connection is required" — is grounded in `launch.sh`'s header comment and in the measured live behaviour, **not** in a reading of the agent's source. The agent is **not present in the monorepo working tree** and is **not tracked by git** (`docs/architecture/02-deployment-topology.md` §3.3 and §9.4): | Question | Answer | Status | |---|---|---| | Is `deploy/codespace/tunnel_agent.py` in the monorepo working tree? | **No** — `Glob **/tunnel_agent*` finds nothing | `MEASURED` | | Where does it exist? | `Anish-lab-blip/SatQuery-Inference` (private) | `VERIFIED` | | What *is* established about it? | it imports `httpx`; it dials `SATQUERY_HUB_URL`; it executes against `http://127.0.0.1:8000`; it logs `announced to hub`; it is supervised by `launch.sh` | `VERIFIED` (from `launch.sh` comments and greps) | Therefore the agent's **function names, arguments and payload shapes** — and any authentication the tunnel handshake performs between the agent and the hub — are `UNKNOWN — not established from the available evidence`. What the evidence establishes is the direction (outbound), the local target (`127.0.0.1:8000`), the transport (HTTPS long-poll) and the observable health signal (`agent_connected`). ### 9.5 The tunnel is also a resilience mechanism, and its failure is honest Because the Codespace may be stopped when idle, the deployment has a cold-start story, and it is *documented rather than hidden* (`docs/DEPLOYMENT_TOPOLOGY.md` §2): *"Cold start is therefore tens of seconds and is **documented, not hidden**. Observed warm state: `agent_connected:true`, `completed:314`."* A tunnel gap is a known, open item. **B-07** — *"Transient tunnel-agent gaps → a request can hang or return 504. Patch prepared, **NOT deployed**"* — is `OPEN` (`CURRENT_RELEASE_STATE.md` §6). The measured root shape is recorded too: *"in `auto` mode a tunnel timeout **falls through** to the forward path (`main.py:546`), burning `wake_timeout_s=120` on a 302 (~249 s ≈ 150+120)."* The client-facing mitigation is an actionable retry message, and the response is `tunnel_offline` / `503` with `recoverable: true`. **The transport is observable from the client.** The frontend treats a response header as evidence that the hub forwarded to the Codespace rather than answering locally (`docs/architecture/02-deployment-topology.md` §3.4): `frontend/assets/js/live.js` reads `X-SatQuery-State`, set by `deploy/render/main.py::infer` to `"waking"` or `"ready"`. The live validation recorded `x-satquery-transport: tunnel` on the error-contract probe (`docs/FINAL_DELIVERY_REPORT.md` §3, P3). --- ## 10. What is explicitly NOT defended against This section is the most important in the document. Each row is a property the system does **not** claim, and where possible it names what the consequence would be. ### 10.1 No adversarial-input hardening | Not defended | Consequence | Evidence | |---|---|---| | **Adversarial images** (perturbation, steganographic payloads, malformed-but-parseable rasters) | the raster reader and the models process them; there is no adversarial-robustness layer and no claim of one | no such module exists; `docs/LIMITATIONS.md` | | **Adversarial text** (prompt injection into the query) | the router is a closed-ontology classifier over six task classes and the planner is deterministic, so a query cannot select an arbitrary tool — but this is a **design property**, not a hardening claim, and it is `UNKNOWN — not established from the available evidence` whether it has been adversarially tested | `configs/base.yaml` `router.tasks`; `docs/MASTER_ARCHITECTURE_PLAN.md` §28 | | **Content sanitisation beyond validation** | the upload path validates **size** and **content type**; it does *not* decode, inspect or sanitise the bytes | `gateway/assets.py`: *"It does not decode, validate, or inspect the image. A handle means 'these bytes were accepted under this content type at this time'"* | | **Decompression-bomb protection** | the pixel budget (`image.max_pixels: 25000000`) is the only geometric bound; whether a small, highly-compressed raster that expands past the budget is refused *before* decoding is `UNKNOWN — not established from the available evidence` | `configs/base.yaml` | | **Malicious `force_task` probing** | `force_task` bypasses the router by design (`docs/API_CONTRACT.md` §2.4); it does not bypass input validation, but it does let a caller select any served capability | `gateway/policy.py::validate_analyze_body` | ### 10.2 No rate-limit-based DoS protection claim **The system does not claim to be protected against denial of service.** The limiter is a fairness control (§5), and the owner ruling is explicit: *"**Do not size abuse protection on this limiter.**"* A caller willing to vary one header produces no `429` at all (§5.3). The gateway's request-side boundary covers **shape, size and content type** — *"Rate is not one of them."* ### 10.3 No content sanitisation beyond validation The upload path accepts five declared content types and a byte cap. It stores the bytes and hands back a handle. It does not scan, transcode, strip metadata or re-encode. The stored bytes are read later by the raster reader inside the inference host, and any GeoTIFF metadata (including embedded geolocation) is preserved by design — that preservation is a *feature* (the geospatial contract), not a sanitisation gap, but it is worth naming as a property: **uploaded metadata is not stripped.** ### 10.4 No audit log | Not present | Consequence | |---|---| | An audit log of who did what | there is no "who" — no auth, no users (§1.3) | | A per-request persistent record | the gateway is stateless (`docs/DEPLOYMENT_ARCHITECTURE.md` §2.2) and the inference host's only retained state is the ephemeral asset store (§6) | | Log retention policy | `*.log` is gitignored (`.gitignore`); server logs are the platform's | | An intrusion-detection signal | none; a `429` is *"a fairness signal, not a security signal, and its ABSENCE is not evidence that no abuse occurred"* (§5.1) | The gateway does log transport failures (`_log.error(..., exc_info=exc)`, §8.7) and the inference host logs invalid requests and unhandled failures (`satquery.space` logger, §8.4). Those are **operational** diagnostics, not an audit trail: they are not retained by the application, not queryable, and not correlated to an identity. ### 10.5 No secrets management beyond environment variables Credentials live in the platform's environment (`GITHUB_TOKEN` on Render; §2.3). There is no secret manager, no rotation policy, no vault, and no per-request credential scoping. The single mitigation is that the credential is **custodied at one tier and reported as a boolean** (§2.2, §2.6). ### 10.6 No cross-tenant isolation There is no tenancy. `AssetStore` is a single process-wide store with a bounded capacity of 32 handles and a 15-minute TTL. The handle *is* the isolation (§6.3): unguessable, so one client cannot reach another's upload — but there is no ownership check, because there is no owner. If two clients hold each other's handles (e.g. through a leak outside this system), each can use the other's upload until it expires. ### 10.7 No CI-verified security scanning The release tooling includes manifest and checksum generators (`tools/generate_release_manifest.py`, `tools/verify_archive.py`, `tools/hf_verify.py`) but **no security scanner, dependency auditor or secret scanner is configured in a CI pipeline**, and no CI configuration was verified for this release. The secret discipline in §11 was enforced by an **ad-hoc scan performed during the release**, not by a standing pipeline. Whether a CI pipeline exists in the private deployment repositories is `UNKNOWN — not established from the available evidence`. ### 10.8 Summary table | Control | Claim | Status | |---|---|---| | CORS allowlist | enforced on request **and** response legs | `VERIFIED` (§3) | | Body-size cap | enforced while reading, at both layers | `VERIFIED` (§4.4) | | Per-file cap | one variable, two strict parsers, startup-validated | `VERIFIED` (§4.5) | | Content-type allowlist | closed list of five; absent type refused | `VERIFIED` (§4.6) | | Pixel/tile budget | frozen in config | `IMPLEMENTED` (deployment enforcement `UNKNOWN`, §4.3) | | Per-IP rate limit | fairness only | `MEASURED` (§5) | | Upload fail-closed | `503` unless both variables set | `VERIFIED` (§6.1) | | Handle opacity | 128-bit `secrets.token_hex(16)` | `VERIFIED` (§6.3) | | Path disclosure in errors/trace | scrubbed to basename | `VERIFIED` (§8.4, §8.5) | | No inbound path to inference host | outbound tunnel | `MEASURED` (§9) | | Authentication | **absent by design** | `REJECTED` (§1.3) | | DoS protection | **not claimed** | `REJECTED` (§10.2) | | Audit log | **not present** | `OPEN` (§10.4) | | CI security scanning | **not configured** | `NOT RUN` (§10.7) | --- ## 11. Release-side security discipline: no secrets in any published file ### 11.1 The rule > **NO SECRETS in any published file.** The release publishes to a public GitHub repository (`Anish-lab-blip/SatQuery-AI`) and a public Hugging Face model repository (`thundercode/SatQuery`). Publishing changes the threat model: a credential in a published file is a credential disclosed, and a documentation set written from the inside is the most likely place for one to appear by accident. The release manifest states the exclusion as a property of the artifact set (`RELEASE_MANIFEST.md` §"What this release deliberately does NOT contain"): > - **Backbone weights.** They are fetched from the Hugging Face Hub, pinned by revision. > - **Secrets.** No tokens, keys, or environment files. > - **The private deployment repositories.** Their sources are not published here. > - **Datasets.** Acquisition procedures are documented; the data is not redistributed. > - **A licence file.** None has been selected yet — this is an OPEN item. The manifest is **generated from disk, never typed** (`RELEASE_MANIFEST.md` header: *"Generator: `release/tools/generate_release_manifest.py` (computed from disk, never typed)"*), and it lists all **42** files with their byte sizes and sha256 digests — so a reviewer can verify the published set independently rather than trusting a description of it. ### 11.2 The scan that enforces it The discipline is enforced and verified by a **regex scan across every markdown file** in the release. The scan looks for credential-shaped strings — the Hugging Face token prefix (`hf_…`), GitHub personal-access token prefixes (`ghp_…`, `github_pat_…`), and the standard provider-key shapes — and reports any match as a release blocker. The scan is a **release-time gate**, not a runtime control: its scope is the published file set, and it is run against the files as they will be published. **What this establishes:** no file in the published set contains a credential-shaped string. **What it does not establish:** that no secret is *derivable* from the published set (e.g. a non-secret value that narrows a credential search space), and it does not scan the private deployment repositories, which are not published. Those are `UNKNOWN — not established from the available evidence`. ### 11.3 The Hugging Face release's own secret statement `HF_RELEASE_VERIFICATION.md` §8 records the same property for the model repository, and it names the token handling explicitly: > **No secret was uploaded.** The uploaded set is: the model card, the manifest, the checksums, the 11 > docs, and the six weight files. No tokens, keys, environment files, or credentials exist in any uploaded > file. The token used for the upload is **not** written into any released file. The verification document also records the write-permission check that preceded the upload, which read the token's own scopes from `GET /api/whoami-v2` and confirmed `repo.write` scoped to the user — *"Verified **before** uploading"* — rather than discovering a permission problem mid-upload. ### 11.4 The temporary token file used during the push was deleted The release process used a temporary token file to authenticate the push. That file was **deleted** after the push completed, and it is not part of the published set. This matters because a token file left on disk in a directory that a later `git add -A` (or an archive build) could sweep up is a common accidental disclosure — and the release's archive tooling excludes secret/token files explicitly (`docs/REPRODUCIBILITY.md` §10: *"The archive **excludes** secret/token files, OS junk, virtualenvs"*). **Honest limit:** the deletion is a **release-process fact**, and the published evidence for it is this statement rather than a scanner report — no file named in the manifest is a token file, which is consistent with the deletion, but the manifest cannot prove a negative about a file that no longer exists. The strongest available evidence is that (a) the scan in §11.2 finds no credential-shaped string in any published file, and (b) the 42-file manifest contains no environment file, no `.env`, and no credential file. ### 11.5 This document's own discipline This document obeys the same rule it describes. It contains: - **no credential, token, key or password, and no path to a credential file** — every secret is described by *where it lives*, never by its value (the pattern stated in `docs/architecture/02-deployment-topology.md` §2.7: *"Every secret is described by *where it lives*, never by its value."*); - **no absolute filesystem path** from the authoring host — the paths quoted in §8 are the ones the project's own documents quote as *examples of what was scrubbed*, and they are reproduced here as evidence of the defect, not as current configuration; - **no internal hostname beyond the publicly reachable endpoints** already published in `CURRENT_RELEASE_STATE.md` and `docs/DEPLOYMENT.md` (the Cloudflare Pages origin and the Render host). The one place where a reader might expect a value and find none is the live configuration (§2.3): the document names the **variables** that are set and reports the token as a boolean, because that is exactly what the live health endpoint publishes. ### 11.6 What the release does NOT include, and why | Excluded | Reason | Evidence | |---|---|---| | Backbone weights (MiniLM, SmolVLM, RemoteCLIP, CROMA) | fetched from the Hub, pinned by revision | `RELEASE_MANIFEST.md`; `configs/base.yaml` revisions | | Secrets / environment files | §11.1 | `RELEASE_MANIFEST.md` | | Private deployment repositories | their sources are not published | `RELEASE_MANIFEST.md` | | Datasets | acquisition documented, data not redistributed | `RELEASE_MANIFEST.md`; `docs/DATASETS.md` | | A `LICENSE` file | none selected yet — **OPEN** | `RELEASE_MANIFEST.md`; `CURRENT_RELEASE_STATE.md` §6 | The **no-LICENSE** item is `OPEN` and is recorded in three places (`RELEASE_MANIFEST.md`, `CURRENT_RELEASE_STATE.md` §6, and the style guide's facts table). It is a **legal** rather than a security item, but it is named here because a public release with no licence is an ambiguity about what others may do with the code, and a reader of a security chapter should not have to discover it elsewhere. --- ## 12. Reporting a vulnerability SatQuery AI is a research prototype released publicly, with **no authentication and no user data**. The most useful reports are those that identify a path by which a client can reach something the contract says it cannot — for example a filesystem path, a credential, an internal host, or another client's upload. ### 12.1 The contact **Security contact: `UNKNOWN — not established from the available evidence`.** **This section requires an owner-supplied contact address.** No security contact, disclosure policy or `SECURITY.md` contact has been established for this project, and inventing one would be exactly the kind of fabrication the release's grounding rules forbid. Until an owner supplies a contact, the honest statement is that **there is no published vulnerability-reporting channel**, and the recommendation is: > **Owner action required.** Provide a security contact (a dedicated address or an issue-tracker URL) and > a disclosure expectation. This document will be updated to name it. Until then, treat the row above as > the accurate state: no channel is published. ### 12.2 What a report should contain, once a channel exists The reporting structure below is a recommendation grounded in the artefacts this document has described — each item maps to a piece of evidence the maintainer can reproduce: | Field | What to provide | Why it maps to a real artefact | |---|---|---| | Affected tier | frontend / orchestrator / inference host / model host | the four tiers are distinct deployables (§1.1) | | Endpoint and method | e.g. `POST /api/infer` | the orchestrator route list is published (`docs/DEPLOYMENT_TOPOLOGY.md` §3.2) | | `request_id` | the value from the error envelope | the gateway generates and echoes `X-Request-Id` and the envelope carries `request_id` (`docs/API_CONTRACT.md` §5) | | `run_id` | the value from the response, if the request succeeded | the server always echoes a `run_id` (`docs/API_CONTRACT.md` §2.4) | | Error `code` | the value from the envelope | the taxonomy is closed and published (`docs/API_CONTRACT.md` §5.2) | | Reproduction | minimal steps, with the request body | — | | Expected vs observed | what the contract promises vs what happened | `docs/API_CONTRACT.md` is the contract | The `request_id` is the single most useful field: it is the correlation key the gateway generates, injects and echoes (§7.6), and it is present in the log line the gateway writes for a transport failure (§8.7). ### 12.3 What is already known and documented A reporter should read the following before filing, because each is a **documented, accepted limitation** rather than a new finding: | Documented limitation | Where | |---|---| | The rate limiter is a fairness control, not a security control; a varied `X-Forwarded-For` produces no `429` | §5; `docs/DEPLOYMENT_ARCHITECTURE.md` §5.2 | | The body cap's `Content-Length` check is declarative; the streaming check is the enforced one | §4.4; `docs/DEPLOYMENT_ARCHITECTURE.md` §4 | | A misconfigured asset root answers `500 text/plain` on the inference host's own URL | §6.10; `docs/DEPLOYMENT_ARCHITECTURE.md` §5.1.1 | | `change_vqa.artifact_dir` is a configured-but-inert key | §13 (F-17); `docs/DEPLOYMENT_ARCHITECTURE.md` §5.6 | | The trace's registry block is a snapshot of the *previous* request | §13 (F-19); `docs/DEPLOYMENT_ARCHITECTURE.md` §5.8 | | B-07: transient tunnel gaps; patch prepared, not deployed | §9.5; `CURRENT_RELEASE_STATE.md` §6 | | No auth, by design | §1.3; `docs/API_CONTRACT.md` §7 | --- ## 13. Open, blocked and not-run items Every item below is a security-relevant state that is **not closed**. None is presented as fixed. | ID | Item | State | Detail | |---|---|---|---| | **B-07** | Transient tunnel-agent gaps → a request can hang or return `504` (`tunnel_offline` / wake timeout) | **OPEN** | patch **prepared, NOT deployed**; measured root shape: in `auto` mode a tunnel timeout falls through to the forward path, burning `wake_timeout_s=120` on a `302` (`CURRENT_RELEASE_STATE.md` §6) | | **B-02** | `/api/health` `codespace_name` carries a trailing `\n` | **OPEN (cosmetic)** | the wake path strips it (`_codespace_name()`); only the health payload reports the raw value; confirmed still live 2026-09-25 | | **Licence** | No `LICENSE` file exists | **OPEN** | `RELEASE_MANIFEST.md`; `CURRENT_RELEASE_STATE.md` §6 | | **F-17** | `change_vqa.artifact_dir` is a configured-but-inert key — advertised, never read, no warning | **OPEN** | severity low; *"documented, not patched"*; two independent checks agree (the attribute occurs once as an assignment; the module contains no file-writing code) — `docs/DEPLOYMENT_ARCHITECTURE.md` §5.6 | | **F-19** | The trace's registry block is a snapshot of the *previous* request | **OPEN** | observability only; no result, disclosure or availability claim depends on it; *"documented, not patched. No guard added"* — `docs/DEPLOYMENT_ARCHITECTURE.md` §5.8 | | **F-12 / F-12b** | A misconfigured asset root, and framework 404/405, answer non-envelope bodies **on the inference host's own URL** | **OPEN (bounded)** | *"a client that goes through the gateway never sees it"*; *"Apply the fix with the deployment work, not before it"* — `docs/DEPLOYMENT_ARCHITECTURE.md` §5.1.1 | | **F-16c** | A no-change run on a deployment that configures `change.artifact_dir` now reports `degraded: true` | **OPEN** | *"a signalling change, not a disclosure"*; the shipped configuration is unaffected; seam `core/schemas.py::_task_output_consistency` — `docs/DEPLOYMENT_ARCHITECTURE.md` §5.5 | | **F-11** | The two `503` causes on `/v1/assets` (unconfigured vs full) remain indistinguishable | **OPEN** | the diagnostic that would have separated them was removed because nothing read it — `docs/DEPLOYMENT_ARCHITECTURE.md` §5.1 | | Security contact | No published vulnerability-reporting channel | **OPEN** | §12.1 — requires an owner-supplied contact | | CI security scanning | No scanner or auditor in a verified pipeline | **NOT RUN** | §10.7 | | Adversarial-input testing | None performed | **NOT RUN** | §10.1 | | Audit log | Not present | **OPEN** | §10.4 | | Pixel-budget enforcement on the deployed host | Not probed | **UNKNOWN** | §4.3 | ### 13.1 Items recorded as RESOLVED, so the fix is not re-litigated | ID | Item | State | |---|---|---| | F-2 | CORS response-leg bypass | **RESOLVED** (2026-09-22) — prefix filter + pinned assertion (§3.5) | | F-3 | Framework 404/405 lacked the envelope on the gateway | **RESOLVED** (2026-09-22) (§8.3) | | F-5 | Rate limiter is not a protection control | **RESOLVED** (ruled 2026-09-23) — documentation ruling; no code change (§5) | | F-6 | Gateway body cap was declarative | **RESOLVED** — streaming reader (§4.4) | | F-7 | One variable, two parsers | **RESOLVED** — both refuse non-positive/unparsable, naming the variable (§4.5) | | F-9 | Inference host buffered the whole body before the cap | **RESOLVED** — shared reader (§4.4) | | F-13 | `trace.inputs` echoed a path | **RESOLVED** — `_asset_label` basename reduction (§8.5) | | F-14 | `trace.steps[PARSE].detail["inputs"]` echoed the same path | **RESOLVED** — one-line reuse of `_asset_label` (§8.5) | | F-15 | A construction failure's exception string reached client fields | **RESOLVED** — `scrub_paths` basename reduction at four carriers (§8.4) | | F-15c | A transport failure's raw exception text was published | **RESOLVED** — MRO-classification mapping (§8.7) | | F-16 | `artifact_ref` carried a filesystem path where the contract promised a URI | **RESOLVED** — `null` + non-retrievable warning, **both** carriers (§8.6) | | F-18 | §5.3–§5.8 sat after §8 | **RESOLVED** (2026-09-23) — sections moved; ordering frozen | | F-11 | `AssetStore.stats()` and its counters were computed and read by nothing | **RESOLVED** — removed rather than given a consumer; **the ambiguity it would have separated remains** (§6.2) | --- ## 14. Evidence index ### 14.1 Source files read for this chapter | File | What it establishes here | |---|---| | `gateway/app.py` | the ASGI layer: allowlists, `_proxy`, the CORS assertion, transport classification, `_read_body_bounded` | | `gateway/policy.py` | `GatewayConfig` validation, the admit ladder, `build_cors_headers`, `RateLimiter`, `translate_error`, `validate_analyze_body`, `response_headers` | | `gateway/assets.py` | the ephemeral store: `read_body_bounded`, opaque handles, `_SUFFIXES`, `put`/`get`/`sweep`, `to_response` | | `app/space_app.py` | the inference host: `_asset_store_available`, the `503` refusal, the handle→path translation, the exception handlers | | `deploy/render/main.py` | the orchestrator: `_allowed_origins`, `_DEV_ORIGINS`, `_PRODUCTION_ORIGINS`, the health payload, `_proxy` | | `deploy/render/codespaces.py` | the GitHub Codespaces API client (`GITHUB_TOKEN` as a Bearer token) | | `core/errors.py` | the 23-code taxonomy, `SatQueryError`, `scrub_paths` | | `configs/base.yaml` | `image.max_pixels: 25000000`, `image.max_tiles: 64`, the frozen task list, `agent.timeout_seconds: 120` | | `.gitignore` | the credential-shaped exclusions (`kaggle.json`, `.kaggle/`, `.deploy/`, `*.log`, weights, data) | | `docs/DEPLOYMENT_ARCHITECTURE.md` | §1.1 the boundary argument; §2 the responsibility table and `must NOT do` list; §2.3 error translation; §3.3 entrypoint requirements; §4 the env-var vocabulary and the F-6 note; §5 the failure-mode table and §5.1–§5.8 findings | | `docs/API_CONTRACT.md` | §2.4 the request shape; §2.5 the upload endpoint and its three guarantees; §5 the envelope and status map; §5.2 the taxonomy; §5.3 the gateway-origin code; §6 quotas; §7 authentication; §8 status | | `docs/DEPLOYMENT_TOPOLOGY.md` | the measured live configuration (header note); §2 the tunnel; §3 the per-tier responsibilities; §4 the closed blockers; §5 the reconciliation | | `docs/FINAL_DELIVERY_REPORT.md` | the deployed topology and the `deploy/`-is-stale warning | | `.workbuddy-ai/scratch/live_validation/LIVE_VALIDATION_POSTFIX.md` | the three live passes (behavioural evidence, §9.5 context) | ### 14.2 Release documents this chapter links to | Document | Role | |---|---| | [`architecture/02-deployment-topology.md`](architecture/02-deployment-topology.md) | the deployment topology and the backend-contract reference (§1.1, §2, §3, §4, §5 of `DEPLOYMENT_ARCHITECTURE.md`) | | [`architecture/08-api-contract.md`](architecture/08-api-contract.md) | the deep API contract (the `/v1/*` surface, the envelope, the taxonomy) | | [`architecture/10-observability-and-ops.md`](architecture/10-observability-and-ops.md) | failure modes, the wake flow, operator procedures | | [`DEPLOYMENT.md`](DEPLOYMENT.md) | the deployment guide and the platform traps | | [`LIMITATIONS.md`](LIMITATIONS.md) | the consolidated limitation list (B-07, B-02, the licence item) | | [`EVALUATION.md`](EVALUATION.md) | the live-validation record and the model-quality verdicts | | [`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) | how to reproduce the tests and a live run; the archive exclusions | | [`architecture/07-configuration-freeze.md`](architecture/07-configuration-freeze.md) | the frozen config and `Config.hash == 78f1e3700da15aa1` | ### 14.3 Where the security evidence physically lives | Evidence | Location | |---|---| | The three live validation passes (raw + recomputed) | `.workbuddy-ai/scratch/live_validation/` — `run_output.txt`, `run_final2.txt`, `run_final3.txt`, `results_final.json`, `results_pass3.json`, `LIVE_VALIDATION_POSTFIX.md` | | The verdict recomputation tool | `.workbuddy-ai/scratch/recompute_verdicts.py` | | The deployed-head verification | `.workbuddy-ai/scratch/verify_deployed_head.py` | | The undeployed B-07 patch | `.workbuddy-ai/scratch/deployed-backend/fix-b07-forward-unavailable.patch` | | The release manifest and digests | `RELEASE_MANIFEST.md` (42 files, sha256 per file) | | The Hugging Face verification | `HF_RELEASE_VERIFICATION.md`; `tools/hf_verify_report.txt` | ### 14.4 Final statement of posture SatQuery AI v1 is a **public, unauthenticated research prototype**. Its security posture is narrow and deliberate: 1. **One credential, at one tier, never sent to a browser.** The gateway holds it; the health endpoint reports it as a boolean (§2). 2. **A hard CORS allowlist, enforced on both legs.** Never `*`; refused at startup if misconfigured (§3). 3. **Size and content-type limits that are enforced, not declared.** A streaming reader at both layers bounds memory regardless of what the client claims (§4). 4. **A rate limiter that is honestly labelled a fairness control**, because the measurement shows it can be defeated by one header (§5). 5. **An upload endpoint that fails closed**, whose only access control is an unguessable handle (§6). 6. **An error contract that publishes classifications, not internals** — no paths, no stack traces, no third-party exception text (§8). 7. **No inbound path to the inference host**, because the transport dials out (§9). 8. **No claim of protection against adversarial input, denial of service, or abuse**, because no such control exists (§10). The gaps in §13 are the honest boundary of that posture. Where the project could not establish a fact, this document says so rather than filling it in.