Skip to content

fix(graphql): enforce configurable query depth limit (#360) - #539

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
lancefair431-code:fix/graphql-query-depth-limit
Oct 19, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
lancefair431-code:fix/graphql-query-depth-limit

Conversation

@lancefair431-code

Copy link
Copy Markdown

Summary

Fixes #360 by enforcing a configurable maximum query depth for the GraphQL endpoint.

Changes Made

  • Add a configurable maximum GraphQL query depth.
  • Validate query depth before resolver execution to prevent excessively nested queries from reaching application logic.
  • Return a clear GraphQL error that includes both the configured maximum depth and the requested query depth.
  • Handle introspection queries according to the documented depth-limit policy without creating an unrestricted bypass for ordinary queries.
  • Document the query depth limit alongside the GraphQL schema and update .env.example if an environment variable is introduced.
  • Add Vitest regression tests covering queries within and beyond the configured limit, error details, resolver execution, and relevant GraphQL query structures.

Testing

  • Run the targeted GraphQL tests.
  • Run npm test.
  • Run the project's applicable lint and TypeScript checks.
  • Verify that rejected queries do not execute resolvers.

Note: The existing Load test (prices) failure on the main branch is known. Any test failures should be reported accurately and distinguished from regressions introduced by this change.

Scope

This change is limited to GraphQL query-depth validation, its configuration, documentation, and tests. It does not modify pricing logic, financial behavior, or asset and contract identifiers.

Closes: #360

@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@lancefair431-code Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656
Miracle656 merged commit 0642ed3 into Divinity-Labs:main Oct 19, 2026
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged. #360 is yours and this is a careful piece of work — it closes the hole rather than appearing to.

The thing that makes it right is the part most depth limiters get wrong: fragments are expanded. Mercurius's built-in queryDepth doesn't, so a limit configured through it is defeated by moving the deep part of a query into a named fragment — the attack is one refactor away from the thing you're defending against. Counting a fragment node as adding no depth while its selected fields do is the correct accounting, and the test for it is the one I'd have asked for.

Four more details I checked and agreed with:

  • Fragment cycles terminate. A fragment already being expanded isn't expanded again, so GraphQL's own cycle validation reports the cycle rather than this traversal blowing the stack first. Easy to get wrong in a recursive walk, and the test pins it.
  • Introspection exempt only at the operation root. This is the second hole people leave: exempt __schema wholesale and an operation mixing __schema with deep application fields walks straight through. Checking depth === 0 and still measuring the application branch is the right narrow exemption, and again there's a test for the mixed case.
  • A bad GRAPHQL_MAX_QUERY_DEPTH throws at startup. Most config parsing falls back to a default on garbage; here falling back would mean "no limit", so a deployment typo would silently undo a security control. Failing to boot is correct and the comment says so.
  • Rejection in validation, before any resolver runs, with the error naming both the measured depth and the configured maximum — so a legitimate client that trips it can tell what to change.

One tradeoff worth flagging, not blocking

cache: false turns off Mercurius's document cache for every request, because a callback-style validationRules can't be used with it. You documented that, and choosing correctness over throughput on a security control is the right call — so I'm not asking you to change it. But it does mean every GraphQL request now re-parses and re-validates, and this is a public endpoint.

If you want the cache back later, there's a route that doesn't weaken anything: measure every operation in the document and take the maximum, rather than only the one operationName selects. That needs no callback, so a static validationRules array works and the cache stays on. It's strictly more conservative — the only behaviour change is that a multi-operation document carrying one deep unselected operation would be refused, which is a rare and arguably fine thing to refuse. Worth its own PR if GraphQL throughput ever matters; the repo has k6 load tests to tell you whether it does.

Also keep in mind DEFAULT_MAX_GRAPHQL_QUERY_DEPTH = 3 is pinned to "the deepest application selection in the current schema". Add a nested field to the schema and legitimate queries start failing until it's raised — that's the limit working, but it's a surprise worth remembering. A test asserting the default is greater than or equal to the schema's actual deepest path would turn that surprise into a red test.

Merge note: .env.example conflicted — #544's readiness-probe block landed while this was open. Resolved additively; both sections kept, and envDrift passes.

Verified on the merge result rather than your branch: tsc --noEmit silent, 896 tests across 83 suites, nothing red.

Closes #360.

Miracle656 added a commit that referenced this pull request Oct 19, 2026
…key expiry

Three related things, all wanted:

- **Auth failures are now counted** — `admin_auth_failures_total` and
  `api_key_auth_failures_total{reason}`, plus
  `rate_limit_rejections_total{scope}` on the per-key/IP limiter. A rejected
  request was previously invisible, so neither a misconfigured client nor
  someone probing `ADMIN_TOKEN` showed up anywhere.
- **A per-instance request ceiling** that sheds with 503 and `Retry-After`
  once this process is over `GLOBAL_RATE_LIMIT_MAX` in a rolling minute, with
  expensive route templates shed at 80% of it and the probe paths exempt. This
  is self-defence rather than a quota, so keeping it per-process and
  in-memory is the right call — it protects *this* instance's resources.
- **`api_keys.expires_at`**, with admin key creation accepting an optional
  future ISO date-time and `lookupApiKey` refusing an expired key.

`parseGlobalRateLimit` throws on an invalid value instead of falling back to a
default, which is the same instinct as #539's depth limit and correct for the
same reason: a config typo must not quietly mean "no ceiling".

Resolutions and fixes while merging:

- `src/api/usage.ts` re-added a local `isAdminAuthorized` — #517 removed
  exactly that copy earlier today. Kept the shared one from `./auth` and the
  new metric import.
- `src/app.ts`: kept `allowList: (req) => PROBE_PATHS.has(req.url)` from main
  rather than this PR's `req.url === '/status'`, since `/live` and `/ready`
  (from #544) are platform-polled and must not be limited either. Took the
  PR's metric-emitting `errorResponseBuilder` and the global-ceiling
  registration, and kept main's note on the probe-route ceiling.
- `.env.example` was missing `GLOBAL_RATE_LIMIT_MAX`, which the `envDrift`
  guard caught. Documented, including that the fleet ceiling is this value
  times the instance count by design.
- `npx prisma generate` was needed after the schema change to clear phantom
  `expiresAt` errors.

⚠️ Deploy order: `api_keys.expires_at` must exist before this runs, because
`lookupApiKey` selects it — without the column, every authenticated request
fails, not just key creation. Lens has no migrations directory (schema-first
via `db push`), so:

    ALTER TABLE "api_keys" ADD COLUMN IF NOT EXISTS "expires_at" TIMESTAMP(3);

Verified on the merge result: `tsc --noEmit` silent, 908 tests pass across 84
suites, nothing red.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656 Miracle656 mentioned this pull request Oct 19, 2026
5 of 11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A GraphQL query can nest as deep as it likes

3 participants