Skip to content

P0: Add GraphQL query-cost controls via graphql-armor (#164) - #183

Open
dkijania wants to merge 2 commits into
mainfrom
feat/graphql-armor
Open

P0: Add GraphQL query-cost controls via graphql-armor (#164)#183
dkijania wants to merge 2 commits into
mainfrom
feat/graphql-armor

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

What & why

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

The public GraphQL endpoint had no query-cost controls. The list fields (events, actions, blocks) return unbounded lists, so a deeply-nested or heavily-aliased query is a trivial DoS against the backing Postgres — it can be made arbitrarily expensive before any limit applies. This is the highest-impact P0: the "crafted query = outage" mitigation.

Changes

  • New src/server/graphql-armor.ts — builds graphql-armor validation plugins from conservative, env-tunable limits (isolated and unit-testable).
  • buildPlugins runs them ahead of execution, so abusive shapes are rejected during validation.
Env var Default Limit
GRAPHQL_MAX_DEPTH 10 Selection-set nesting depth
GRAPHQL_MAX_ALIASES 15 Aliases per operation
GRAPHQL_MAX_TOKENS 1000 Lexical tokens per document
GRAPHQL_MAX_COST 5000 Depth/field cost heuristic
  • Field suggestions are always blocked so errors don't leak schema shape (complements useDisableIntrospection). Introspection is ignored by the depth/cost rules so GraphiQL still works when explicitly enabled.
  • The deepest query this API legitimately serves is ~5 levels — comfortably within the defaults. Malformed env values fall back to the safe default rather than disabling a protection.

Why the individual plugins, not the meta package

@escape.tech/graphql-armor (meta) requires @envelop/core v5, but this stack is pinned to v4 (Yoga 4). The individual @escape.tech/graphql-armor-* sub-plugins have no conflicting peer deps and work on the v4 stack. Switching to the meta integration can follow the Yoga 5 upgrade (#176).

Testing

  • npm run build — clean
  • npm run test:unit — all pass, including an end-to-end test through Yoga asserting an over-depth query is rejected before execution, plus config parsing/fallback tests
  • npm run lint — clean
  • npx prettier --debug-check . — exit 0

🤖 Generated with Claude Code

@dkijania dkijania added 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

Verdict: MERGEABLE

I measured every configured limit against the actual queries the two downstream consumers send, running the real @escape.tech/* packages resolved from this PR's lockfile against this repo's schema.graphql. Nothing the explorer or the API backend sends comes close to any limit, and the error-text contract survives intact.

What I checked

1. Plugins enabled at head (src/server/graphql-armor.ts:65-74, wired in src/server/plugins.ts:18):

plugin configured armor's own default
maxDepthPlugin n: 10, ignoreIntrospection: true n: 6
maxAliasesPlugin n: 15 n: 15
maxTokensPlugin n: 1000 n: 1000
costLimitPlugin maxCost: 5000, ignoreIntrospection: true maxCost: 5000, objectCost: 2, scalarCost: 1, depthCostFactor: 1.5
blockFieldSuggestionsPlugin no options → mask: '[Suggestion hidden]' same

2. Measured metrics for the real consumer queries (depth/alias/cost taken from the plugins' own found N output by forcing n: -1; tokens from MaxTokensParserWLexer):

query depth /10 aliases /15 cost /5000 tokens /1000
explorer blocks list FULL + paginated + inBestChain (blocks.ts:46-114) 4 0 56.1 72
explorer block detail FULL (blocks.ts:123-155) 4 0 98.5 89
explorer Analytics, ANALYTICS_BLOCK_LIMIT = 2000 (analytics.ts:14,51-71) 4 0 25.3 51
explorer SearchTransaction FULL — deepest real query (transactions.ts:212-235) 7 0 160.7 68
explorer transactions-archive paginated + inBestChain (transactions.ts:783-840) 4 0 80.5 74
explorer GetBlocksByDateRange, limit: 10000 (blocks.ts:1100) 2 0 6.5 48
mina-explorer-api build_blocks_query('FULL', paginated, best_chain) @ page size 500 (app/upstream/archive.py:231-249) 4 0 56.1 72
readiness { __typename } (app/observability.py:307) 1 0 1 3
o1js events / actions 4 0 45.1 / 68.4 42 / 53

The limit argument does not feed the cost model at all. costLimitPlugin's setMultiplier only fires for arguments literally named first or last (graphql-armor-cost-limit@2.4.3 dist, computeComplexity); this schema uses limit, so limit: 2000 and limit: 10000 cost exactly the same as limit: 1. Analytics costs 25.3 of 5000 — 0.5% of budget. No cost-limit break of the Analytics page.

3. blockFieldSuggestions does NOT mask the message — HARD CONSTRAINT #1 holds. The installed implementation is a one-line regex substitution, not a message replacement:

// @escape.tech/graphql-armor-block-field-suggestions@3.0.1
error.message = error.message.replace(/Did you mean ".+"\?/g, mask).trim();

Driven end-to-end through Yoga 4 with maskedErrors: true and this repo's schema + the PR's exact plugin list:

FULL-tier probe (protocolState):  200  ["Cannot query field \"protocolState\" on type \"Block\"."]
typo'd input field:               200  ["Field \"inBestChainn\" is not defined by type \"BlockQueryInput\". [Suggestion hidden]"]
unknown argument:                 200  ["Unknown argument \"bogusArg\" on field \"Query.blocks\"."]

So SCHEMA_ERROR_MARKERS (app/upstream/graphql.py:32-36) and error.message.includes('inBestChain') (src/services/api/bestChainFilter.ts:31, app/upstream/archive.py:78-82) all still match — only the Did you mean …? clause is swapped. Tier fallback FULL→BASIC→MINIMAL is unaffected.

4. Rejection messages can't be confused with the markers, and rejections don't retry-amplify. All four limits reject with Syntax Error: Query depth limit of N exceeded, found M. / Query Cost limit of N exceeded, found M. / Token limit of N exceeded. / Aliases limit of N exceeded, found M. — no inBestChain, no Cannot query field. All come back HTTP 200 with errors[] (verified above), so client.ts's non-2xx path isn't even needed and mina-explorer-api classifies them as plain UpstreamError, not 5xx → no retry.

5. Introspection / GraphiQL unchanged. useDisableIntrospection gating is untouched (src/server/plugins.ts:36-38). getIntrospectionQuery() measures 160 tokens (180 with all the GraphiQL extras) and is skipped by the depth/cost rules via ignoreIntrospection — so no interaction with ENABLE_INTROSPECTION=true or the Check Schema / GraphQL Inspector jobs.

6. No hidden parser-cache bypass. Yoga adds useParserAndValidationCache after user plugins, but it uses setParsedDocument/setResult, not setParseFn — so maxTokensPlugin's parser stays installed on cache misses and rejected documents land in the errorCache. The limit isn't silently skipped.

All four limits are env-tunable with documented defaults (docs/getting-started.md:181-184, .env.example.compose:33-37) and malformed values fall back to the safe default rather than disabling the protection.

Non-blocking nits

  1. Depth headroom is the thinnest margin here — 7 of 10. The explorer's SearchTransaction FULL tier is depth 7 (blocks → transactions → zkappCommands → zkappCommand → feePayer → body → publicKey, transactions.ts:222-233) and it is sent to the archive endpoint on every transaction search (transactions.ts:478), relying on Cannot query field "zkappCommand" to fall back to FLAT. Two things worth noting for whoever touches this next:

    • graphql-armor's own default is n: 6, which would break that probe. The explicit 10 is load-bearing; please don't "simplify" it away to the library default later.
    • When any armor rule trips it throws out of validate(), which aborts the whole validation pass — every other validation error is lost. I confirmed this: with maxDepth: 6, the probe returns only Syntax Error: Query depth limit of 6 exceeded, found 7. and never Cannot query field "zkappCommand", so both consumers' tier fallback silently degrades instead of falling back. So a too-low GRAPHQL_MAX_DEPTH is a silent explorer degradation, not a loud failure.

    Suggested: bump the default to 12 and document the floor. Exact diff:

    --- a/src/server/graphql-armor.ts
    +++ b/src/server/graphql-armor.ts
    @@
     const ARMOR_DEFAULTS: ArmorConfig = {
    -  maxDepth: 10,
    +  // Deepest query in production use is 7 (mina-explorer's SearchTransaction
    +  // FULL tier probe: blocks > transactions > zkappCommands > zkappCommand >
    +  // feePayer > body > publicKey). Never set below 8: an armor rule throws out
    +  // of validate(), suppressing the `Cannot query field` error that both
    +  // downstream consumers string-match to drive their field-tier fallback.
    +  maxDepth: 12,
       maxAliases: 15,
    --- a/docs/getting-started.md
    +++ b/docs/getting-started.md
    -| `GRAPHQL_MAX_DEPTH` | `10` | Max query selection-set nesting depth |
    +| `GRAPHQL_MAX_DEPTH` | `12` | Max query selection-set nesting depth. Do not set below `8` — known clients send depth-7 probes, and a depth rejection replaces the `Cannot query field` error those clients rely on for schema-tier fallback |
    --- a/.env.example.compose
    +++ b/.env.example.compose
    -GRAPHQL_MAX_DEPTH=10
    +GRAPHQL_MAX_DEPTH=12
  2. The enforcement test only proves the depth rule fires; nothing pins the "real traffic still passes" side. tests/unit/graphql-armor.test.ts:83-91 asserts a within-limit query isn't depth-rejected, but a future limit tightening wouldn't fail CI. Worth adding a table-driven regression test with the verbatim consumer query shapes — it's the only thing that would catch a silent explorer break:

    describe('real downstream client queries pass the default limits', () => {
      const DOWNSTREAM_QUERIES: Record<string, string> = {
        // mina-explorer src/services/api/transactions.ts:212 (deepest, depth 7)
        searchTransactionFull: `
          query SearchTransaction($limit: Int!) {
            blocks(limit: $limit, sortBy: BLOCKHEIGHT_DESC) {
              blockHeight stateHash dateTime
              transactions {
                userCommands { hash kind from to amount fee memo nonce failureReason }
                zkappCommands {
                  hash
                  failureReasons { failures }
                  zkappCommand {
                    memo
                    feePayer { body { publicKey fee } }
                    accountUpdates { body { publicKey } }
                  }
                }
              }
            }
          }`,
        // mina-explorer src/services/api/analytics.ts:51 (ANALYTICS_BLOCK_LIMIT = 2000)
        analytics: `
          query BlocksAnalytics($limit: Int, $dateTime_gte: DateTime) {
            blocks(query: { canonical: true, dateTime_gte: $dateTime_gte }, sortBy: BLOCKHEIGHT_DESC, limit: $limit) {
              blockHeight dateTime txFees
              transactions { userCommands { hash } zkappCommands { hash } }
            }
          }`,
        // mina-explorer-api app/upstream/archive.py:228 (FULL tier, page size 500)
        blocksFullPaginatedBestChain: `
          query GetBlocksFULLPaginatedBestChain($limit: Int!, $maxBlockHeight: Int!) {
            blocks(query: { blockHeight_lt: $maxBlockHeight, inBestChain: true }, limit: $limit, sortBy: BLOCKHEIGHT_DESC) {
              blockHeight stateHash creator dateTime
              protocolState { consensusState { epoch slot slotSinceGenesis } }
              transactions { coinbase userCommands { hash } zkappCommands { hash } }
            }
            networkState { maxBlockHeight { canonicalMaxBlockHeight pendingMaxBlockHeight } }
          }`,
        readiness: '{ __typename }',
      };
    
      for (const [name, query] of Object.entries(DOWNSTREAM_QUERIES)) {
        test(`${name} is not rejected by any armor limit`, async () => {
          const result = await runQuery(query, {}); // defaults
          const armorError = result.errors?.find((e: { message: string }) =>
            /Syntax Error: (Query depth limit|Query Cost limit|Token limit|Aliases limit)/.test(
              e.message
            )
          );
          assert.strictEqual(
            armorError,
            undefined,
            `armor rejected a production query: ${armorError?.message}`
          );
        });
      }
    
      // Unknown fields must still produce the verbatim graphql-js validation error:
      // both downstream clients string-match it to drive field-tier fallback.
      test('field-suggestion blocking keeps "Cannot query field" verbatim', async () => {
        const result = await runQuery(
          '{ blocks(limit: 1) { protocolState { consensusState { epoch } } } }',
          {}
        );
        assert.ok(
          result.errors?.some((e: { message: string }) =>
            e.message.startsWith('Cannot query field "protocolState" on type "Block".')
          ),
          `tier-fallback marker lost: ${JSON.stringify(result.errors)}`
        );
      });
    });

    (Both new tests fail if someone lowers maxDepth below 8 or swaps blockFieldSuggestionsPlugin() for a message-replacing mask.)

  3. .env.example.lightnet carries CORS_ORIGIN but didn't get the four new vars — worth the two-line copy for consistency with .env.example.compose.

  4. intFromEnv swallowing a malformed value is the right call, but a console.warn('[armor] ignoring invalid GRAPHQL_MAX_DEPTH="x", using 12') would save an operator a confusing afternoon. Purely optional.

  5. assert.strictEqual(plugins.length, 5) (tests/unit/graphql-armor.test.ts:60) will need editing every time a plugin is added — a assert.ok(plugins.length >= 5) or a set-of-hook-names assertion ages better.

  6. The branch is mergeable: CONFLICTING against current main (base is 491b8bc, main is now e353bbc) — needs a rebase, almost certainly just package.json / package-lock.json against ci(publish): pin npm to 11.x so the publish job works on Node 20 #207.

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.

SanabriaRusso
SanabriaRusso previously approved these changes Aug 24, 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. The round-1 carry-forward is fully addressed, and I verified the numbers by execution against this repo's real schema and resolved dependency versions rather than by inspection.

Verified fixed

  • maxDepth: 12 at src/server/graphql-armor.ts:34, with the floor documented in the config table: "Do not set below 8: known clients send depth-7 probes, and a depth rejection replaces the Cannot query field error those clients rely on for schema-tier fallback." Mirrored in both .env.example.* files.
  • Armor's own default of 6 is unreachable. buildArmorPlugins always passes { n: config.maxDepth } explicitly (:72), and resolveArmorConfig falls back to ARMOR_DEFAULTS (12), never to the library default, for unset / blank / non-integer / <1 values (:50, :56-59).
  • Headroom, measured. Minimum maxDepth that passes each real consumer query: searchTransactionFull 7, analytics 4, blocksFullPaginatedBestChain 4, { __typename } 1 — 5 levels of headroom at 12, and 1 at the documented floor of 8. Measured cost of the heaviest query: 160.671875 vs maxCost: 5000 (31×). Tokens 89 vs 1000; aliases 0 vs 15.
  • Round 1's concern was real and is now closed: at n: 6 the depth-7 query is rejected — Syntax Error: Query depth limit of 6 exceeded, found 7.
  • costLimit's multiplier still only fires on first/last — re-confirmed by direct experiment on the same query shape: l1(limit: 2000) → cost 12.875; l1(first: 2000) → cost 25750. limit: 2000 contributes nothing.
  • All four limits are env-configurable and present in .env.example.compose, .env.example.lightnet, src/envionment.d.ts, and the docs table.

On the silent-tier-degradation risk — the main downstream question, stated plainly

A tripped armor rule still aborts validation and therefore still suppresses Cannot query field. What has changed is that it is now only reachable by misconfiguration (12 vs a depth-7 worst case, floor documented at 8). And when it does trip, the failure is loud, not silent: armor emits Syntax Error: Query depth limit … / Aliases limit … / Token limit … / Query Cost limit …, and none of those contain any of mina-explorer-api's SCHEMA_ERROR_MARKERS (app/upstream/graphql.py:31-35 — "Cannot query field", "Unknown argument", "Unknown type") or its UNAVAILABLE_ERROR_MARKERS. It classifies as a generic UpstreamError, so it will not poison the capability cache or trigger a bogus tier downgrade. That is the right side to fail on.

One consequence worth knowing for capacity planning: in mina-explorer-api, a generic UpstreamError maps to outcome="unavailable"breaker.record_failure() (app/upstream/graphql.py, query()). So repeated armor rejections drive that consumer's per-endpoint circuit breaker toward OPEN rather than merely failing the individual query. With 5× depth and 31× cost headroom this should never fire in practice — it is an argument for keeping the headroom, not against the PR.

Non-blocking

  1. The floor is documented but not enforced. graphql-armor.ts:50 accepts any integer >= 1, so GRAPHQL_MAX_DEPTH=6 is silently honoured and reinstates exactly what the doc warns about. Consider making it real:
/** Below this, known downstream depth-7 probes break — see docs/getting-started.md. */
const MIN_SAFE_DEPTH = 8;

const maxDepth = intFromEnv(env.GRAPHQL_MAX_DEPTH, ARMOR_DEFAULTS.maxDepth);
if (maxDepth < MIN_SAFE_DEPTH) {
  console.warn(
    `[armor] GRAPHQL_MAX_DEPTH=${maxDepth} is below the safe floor of ${MIN_SAFE_DEPTH}: ` +
      'known clients send depth-7 queries and a depth rejection replaces the ' +
      '"Cannot query field" error they use for schema-tier fallback.'
  );
}
  1. The floor is pinned only indirectly. resolveArmorConfig({}) deepStrictEqual ARMOR_DEFAULTS is tautological and would pass at 6. What actually catches a regression is the searchTransactionFull is not rejected by any armor limit case — that query needs maxDepth >= 7, and I confirmed its strict regex (/Syntax Error: (Query depth limit|Query Cost limit|Token limit|Aliases limit)/) matches armor's real message strings byte-for-byte, so it is not passing vacuously. A regression to 8–11 would still slip through. One explicit line closes it:
test('the default depth stays above the documented floor for downstream clients', () => {
  // Deepest known real query is 7 (mina-explorer SearchTransaction FULL tier);
  // armor's own default of 6 would reject it.
  assert.ok(ARMOR_DEFAULTS.maxDepth >= 8, `maxDepth floor breached: ${ARMOR_DEFAULTS.maxDepth}`);
});
  1. maxCost: 5000 is safe only because this schema uses limit:. The same query written with first: 2000 costs 25750 — 5× over. Worth a comment at graphql-armor.ts:37 so whoever adds a first/last pagination argument (#162 is a live candidate) revisits GRAPHQL_MAX_COST in the same PR.
  2. The lockfile bumps graphql 16.8.1 → 16.14.2 (transitively — armor requires ^16.10.0). Both consumers treat graphql-js error text as a contract. The text is stable across 16.x and your startsWith('Cannot query field "protocolState" on type "Block".') assertion pins it, so this is fine — but please call it out in the PR description rather than leaving it buried in the lockfile diff.
  3. Merge-order note: src/server/plugins.ts is touched by #183, #185, #190 and #191, all prepending to the same array. No functional conflict for this PR specifically (armor hooks validation, not onRequest), but expect a textual one.

Downstream: none at the shipped defaults, verified by execution. All three real consumer queries and the { __typename } probe pass every limit with 5× depth, 31× cost and 11× token headroom. blockFieldSuggestions preserves Cannot query field "X" on type "Y". verbatim, so explorer-api's tier fallback and the explorer's unsupported-filter detection are untouched. The only way to break a consumer is to set GRAPHQL_MAX_DEPTH below 8, which the docs explicitly warn against.

dkijania and others added 2 commits August 26, 2026 17:59
The public GraphQL endpoint had no query-cost controls. The list fields
(`events`, `actions`, `blocks`) return unbounded lists, so a deeply-nested or
heavily-aliased query is a trivial denial-of-service against the backing
Postgres — it can be made arbitrarily expensive before any limit applies.

Add graphql-armor validation plugins, wired ahead of execution in
`buildPlugins`, with conservative, env-tunable limits:

- GRAPHQL_MAX_DEPTH   (selection-set nesting, default 10)
- GRAPHQL_MAX_ALIASES (aliases per operation, default 15)
- GRAPHQL_MAX_TOKENS  (lexical tokens per document, default 1000)
- GRAPHQL_MAX_COST    (depth/field cost heuristic, default 5000)

Field suggestions are always blocked so error messages don't leak schema
shape (complementing `useDisableIntrospection`); introspection is ignored by
the depth/cost rules so GraphiQL still works when explicitly enabled. The
deepest query this API legitimately serves is ~5 levels, well within the
defaults. Malformed env values fall back to the safe default rather than
disabling a protection.

The individual `@escape.tech/graphql-armor-*` plugins are used (not the meta
package) because the meta package requires @envelop/core v5 while this stack is
pinned to v4 (Yoga 4); the sub-plugins have no conflicting peer deps. The full
graphql-armor meta integration can follow the Yoga 5 upgrade (#176).

Unit tests cover config parsing/fallbacks and prove end-to-end through Yoga
that an over-depth query is rejected before execution. Docs, env example, and
env type declarations updated.

Closes #164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
@dkijania
dkijania force-pushed the feat/graphql-armor branch from 8cb9a2d to 441a52d Compare August 26, 2026 16:04

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

Re-approving after the rebase (the previous approval was dismissed by the force-push).

Re-verified: the PR content at 441a52d differs from the commit I approved (c53ddeb) by one blank line in src/server/plugins.ts — absorbed while rebasing over #191's metrics plugin, which now sits in the same function. No semantic change. The lockfile still carries full resolved/integrity metadata for all 1602 entries.

Carrying forward the round-2 verification unchanged:

  • maxDepth 12, floor documented at 8, armor's own default of 6 unreachable.
  • Headroom measured against the real schema: the deepest real consumer query is depth 7; cost 160.67 vs 5000 (31x); tokens 89 vs 1000. Confirmed n:6 does reject the depth-7 query, so the round-1 concern was real.
  • costLimit multiplier re-confirmed to fire only on first/last: (limit:2000) → 12.875, (first:2000) → 25750.

Non-blocking:

  • The depth floor is documented but not enforced in code.
  • maxCost 5000 is only safe while the schema avoids first/last — relevant to #162.
  • The lockfile bumps graphql 16.8.1 → 16.14.2; worth naming in the PR description.
  • Keep the current 5x/31x headroom: mina-explorer-api maps a non-retried 4xx without a GraphQL errors[] array to outcome="unavailable"breaker.record_failure(), so sustained armor rejections open its per-endpoint circuit breaker and the archive tier goes dark as a step function rather than degrading gradually.

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

Labels

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: Add GraphQL query-cost controls (depth / alias / complexity limits)

2 participants