|
| 1 | +# Security & Deployment Hardening |
| 2 | + |
| 3 | +This document describes the intended security posture of the Archive Node API |
| 4 | +and how to deploy it safely on a public network. It complements the |
| 5 | +[setup guide](./getting-started.md); read that first for installation and the |
| 6 | +full [configuration reference](./getting-started.md#configuration). |
| 7 | + |
| 8 | +## Security model |
| 9 | + |
| 10 | +The Archive Node API is a **public, read-only** GraphQL service over an existing |
| 11 | +archive-node Postgres database. It exposes already-public on-chain data (blocks, |
| 12 | +events, actions, transactions) and **never writes** to the database or the chain. |
| 13 | + |
| 14 | +Consequences of that model: |
| 15 | + |
| 16 | +- **No application-level authentication.** The API is meant to be openly |
| 17 | + queryable, the same way a block explorer's read API is. Access control, if you |
| 18 | + need it, is enforced at the gateway in front of the service (see below) — not |
| 19 | + in the app. |
| 20 | +- **The data is not secret; availability is the asset to protect.** The main |
| 21 | + threat is abuse that degrades the service or the backing Postgres for everyone. |
| 22 | + The hardening below is aimed at that. |
| 23 | + |
| 24 | +> If you require per-caller authentication or quotas, terminate it at the |
| 25 | +> gateway (API keys, JWT, or mTLS). The application is intentionally kept simple |
| 26 | +> and unauthenticated; gating is an operator concern. |
| 27 | +
|
| 28 | +## Network architecture |
| 29 | + |
| 30 | +Run the API behind a **TLS-terminating reverse proxy or load balancer**. The |
| 31 | +application itself speaks plain HTTP on `PORT` and does not terminate TLS. |
| 32 | + |
| 33 | +``` |
| 34 | + ┌─────────────────────────┐ |
| 35 | + client ───▶│ TLS gateway / LB │ (HTTPS, X-Forwarded-For, |
| 36 | + (HTTPS) │ nginx / Envoy / ALB │ request-size limits, optional auth) |
| 37 | + └────────────┬────────────┘ |
| 38 | + │ HTTP (private network) |
| 39 | + ┌────────────▼────────────┐ |
| 40 | + │ Archive Node API │ (this service, :8080) |
| 41 | + └────────────┬────────────┘ |
| 42 | + │ TCP (private network) |
| 43 | + ┌────────────▼────────────┐ |
| 44 | + │ Postgres (archive DB) │ read replicas, not publicly reachable |
| 45 | + └─────────────────────────┘ |
| 46 | +``` |
| 47 | + |
| 48 | +Requirements: |
| 49 | + |
| 50 | +- **TLS at the gateway.** Never expose the plain-HTTP app port to the internet. |
| 51 | +- **Set `X-Forwarded-For` and `TRUST_PROXY` together (from 1.0.0).** A gateway |
| 52 | + should append `X-Forwarded-For`, and the API derives the rate-limit client |
| 53 | + from that header only as far as `TRUST_PROXY` allows: it names how many proxy |
| 54 | + hops sit in front of the API, and the client is read that many entries from the |
| 55 | + _right_ of the header — the part your own proxies appended. `TRUST_PROXY` has |
| 56 | + no default; while it is unset, rate limiting is disabled with a startup |
| 57 | + warning. Use `TRUST_PROXY=0` only for a directly exposed server. Behind a |
| 58 | + gateway, set the real hop count for your topology. A generic single reverse |
| 59 | + proxy is often `1`; a GCP external Application Load Balancer commonly needs |
| 60 | + `2` because it appends two `X-Forwarded-For` entries. Too low collapses |
| 61 | + clients onto a proxy address; too high can trust caller-prepended entries. |
| 62 | +- **Keep Postgres private.** The database must not be reachable from the public |
| 63 | + internet — only from the API instances. |
| 64 | + |
| 65 | +## Built-in protections |
| 66 | + |
| 67 | +The service ships with abuse controls that are safe by default and tunable via |
| 68 | +the [configuration](./getting-started.md#configuration): |
| 69 | + |
| 70 | +| Protection | Default | Purpose | |
| 71 | +| --------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | |
| 72 | +| 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 | |
| 73 | +| GraphQL **query-cost limits** (depth / aliases / tokens / cost) | on | Rejects expensive/abusive query shapes before execution | |
| 74 | +| Postgres **statement timeout** & pool limits | on | Caps how long/much a single query can consume | |
| 75 | +| **CORS** | same-origin only | Cross-origin browser access is opt-in — see the caveat below before locking it down | |
| 76 | +| **Introspection** | off | Schema introspection disabled unless explicitly enabled | |
| 77 | +| Field-suggestion blocking | on | Hides `Did you mean ...?` suggestions while preserving GraphQL validation text | |
| 78 | + |
| 79 | +> **These controls arrive in 1.0.0.** On `0.0.x` releases they are absent or |
| 80 | +> default-open, or have older env parsing — notably `CORS_ORIGIN` defaults to |
| 81 | +> `*` there, so cross-origin access is wide open rather than opt-in. |
| 82 | +> Introspection disabling already exists on `0.0.x`, but any non-empty |
| 83 | +> `ENABLE_INTROSPECTION` value, including `false`, enables it. Check your |
| 84 | +> running version before relying on any row above. |
| 85 | +
|
| 86 | +Tune these to your traffic; see the configuration table for the exact |
| 87 | +environment variables and defaults. |
| 88 | + |
| 89 | +### CORS and browser clients |
| 90 | + |
| 91 | +Cross-origin browser clients **cannot reach this API unless their web origin is |
| 92 | +allowlisted in `CORS_ORIGIN`** — and when they fail, they fail silently from the |
| 93 | +server's point of view: the browser blocks the response and the server logs stay |
| 94 | +clean. This catches people out, so decide deliberately: |
| 95 | + |
| 96 | +- **A genuinely public read API** that any browser may call — including the |
| 97 | + [mina-explorer](https://github.com/o1-labs/mina-explorer) and third-party |
| 98 | + dashboards — wants `CORS_ORIGIN=*`. That is the correct setting here, not a |
| 99 | + lapse in hardening: the data is already public, and CORS is not an access |
| 100 | + control (it constrains browsers, not `curl` or a server-side client). |
| 101 | +- **A deployment with a known, fixed set of front-ends** wants those origins |
| 102 | + listed explicitly. This only limits which _browser pages_ may read responses; |
| 103 | + it does not restrict anyone else. |
| 104 | + |
| 105 | +## Least-privilege database access |
| 106 | + |
| 107 | +The API only ever issues `SELECT`s. Give it a **read-only** Postgres role rather |
| 108 | +than a superuser or the archive ingest user: |
| 109 | + |
| 110 | +```sql |
| 111 | +-- one-time setup on the archive database |
| 112 | +CREATE ROLE archive_api_ro LOGIN PASSWORD 'change-me'; |
| 113 | +GRANT CONNECT ON DATABASE archive TO archive_api_ro; |
| 114 | +GRANT USAGE ON SCHEMA public TO archive_api_ro; |
| 115 | +GRANT SELECT ON ALL TABLES IN SCHEMA public TO archive_api_ro; |
| 116 | +-- so the role can also read tables added by future archive migrations |
| 117 | +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO archive_api_ro; |
| 118 | +``` |
| 119 | + |
| 120 | +Then point `PG_CONN` at `archive_api_ro`. Even in the event of a query-layer bug, |
| 121 | +the credentials cannot modify or delete data. |
| 122 | + |
| 123 | +## Operational practices |
| 124 | + |
| 125 | +- **Secrets:** pass `PG_CONN` (and any gateway secrets) via environment / a |
| 126 | + secret manager, never bake them into the image. Prefer SSL to Postgres |
| 127 | + (`?sslmode=require`) when the DB is on a managed provider. |
| 128 | +- **Keep `ENABLE_GRAPHIQL` / `ENABLE_INTROSPECTION` off in production** unless you |
| 129 | + intentionally want a public playground. |
| 130 | +- **Updates:** track and apply dependency and base-image security updates |
| 131 | + (supply-chain scanning is part of the [production-readiness work](https://github.com/o1-labs/Archive-Node-API/issues/163)). |
| 132 | + |
| 133 | +## Deployment checklist |
| 134 | + |
| 135 | +- [ ] TLS terminated at a gateway; plain-HTTP app port not publicly exposed |
| 136 | +- [ ] `TRUST_PROXY` explicitly set: `0` only for direct exposure, or the real |
| 137 | + hop count behind a gateway; rate limiting is disabled until this is set |
| 138 | +- [ ] Gateway sets `X-Forwarded-For` |
| 139 | +- [ ] Postgres reachable only from the API, not the public internet |
| 140 | +- [ ] API uses a **read-only** Postgres role |
| 141 | +- [ ] Rate-limit and query-cost limits reviewed for your expected traffic |
| 142 | +- [ ] `CORS_ORIGIN` matches your clients: `*` for a public API any browser may |
| 143 | + call, or an explicit allowlist if your front-ends are known and fixed — |
| 144 | + leaving it unset blocks all cross-origin browser clients |
| 145 | +- [ ] `ENABLE_GRAPHIQL` and `ENABLE_INTROSPECTION` off (unless intentionally |
| 146 | + public) — on `0.0.x`, leave them unset; any non-empty value, including |
| 147 | + `false`, enables introspection |
| 148 | +- [ ] Secrets injected via env / secret manager; SSL to Postgres where applicable |
| 149 | + |
| 150 | +## Scope |
| 151 | + |
| 152 | +This document covers deploying _this service_ securely. It does not cover |
| 153 | +securing the upstream Mina archive node or its Postgres ingest pipeline. Broader |
| 154 | +production-readiness work (observability, readiness probes, supply-chain |
| 155 | +scanning, runbooks) is tracked in the |
| 156 | +[production-readiness epic](https://github.com/o1-labs/Archive-Node-API/issues/163). |
0 commit comments