Skip to content

P0: Add security & deployment hardening guide (#168) - #186

Open
dkijania wants to merge 2 commits into
mainfrom
docs/security-deployment
Open

P0: Add security & deployment hardening guide (#168)#186
dkijania wants to merge 2 commits into
mainfrom
docs/security-deployment

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

What & why

Part of the production-readiness epic (#163). Closes #168.

The repo had no single place describing how to expose the API safely. This adds docs/security.md documenting the intended posture, chosen for this service: a public, read-only GraphQL API meant to run behind a TLS-terminating gateway against a read-only Postgres role, with no application-level auth (per-caller gating, if needed, is an operator concern at the gateway — the app stays simple).

Contents

  • Security model — public read-only; data isn't secret, availability is the asset to protect.
  • Network architecture — TLS at the gateway, X-Forwarded-For for per-client rate limiting, Postgres kept private (with a diagram).
  • Built-in protections — summary table of rate limiting, query-cost limits, statement timeout/pool limits, CORS, introspection-off, field-suggestion blocking.
  • Least-privilege DB access — a ready-to-run archive_api_ro read-only role (SQL included).
  • Operational practices + a deployment checklist.

Linked from the README (new "Security & production deployment" section) and the setup guide's "Where to go next".

Note on sequencing

The "Built-in protections" table describes controls delivered by the sibling P0 PRs (#164 query-cost, #165 PG timeouts, #166 rate limiting, #167 CORS). This doc is best merged after / alongside those so every protection it references is present on main. Cross-doc links only target getting-started.md#configuration, which already exists on main, so the doc has no hard dependency on merge order.

Testing

Docs-only. npx prettier --debug-check . exits 0; npm run lint clean. Internal links verified against existing anchors.

🤖 Generated with Claude Code

The repo had no single place describing how to expose the API safely. Document
the intended posture: a public, read-only GraphQL service meant to run behind a
TLS-terminating gateway against a read-only Postgres role, with no
application-level auth (gating, if needed, is an operator concern at the
gateway).

Adds docs/security.md covering the security model, network architecture
(TLS gateway, X-Forwarded-For, private Postgres), the built-in abuse
protections, a least-privilege read-only DB role (with SQL), operational
practices, and a deployment checklist. Linked from the README and the setup
guide.

Closes #168.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
@dkijania dkijania added documentation Improvements or additions to documentation production-readiness Work toward making the API production-ready / publicly available P0 Blocker for public availability labels Jun 28, 2026
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Thanks for writing this — a single opinionated "how to expose this safely" doc is exactly what the epic needs, and the read-only archive_api_ro role + TLS-gateway diagram are great.

One correctness issue in the Built-in protections table is worth fixing before this lands, because it states the opposite of today's behavior and could break the mina-explorer.

CORS default isn't "same-origin only". On main, src/server/server.ts sets origin: process.env.CORS_ORIGIN ?? '*' (and docs/getting-started.md's config table lists CORS_ORIGIN default *), so cross-origin browser access is fully open by default — not opt-in. As written, an operator would believe they're locked down when they aren't.

That feeds a backwards-compat risk with the mina-explorer: it's a browser app served from its own origin that POSTs application/json to the archive on a different origin (e.g. https://archive-node-api.gcp.o1test.net — see mina-explorer/src/config/networks.ts and src/services/api/client.ts), so it depends on a permissive Access-Control-Allow-Origin. The checklist line

[ ] CORS_ORIGIN set to an explicit allowlist (or left unset) — not *

would, if followed, block every browser Explorer/dashboard instance. Could we add a note, e.g.:

If you serve any cross-origin browser client (the mina-explorer, custom dashboards), its web origin must be listed in CORS_ORIGIN. For a genuinely public read API that any browser may call, CORS_ORIGIN=* is the correct choice — restricting it only suits deployments with a known, fixed set of front-ends.

Two smaller things:

Thanks again — happy to help refine the CORS wording.

Three corrections, all of which would have misled operators:

CORS. The checklist told operators to set an allowlist "or leave unset —
not *", which would block every cross-origin browser client, the
mina-explorer included, with no server-side symptom. For a public
read-only API over already-public data, CORS_ORIGIN=* is the correct
setting rather than a lapse: CORS constrains browsers, not curl, so it
is not an access control. Adds a section making the choice explicit and
notes these controls arrive in 1.0.0 — on 0.0.x, CORS_ORIGIN defaults to
'*', so the protections table describes a version most operators are not
yet running.

TRUST_PROXY. The doc described X-Forwarded-For as read "first hop",
which is the behaviour removed in #185 as a rate-limit bypass. Documents
the hop-count model and the deny-by-default reading instead.

Read replicas. The README claimed the server "fans queries across"
multiple PG_CONN hosts. It does not: postgres.js scopes hostIndex per
Connection (src/connection.js:89), so every pooled connection starts at
host[0] and only advances on failure — failover, not fan-out. As written
it promised read scaling that adding replicas cannot deliver.

Addresses review feedback on #186.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dkijania

Copy link
Copy Markdown
Contributor Author

Thanks @SanabriaRusso — fixed in 4fbb7ae.

CORS. You were right that the checklist was the dangerous part: CORS_ORIGIN set to an explicit allowlist (or left unset) — not * would have blocked every browser client, silently. Took your framing that * is the correct choice for a genuinely public read API rather than a hardening lapse, and added a "CORS and browser clients" section making the decision explicit — including the point that CORS constrains browsers, not curl, so it isn't an access control at all. The checklist line now names both cases instead of prescribing the wrong one.

On the "reads as current" problem — rather than a caveat naming in-flight PRs (which we'd have to remember to delete), I pinned it to a version: the table now says these controls arrive in 1.0.0, and that 0.0.x defaults CORS_ORIGIN to *. That stays true after the merge train lands and is arguably more useful, since the published image today is 0.0.6 — an operator reading this on main is likely running exactly the version where none of it applies. Merge order still puts this doc after #164#167 per the wave plan.

Introspection. Left the "keep it off" advice as-is: #193 lands in wave 1, well ahead of this, so ENABLE_INTROSPECTION=false will do what it says by the time anyone reads this.

Two things you didn't flag, found while in here:

  1. The doc described X-Forwarded-For as read first hop — which is exactly the bypass you caught in P0: Add per-IP request rate limiting (#166) #185. That's now the TRUST_PROXY hop-count model, so the two PRs don't contradict each other.

  2. The README claimed the server "fans queries across" multiple PG_CONN hosts — the same false throughput claim you flagged in P2: Operations runbook — SLOs, capacity, incidents, failover (#180) #197's Scaling section, except this one is already on main. I checked the driver rather than take either of us on faith, and you're right, with a specific mechanism: postgres.js declares hostIndex inside function Connection(...) (src/connection.js:89), so it's per-connection state reset to 0 for every pooled connection. Every connection starts at host[0] and only advances when that connection's attempt fails — failover, not fan-out. The README was promising read scaling that adding replicas cannot deliver, so I've corrected it here and pointed at a real balancer instead. Same fix applied in P2: Reference deployment manifests — k8s + prod Compose (#179) #196.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation P0 Blocker for public availability production-readiness Work toward making the API production-ready / publicly available

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P0: Decide & document auth / TLS story

2 participants