From qa-graphql
Crafts over-limit depth and complexity queries then asserts rejection before execution, verifying that graphql-depth-limit, graphql-cost-analysis, and graphql-armor (max-depth / cost-limit / max-tokens plugins) are actually enforced and not just configured. Use when auditing a GraphQL service for DoS exposure after depth or cost limits have been added as mitigations, or when adding tests that prove the limits in CI before a production deployment.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-graphql:graphql-complexity-limit-testerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
`introspection-attack-surface-reference` names query-depth limiting and
introspection-attack-surface-reference names query-depth limiting and
query-cost limiting as key DoS mitigations, but nothing in that catalog
executes a test. This skill closes that gap: it authors tests that send
an over-limit query and assert a validation error is returned before
any resolver runs.
Three library families are covered:
graphql-depth-limit (npm: graphql-depth-limit, canonical repo
github.com/stems/graphql-depth-limit, per npm registry
registry.npmjs.org/graphql-depth-limit)
depthLimit(n) validation rule, drop-in for Apollo or express-graphql.graphql-cost-analysis (npm: graphql-cost-analysis, repo
github.com/pa-bru/graphql-cost-analysis, per
github.com/pa-bru/graphql-cost-analysis)
costAnalysis({ maximumCost }) validation rule; queries above the
threshold are rejected.@escape.tech/graphql-armor (repo
github.com/Escape-Technologies/graphql-armor, docs
escape.tech/graphql-armor/docs)
max-depth plugin: default n = 6 (per
escape.tech/graphql-armor/docs/plugins/max-depth)cost-limit plugin: default maxCost = 5000 (per
escape.tech/graphql-armor/docs/plugins/cost-limit)max-tokens plugin: default n = 1000 (per
escape.tech/graphql-armor/docs/plugins/max-tokens)Differentiation vs. apollo-server-test: that skill covers resolver
correctness + production-config gates (introspection, APQ,
hideSchemaDetails). This skill is scoped exclusively to depth/complexity
DoS tests - over-limit query construction, validation-layer rejection
assertion, and the cross-library matrix.
If the server under test has no depth or complexity limit configured at
all (no depthLimit/costAnalysis validation rule, no
ApolloArmor/EnvelopArmorPlugin installed), halt immediately:
HALT: no depth/complexity limit configured.
Tests would pass vacuously - no enforcement exists to verify.
Install graphql-depth-limit, graphql-cost-analysis, or
@escape.tech/graphql-armor first, then re-run this skill.
Do not write tests that assert on a server with no limit - they will produce false positives.
Choose the library that matches the project.
# graphql-depth-limit (express-graphql / Apollo standalone rule)
npm install --save-dev graphql-depth-limit
# graphql-cost-analysis (Apollo standalone rule)
npm install --save-dev graphql-cost-analysis
# graphql-armor (Apollo or Envelop/Yoga - bundles all plugins)
npm install --save @escape.tech/graphql-armor
Read the server setup to find the active limit value before crafting queries. Common locations:
| Library | Where the limit lives |
|---|---|
graphql-depth-limit | depthLimit(N) in validationRules array |
graphql-cost-analysis | costAnalysis({ maximumCost: N }) in validationRules |
graphql-armor max-depth | armor.protect() or new ApolloArmor({ maxDepth: { n: N } }) |
graphql-armor cost-limit | new ApolloArmor({ costLimit: { maxCost: N } }) |
If the limit is not explicit, use the library default: n = 6 for
graphql-armor max-depth (per
escape.tech/graphql-armor/docs/plugins/max-depth),
maxCost = 5000 for graphql-armor cost-limit (per
escape.tech/graphql-armor/docs/plugins/cost-limit).
Build a query whose nesting depth is configuredLimit + 1. If the
schema is User { friends: [User] } and the depth limit is 5:
query DepthBust {
user { # depth 1
friends { # depth 2
friends { # depth 3
friends { # depth 4
friends { # depth 5
friends { id } # depth 6 -> over limit
}
}
}
}
}
}
For schemas without recursive types, chain any nested relationship until the depth exceeds the limit.
For graphql-cost-analysis, assign costs via the schema directive
@cost(complexity: N) or via costMap. To construct an over-limit
query without schema changes, use a fan-out pattern whose calculated
cost exceeds maximumCost. Per
github.com/pa-bru/graphql-cost-analysis,
defaultCost applies to each field when no explicit cost is set; repeat
high-cost fields until the sum exceeds the threshold:
query CostBust {
users { id name email createdAt updatedAt roles permissions profile
settings { notifications theme language timezone } }
}
For graphql-armor cost-limit, the default costs are: objectCost = 2,
scalarCost = 1, depthCostFactor = 1.5 (per
escape.tech/graphql-armor/docs/plugins/cost-limit).
A query with cost above maxCost = 5000 can be crafted by stacking
scalar fields at multiple nesting levels.
For graphql-armor max-tokens, the default token limit is n = 1000
(per escape.tech/graphql-armor/docs/plugins/max-tokens).
Tokens include field names, arguments, braces, and directives. A query
with more than 1000 tokens can be constructed by repeating field
selections:
query TokenBust {
users {
f1 f2 f3 f4 f5 f6 f7 f8 f9 f10
# ... repeat until token count > configured limit
}
}
Per the apollo-server-test skill, use executeOperation for
in-process validation. The depthLimit(n) rule is passed as a
validationRules option (per npm registry description of
graphql-depth-limit).
import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
import { typeDefs, resolvers } from './schema';
const DEPTH_LIMIT = 5;
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(DEPTH_LIMIT)],
});
test('rejects query exceeding depth limit', async () => {
const overDepthQuery = `
query DepthBust {
user { friends { friends { friends { friends { friends { id } } } } } }
}
`;
const resp = await server.executeOperation({ query: overDepthQuery });
if (resp.body.kind !== 'single') throw new Error('expected single');
// Validation errors are returned in errors[], not thrown
expect(resp.body.singleResult.errors).toBeDefined();
expect(resp.body.singleResult.data).toBeUndefined();
});
test('accepts query within depth limit', async () => {
const safeQuery = `
query SafeDepth {
user { friends { id } }
}
`;
const resp = await server.executeOperation({ query: safeQuery });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeUndefined();
});
Per github.com/pa-bru/graphql-cost-analysis,
costAnalysis plugs into validationRules alongside any other rules:
import { ApolloServer } from '@apollo/server';
import costAnalysis from 'graphql-cost-analysis';
const MAX_COST = 100;
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
costAnalysis({
maximumCost: MAX_COST,
defaultCost: 1,
variables: {},
}),
],
});
test('rejects query exceeding cost limit', async () => {
// Each field costs defaultCost=1; repeat fields to exceed MAX_COST
const fields = Array.from({ length: MAX_COST + 1 }, (_, i) => `field${i}`).join('\n ');
const overCostQuery = `query CostBust { users { ${fields} } }`;
const resp = await server.executeOperation({ query: overCostQuery });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
});
The createError(maximumCost, cost) option (per
github.com/pa-bru/graphql-cost-analysis)
lets you assert on a custom error message if the project overrides the
default error format.
Per escape.tech/graphql-armor/docs/getting-started,
ApolloArmor spreads protection options into the server constructor:
import { ApolloServer } from '@apollo/server';
import { ApolloArmor } from '@escape.tech/graphql-armor';
const armor = new ApolloArmor({
maxDepth: { n: 4 }, // override default 6
costLimit: { maxCost: 200 }, // override default 5000
maxTokens: { n: 50 }, // override default 1000
});
const server = new ApolloServer({
typeDefs,
resolvers,
...armor.protect(),
});
test('graphql-armor rejects over-depth query', async () => {
const query = `{ a { b { c { d { e { id } } } } } }`; // depth 6 > limit 4
const resp = await server.executeOperation({ query });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
// With exposeLimits: true (default), error includes limit detail
// With exposeLimits: false, message is 'Query validation error.'
// Per escape.tech/graphql-armor/docs/plugins/max-depth
});
test('graphql-armor rejects over-cost query', async () => {
// objectCost=2, scalarCost=1, depthCostFactor=1.5 (defaults per
// escape.tech/graphql-armor/docs/plugins/cost-limit)
// Stack fields so calculated cost > maxCost=200
const query = `{ users { id name email createdAt updatedAt
profile { bio avatar roles permissions settings { a b c d e } } } }`;
const resp = await server.executeOperation({ query });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
});
test('graphql-armor rejects over-token query', async () => {
// n=50 tokens; build a query with more than 50 tokens
const fields = Array.from({ length: 60 }, (_, i) => `f${i}`).join(' ');
const query = `{ users { ${fields} } }`;
const resp = await server.executeOperation({ query });
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
});
Per escape.tech/graphql-armor/docs/getting-started:
import { envelop } from '@envelop/core';
import { EnvelopArmorPlugin } from '@escape.tech/graphql-armor';
const getEnveloped = envelop({
plugins: [
EnvelopArmorPlugin({
maxDepth: { n: 4 },
costLimit: { maxCost: 200 },
maxTokens: { n: 50 },
}),
],
});
Test via the Yoga HTTP layer using supertest (same pattern as
apollo-server-test).
Confirm limits are enforced at validation, not resolver time. One way: instrument a resolver with a side-effect counter and assert it was never called on an over-limit query:
let resolverCallCount = 0;
const server = new ApolloServer({
typeDefs,
resolvers: {
Query: {
users: () => {
resolverCallCount++;
return [];
},
},
},
validationRules: [depthLimit(3)],
});
test('resolver never called on over-limit query', async () => {
resolverCallCount = 0;
const resp = await server.executeOperation({
query: '{ users { friends { friends { friends { id } } } } }',
});
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
expect(resolverCallCount).toBe(0); // validation short-circuits execution
});
npm test # jest / vitest discover *.test.ts
npx jest --testPathPattern complexity -t "limit"
Run against the production configuration. Tests that pass in
NODE_ENV=test but fail in NODE_ENV=production (or vice versa) signal
a configuration drift problem. See the CI note in apollo-server-test.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Writing depth tests without checking the actual configured limit | Query may be under-limit; test passes vacuously | Read validationRules / ApolloArmor config first |
| Testing at depth = limit (not limit+1) | Boundary is ambiguous; limit is exclusive or inclusive depending on library | Use limit+1 to be unambiguous |
Asserting errors[0].message string exactly | Error text includes dynamic limit values; brittle | Assert errors is defined; check extensions.code if set |
| Skipping the resolver-call assertion | A misconfigured rule may reach resolvers silently | Instrument resolvers to confirm validation short-circuits |
Using depthLimit and costAnalysis together without verifying precedence | First rule to reject wins; a low depth limit may mask cost-limit tests | Test each rule independently with the other absent |
| Assuming graphql-armor defaults when the project overrides them | Wrong threshold = vacuous pass | Always read the actual ApolloArmor(...) / EnvelopArmorPlugin(...) call |
graphql-depth-limit does not cover cost/fan-out attacks. Depth 2
with 1000 siblings is not blocked. Pair with a cost rule.graphql-cost-analysis is not actively maintained. Check the
project's dependency health before adopting; @escape.tech/graphql-armor
cost-limit is the actively maintained alternative.max-tokens counts tokens in the document AST, not resolver calls.
It prevents parsing overhead but not algorithmic fan-out in resolvers.
Pair with cost-limit for full coverage.executeOperation tests do not cover HTTP-level rate limiting.
Network-layer limits (nginx, API gateway) need HTTP integration tests.graphql-depth-limit (npm: graphql-depth-limit, canonical repo
github.com/stems/graphql-depth-limit):
registry.npmjs.org/graphql-depth-limitgraphql-cost-analysis (npm: graphql-cost-analysis):
github.com/pa-bru/graphql-cost-analysis@escape.tech/graphql-armor (repo github.com/Escape-Technologies/graphql-armor):
escape.tech/graphql-armor/docs/getting-started
executeOperation and validationRules:
apollographql.com/docs/apollo-server/testing/testingintrospection-attack-surface-referenceapollo-server-testnpx claudepluginhub testland/qa --plugin qa-graphqlProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.