Skip to content

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

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

P0: Add security & deployment hardening guide (#168)#186
dkijania wants to merge 4 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

@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.

dkijania added a commit that referenced this pull request Jul 17, 2026
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.

@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Verdict: MERGEABLE

Docs-only (README.md, docs/getting-started.md, docs/security.md), no runtime surface, and the CORS point @SanabriaRusso raised has landed correctly. I re-verified every concrete claim against code rather than the description.

What I checked

  • CORS guidance vs. both code states — correct. Table row same-origin only + the > These controls arrive in 1.0.0 … 0.0.x defaults CORS_ORIGIN to * caveat matches today's src/server/server.ts:24 (process.env.CORS_ORIGIN ?? '*') and P0: Secure CORS default instead of '*' (#167) #184's resolveCorsOptions (src/server/cors.ts, unset → false). The new "CORS and browser clients" section explicitly names CORS_ORIGIN=* as the right setting for a public read API and the checklist line now covers both cases — so an operator following this does not black-hole mina-explorer, which POSTs cross-origin from src/config/networks.ts origins with Content-Type: application/json (preflighted). Maintainer's point addressed.
  • TRUST_PROXY semantics match P0: Add per-IP request rate limiting (#166) #185 exactly — hop count read from the right of X-Forwarded-For, default 0, higher-than-actual falls back to socket address. Verbatim consistent with resolveRateLimitConfig / the warn path in P0: Add per-IP request rate limiting (#166) #185. This is the downstream-relevant instruction and the doc gets it right: at TRUST_PROXY=0 behind an LB every caller collapses into one 600/60s bucket, which is what would actually throttle the explorer fleet + mina-explorer-api's ~4 req/s crawl.
  • Every documented env var exists with that exact spelling: PORT, PG_CONN (src/index.ts:7,11), CORS_ORIGIN, ENABLE_GRAPHIQL (src/server/server.ts:21,24), ENABLE_INTROSPECTION (src/server/plugins.ts:30), TRUST_PROXY (P0: Add per-IP request rate limiting (#166) #185). No invented names, no wrong defaults. ?sslmode=require is real — postgres.js maps it at node_modules/postgres/src/index.js:442.
  • Introspection-off is safe for both consumers. useDisableIntrospection adds NoSchemaIntrospectionCustomRule, which still permits __typename, so mina-explorer-api's readiness probe (app/observability.py:307, { __typename }) keeps working.
  • README failover correction is right. hostIndex is declared inside function Connection(...) (node_modules/postgres/src/connection.js:89) and only advances on that connection's own retry (:349), so multi-host PG_CONN is failover, not fan-out. Good catch, and it removes a false capacity claim from main.
  • No secrets / real infra. SQL uses PASSWORD 'change-me'; no internal hostnames beyond public issue links.
  • No contradiction with the sibling docs PRs. P2: Reference deployment manifests — k8s + prod Compose (#179) #196 sets CORS_ORIGIN: '*' and TRUST_PROXY: '0' in the manifests and repeats the same hop-count advice; P2: Declare 1.0.0 + versioning & schema stability policy (#178) #198 is the bump that makes "arrives in 1.0.0" true.

Non-blocking nits

  1. ENABLE_INTROSPECTION=false still enables introspection today, and P1: Validate config at startup + fix boolean env parsing (#174, #74) #193 is still open. src/server/plugins.ts:30 is if (!process.env.ENABLE_INTROSPECTION), so any non-empty value — including the string false — is truthy and skips the disable plugin. This is the one line where an operator copy-pasting the checklist gets the opposite of what it says, and it's still true at the current head of main. Cheap insurance that survives P1: Validate config at startup + fix boolean env parsing (#174, #74) #193 landing:

    [ ] ENABLE_GRAPHIQL and ENABLE_INTROSPECTION off (unless intentionally public) — leave them unset; before 1.0.0 any non-empty value, including false, enables introspection.

  2. TRUST_PROXY is described in "Network architecture" as current behaviour, ahead of the 1.0.0 caveat that sits in the next section. One clause — "(from 1.0.0)" — closes the gap for a reader running 0.0.9.
  3. The caveat says the 1.0.0 controls "are absent or default-open" on 0.0.x; introspection-off is the exception — it's already present via plugins.ts:30. Cosmetic, and it errs conservative.
  4. For whoever implements the field-suggestion blocking row: strip only the Did you mean …? tail. Cannot query field, Unknown argument, and Unknown type are load-bearing strings for mina-explorer-api's tier fallback (app/upstream/graphql.py:32 SCHEMA_ERROR_MARKERS) and for mina-explorer's inBestChain probe (src/services/api/bestChainFilter.ts). Not this PR's problem, just don't let the doc's promise get implemented as a blanket message rewrite.

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

SanabriaRusso
SanabriaRusso previously approved these changes Aug 18, 2026

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the basis of the second-pass review comment above: no mid-to-high severity security, compatibility, or degradation issue found, and the downstream contract with mina-explorer / mina-explorer-api holds — GraphQL validation error text reaches errors[].message verbatim, the browser SPA's cross-origin access is preserved, and the real consumer query shapes (including the 2000-block analytics query and the 500-row page crawl) still pass.

Two things this approval does not mean:

  • It does not close the non-blocking items in the review comment. Several are worth fixing before or shortly after merge; they are written up there with patches.
  • It does not by itself mean the branch is ready to merge. main requires branches to be up to date, so this needs an update-branch (or a rebase, if the branch is conflicting) first, and a few PRs in this series have cross-PR ordering constraints called out in their review comments.

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

dkijania added a commit that referenced this pull request Aug 24, 2026
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
dkijania force-pushed the docs/security-deployment branch from 3e12fb8 to 8df20e5 Compare August 24, 2026 17:26

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guide is a genuine improvement and almost everything from round 1 is fixed. One factual error blocks it, because it is exactly the kind of error this document exists to prevent.

Blocker — docs/security.md:57 and :75 state the opposite of what #185 ships

docs/security.md:57 says:

TRUST_PROXY defaults to 0, which ignores the header entirely and keys on the socket address; that is correct for a directly-exposed server…

#185 (head cf9eb0fc) gives TRUST_PROXY no default. I checked src/server/rate-limit.ts directly:

trustProxyConfigured: hops !== undefined,
...
if (!config.trustProxyConfigured) {
  warn('[rate-limit] TRUST_PROXY is not set — rate limiting is DISABLED. No default is safe…');
  return {};        // <- empty plugin
}

hopsFromEnv() returns undefined for missing or malformed input, and useRateLimit() then returns a no-op plugin. Unset is not 0. Unset means no rate limiting at all.

This compounds with the protections table at :75, which lists per-IP rate limiting as Default on. So an operator running a directly-exposed server reads :57 ("the default 0 is correct for me"), reads :75 ("rate limiting is on"), leaves TRUST_PROXY unset, and ships with the abuse control this guide promises silently absent. The checklist item at :139 only asks them to set TRUST_PROXY if a gateway is present, so they will skip it too.

This is the one document in the repo whose job is to tell an operator which protections are active. Getting that backwards is worth one more round.

Fix 1 — replace docs/security.md:57-59

  `TRUST_PROXY` has **no default and no safe guess**: with `RATE_LIMIT_MAX > 0`
  and `TRUST_PROXY` unset, the server logs a warning and **rate limiting stays
  disabled**, because socket-keying behind a load balancer collapses every
  client into one bucket while trusting `X-Forwarded-For` blindly lets any
  caller mint a fresh bucket per request. Set `TRUST_PROXY=0` for a directly
  exposed server, or to the real hop count behind a gateway.

Fix 2 — replace the table row at :75

| Per-IP **rate limiting**                                        | on once `TRUST_PROXY` is set | Bounds request volume per client. Disabled (with a startup warning) while `TRUST_PROXY` is unset |

Fix 3 — replace the checklist item at :139, so a no-gateway deployment is covered too

- [ ] `TRUST_PROXY` explicitly set — `0` for a directly exposed server, or the
      real hop count behind a gateway. Rate limiting is **off** until it is set

Everything else verified good

  • The ENABLE_INTROSPECTION caveat (:119-124, :143-145) is accurate and correctly scoped to 0.0.x — confirmed against src/server/plugins.ts:30 on main (if (!process.env.ENABLE_INTROSPECTION), so the string "false" enables introspection). Correctly conditioned on #193 fixing it.
  • Hop counts (:97-99) now match #185's clientId() implementation (forwarded[forwarded.length - trustProxy]), #185's own config docs, and #197's runbook. GCP ALB = 2 in all three. This was round 1's blocker and it is resolved.
  • Every default named in the protections table matches the sibling PRs: CORS same-origin (#184), query-cost limits on with depth 12 (#183), statement timeout and pool limits on (#182 → 15000/10), introspection off, field-suggestion blocking on.
  • No real credentials anywhere; PASSWORD 'change-me' at :152 is an obvious placeholder.
  • Docs-only, so no downstream impact on mina-explorer or mina-explorer-api. The CORS_ORIGIN guidance at :131-143 explicitly names mina-explorer as a reason to choose *, which is the right advice — that consumer calls this API cross-origin from the browser and #184's secure default would otherwise block it outright.

One note for whoever picks this up

#196 deploy/README.md:46 and #197 docs/runbook.md:95 carry the same "TRUST_PROXY=0 is the default" mental model. Neither is blocking on its own (#196's manifests set the variable explicitly; #197's is a wrong incident diagnosis rather than a wrong default), and I have left the exact replacement text on both. Correcting all three together is the efficient move, since it is one underlying misconception.

dkijania added a commit that referenced this pull request Aug 24, 2026
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
dkijania force-pushed the docs/security-deployment branch from 8df20e5 to c70376a Compare August 24, 2026 22:06
dkijania added a commit that referenced this pull request Aug 26, 2026
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
dkijania force-pushed the docs/security-deployment branch from c70376a to 3268d5c Compare August 26, 2026 17:04
dkijania and others added 4 commits August 27, 2026 13:26
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
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
dkijania force-pushed the docs/security-deployment branch from 3268d5c to fc33f6b Compare August 27, 2026 11:29
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