Skip to content

security: GraphQL depth/complexity/alias limits and disable GET transport (#14)#584

Merged
lakhansamani merged 2 commits intomainfrom
security/graphql-hardening
Apr 7, 2026
Merged

security: GraphQL depth/complexity/alias limits and disable GET transport (#14)#584
lakhansamani merged 2 commits intomainfrom
security/graphql-hardening

Conversation

@lakhansamani
Copy link
Copy Markdown
Contributor

Summary

Three related hardenings on the GraphQL endpoint:

  1. Depth limit — defeats deeply-nested DoS queries
  2. Alias limit — defeats alias-amplification attacks (many aliases on the same expensive field)
  3. Body size cap — defeats oversized-payload DoS
  4. Disable transport.GET — GraphQL queries (and especially mutations) over GET leak into proxy logs, server access logs, and browser history

Changes

  • New queryLimits handler extension walks the parsed AST and enforces max depth + max alias count via gqlerror, before execution begins.
  • The existing complexity limit is now driven by config instead of a hardcoded 300.
  • internal/http_handlers/graphql.go removes transport.GET{} and wraps the request body with http.MaxBytesReader.
  • New CLI flags (with safe defaults): --graphql-max-complexity (300), --graphql-max-depth (15), --graphql-max-aliases (30), --graphql-max-body-bytes (1 MiB).

Test plan

  • go build ./...
  • TEST_DBS=sqlite go test -run "TestSignup|TestLogin" ./internal/integration_tests/
  • Manual: curl -X GET '<host>/graphql?query={__typename}' returns 405 / 404
  • Manual: query nested 16 levels deep returns the depth-limit gqlerror

…port (#14)

The GraphQL endpoint had no query depth limit, no per-operation alias cap,
and accepted GET requests. These open three denial-of-service / leakage
vectors:

1. Deeply nested queries can blow the call stack and exhaust resolver work
2. Alias amplification (many aliases on the same expensive field) multiplies
   work without changing complexity score
3. transport.GET leaks queries (and any sensitive arguments) into proxy logs,
   server access logs, and browser history

Changes:
- Remove transport.GET — clients must POST
- Add a queryLimits handler extension that walks the parsed AST and enforces
  configurable max depth and max alias count, returning a gqlerror before
  execution begins
- Make the existing complexity limit configurable instead of hardcoded 300
- Cap the GraphQL request body size via http.MaxBytesReader (default 1 MB)

New CLI flags (with safe defaults):
- --graphql-max-complexity (default 300)
- --graphql-max-depth (default 15)
- --graphql-max-aliases (default 30)
- --graphql-max-body-bytes (default 1048576)
@lakhansamani lakhansamani force-pushed the security/graphql-hardening branch from 1e702a3 to 4d0d57a Compare April 7, 2026 04:51
The previous implementation walked the operation's selection-set tree
twice for legitimate traffic — once for max depth, once for total alias
count. Both walks visit every node, so the second pass is pure
duplicated work.

Replace selectionSetDepth + countAliases with a single recursive
walkSelectionSet that returns (depth, aliases) in one traversal. The
behaviour is identical:

- Field nodes contribute one level of depth and (if aliased) one to
  the alias count
- Inline fragments and fragment spreads do not add a depth level but
  their nested aliases still count
- Walks short-circuit naturally on the rejection path because the
  caller checks the limits in order after one pass

For a typical operation (~10–100 nodes) this halves the per-request
AST work added by this PR. No allocations beyond the recursion stack.
@lakhansamani lakhansamani merged commit d1bdab6 into main Apr 7, 2026
@lakhansamani lakhansamani deleted the security/graphql-hardening branch April 7, 2026 04:57
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.

1 participant