GraphQL DoS review — nested-selector bomb / query complexity
Date: 2026-07-02 · Scope: defensive, read-only. PoC ran against the local memcli instance (http://localhost:24091) only. Prod was never touched.
Verdict
A nested-selector-bomb / query-complexity amplification DoS is not feasible — neither as an unauthenticated guest nor as a regular user. The reason is a single load-bearing control that is actually wired and working: extension.FixedComplexityLimit(30) in internal/graph/handler.go:80. Every field in an operation costs at least 1, so both deep nesting and mass-aliasing hit the ceiling of 30 fast and get rejected before any resolver runs.
The owner's belief about the persisted-query allowlist is correct: $trip2g_graphql_persist_queries is not enforced, and arbitrary ad-hoc queries execute freely. But that gap does not translate into an amplification DoS, because the complexity limit sits in front of everything and does not care whether a query is on the allowlist.
Severity of the amplification-bomb class: LOW (mitigated). The real residual exposure is plain volumetric flooding (many cheap-but-real queries in a loop) — see MEDIUM/HIGH findings below.
Evidence
The complexity limit is real and effective
internal/graph/handler.go registers, on the HTTP server:
srv.Use(extension.Introspection{})
srv.Use(extension.FixedComplexityLimit(30))
srv.Use(extension.AutomaticPersistedQuery{Cache: lru.New[string](100)})
gqlgen.yml sets omit_complexity: true, so the generated executableSchema.Complexity(...) returns (0, false) unconditionally (internal/graph/generated.go:895). That looked like it might defeat the limit — it does not. When the custom estimator returns false, gqlgen falls back to its default per-field cost of 1 + childComplexity. So FixedComplexityLimit(30) behaves as a hard cap of ~30 total fields across the whole selection tree (nesting levels and aliases each count).
Local PoC as guest (no auth), bounded on purpose:
| Query | Result |
|---|---|
{ __typename } |
executes — confirms arbitrary ad-hoc queries run (no allowlist) |
{ publicUrl } |
executes (complexity 1) |
publicUrl aliased 25× |
executes (complexity 25 ≤ 30) |
publicUrl aliased 50× |
rejected: operation has complexity 50, which exceeds the limit of 30 (COMPLEXITY_LIMIT_EXCEEDED) |
deep inLinks { inLinks { … } } nesting |
rejected at validation on the local schema; on any schema where it validates, each level adds ≥1 so it dies at 30 anyway |
The aliasing bomb — the classic amplifier (same expensive field 100× under different aliases) — is exactly what the 30-cap kills.
The allowlist is not wired
assets/ui/graphql/queries.ts holds the generated $trip2g_graphql_persist_queries map, but nothing on the server consults it. internal/graph/handler.go adds transport.POST{}, transport.SSE{}, transport.MultipartForm{}, transport.Options{} and the AutomaticPersistedQuery extension. APQ is a hash-caching optimization, not an allowlist: any client can register any query by hash and replay it. Confirmed empirically — { __typename } and other ad-hoc queries execute. So the allowlist is dead as designed; the complexity limit is what actually protects the endpoint.
Introspection is off for guests
internal/graph/handler.go:101 disableIntrospection sets opCtx.DisableIntrospection = true whenever there is no valid user token and the server is not in dev mode (makeAroundOperations, line 151). Guests in prod cannot introspect the schema. Good.
The self-referential amplifier exists but is mostly out of guest reach
NoteView.inLinks: [NoteView!]! (internal/graph/schema.graphqls:162) is self-referential — the note → inLinks → note → inLinks graph traversal. It is reachable from Viewer.latestNoteView, AdminQuery.noteView(id), recentlyModifiedNoteViews, etc. The guest note(input) query returns PublicNote, which deliberately does not expose inLinks (only pathId/path/url/title/html/toc). Either way the 30-cap bounds any traversal.
Guest-reachable surface and its caps
Query root guest-reachable fields: viewer, publicUrl, googleAuthUrl, oidcAuthUrl, githubAuthUrl, note (→ PublicNote), search, similarNotes. Notably:
search(input: SearchInput)—SearchInputonly carriesquery: String!(no client-controlled limit). The resolverinternal/case/sitesearch/resolve.gohard-caps results (vectorTopK = 50, final slice[:20]).similarNotes(input)—limitis clamped server-side to max 20 (internal/case/similarnotes/resolve.go:68 clampLimit,maxLimit = 20).
So arg-driven list amplification is contained by hardcoded resolver caps rather than by the complexity system.
No request-rate limiting in front of /graphql
There is no general HTTP rate limiter on the GraphQL endpoint. The rate-limiting that exists is domain-specific: internal/notfoundtracker (404 IP throttling), internal/personaltoken (5-min throttle), Telegram API backoff. A guest can issue complexity-≤30 queries in a tight loop.
Findings, rated
| # | Finding | Severity |
|---|---|---|
| 1 | Persisted-query allowlist generated but never enforced; arbitrary ad-hoc queries accepted. Compensated by the complexity limit, so not an amplification DoS on its own. | LOW–MEDIUM |
| 2 | No arg-based complexity weighting (omit_complexity: true → all fields cost 1). Today's list resolvers self-cap, but any new list/connection field added without an internal cap would be unprotected, since pagination args do not raise complexity. Fragile-by-default pattern. |
MEDIUM |
| 3 | No HTTP-level rate limiting in front of /graphql. Volumetric flooding of real complexity-30 queries (e.g. search, which can trigger vector/embedding work) remains possible. Matters more than usual because the prod box is small and memory-fragile. |
MEDIUM–HIGH |
| 4 | No explicit max-depth guard; depth is only bounded indirectly by the 30-cap. Acceptable today, but coupling depth protection to the complexity number means a future limit bump silently widens depth too. | LOW |
| 5 | No alias cap independent of complexity; again only bounded by the 30-cap. | LOW |
| 6 | Self-referential NoteView.inLinks amplifier reachable by authenticated users (admin/viewer paths). Bounded by the 30-cap, so not exploitable, but it is the field to watch if the limit is ever raised. |
LOW |
Mitigations, prioritized
- Add a request-rate limiter in front of
/graphql(per-IP token bucket, e.g.golang.org/x/time/rate, tighter for unauthenticated callers). Closes the only realistic DoS path (volumetric) — finding #3. Highest value for a small box. - Keep, and consider lowering,
FixedComplexityLimit. 30 already blocks the bomb class. If the real frontend operations fit comfortably under it, leave it; do not raise it casually, since depth/alias protection ride on this number. - Turn on real per-field complexity (drop
omit_complexity: true, add weighted@goField/complexity funcs forforceResolverand list fields —inLinks,search,similarNotes,warnings,url,html). This makes cost reflect actual work and removes the "new uncapped list field is silently unprotected" trap — finding #2. Medium effort. - Wire the persisted-query allowlist you already generate (
$trip2g_graphql_persist_queries) as defense-in-depth: reject non-allowlisted operations from first-party clients, or at least gate the guest surface to known queries. Do not treat APQ as this control — it is not. Finding #1. - Add an explicit max-depth extension (e.g. depth ≤ 10) so depth protection is independent of the complexity number — finding #4.
- Audit every list/connection resolver for a hard server-side cap and make "caps its own result set" a checklist item for new resolvers, until #3 lands — finding #2.
Bottom line
The endpoint accepts arbitrary queries and the allowlist is indeed inert, but a nested-selector-bomb DoS does not work because FixedComplexityLimit(30) is enabled and empirically rejects both aliasing and nesting amplification. The honest gap is not amplification — it is plain flooding: there is no rate limit in front of /graphql, which on a small box is the thing worth fixing first.