Harden a Django GraphQL API: Five Classic Flaws
You've inherited Acme Social, a small Django app whose API is served by Strawberry. It works — and it ships with five weaknesses that show up again and again in real GraphQL services. The code is a perfectly ordinary Strawberry/Django setup; the defaults are the problem.
Your job: confirm each weakness from the terminal with curl and jq, then fix
it in the real project under ~/graphql-lab. After each fix, restart the
service so your changes take effect, and the lab will re-check the live
endpoint:
sudo systemctl restart graphql-lab.service
It takes a couple of seconds to come back up. If it doesn't (a typo in your
edit, say), sudo systemctl status graphql-lab.service shows why.
The project you edit:
~/graphql-lab/graphql_lab/schema.py— where the schema is built.~/graphql-lab/graphql_lab/urls.py— where the view is wired up.
Nothing in the code is commented to tell you what to change — that's the point. Everything you need is below.
1. Introspection is wide open
GraphQL introspection lets a client download the entire schema. Handy in development, a gift to an attacker in production.
curl -fsS -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={__schema{queryType{name} types{name kind fields{name}}}}' \
| jq .
-G turns --data-urlencode into a URL query string, so this is a plain
GET /graphql/?query=.... If introspection is on, the response lists every
type and every field.
Disable introspection in the schema, then restart the service.
Hint
Strawberry ships a schema extension for exactly this. Extensions are passed
to strawberry.Schema(..., extensions=[...]). Look at
DisableIntrospection.
2. Error messages suggest real field names
Turning off introspection isn't enough. Ask for a field that almost exists and the server helpfully corrects you — rebuilding the schema one guess at a time.
curl -fsS -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={ usrs { id } }' \
| jq -r '.errors[].message'
There's no usrs field, so the query fails. Watch the error message for
Did you mean ...?
Make the server stop suggesting field names, then restart the service.
Hint
This one is a schema config flag, not an extension:
strawberry.Schema(..., config=StrawberryConfig(disable_field_suggestions=True)).
Import StrawberryConfig from strawberry.schema.config.
3. State-changing requests have no CSRF protection
The GraphQL view is wrapped in csrf_exempt (straight from Strawberry's own
Django docs). That means any site can POST a mutation to it in a logged-in
user's browser.
curl -s -o /dev/null -w 'HTTP %{http_code}\n' \
-X POST http://127.0.0.1:8000/graphql/ \
-H 'content-type: application/json' \
-d '{"query":"mutation{ updateBio(text:\"owned\"){ ok } }"}'
This is what a forged cross-site request looks like: a state-changing mutation
with no CSRF cookie and no token. HTTP 200 means it was accepted (vulnerable);
HTTP 403 means Django rejected it (fixed).
Make Django enforce CSRF on the endpoint again, then restart the service. (A request that carries a valid CSRF token must still work — the checker verifies both.)
Hint
The fix is a deletion, not an addition. Look at how the view is wrapped in
graphql_lab/urls.py and let Django's CsrfViewMiddleware do its job. A
browser client primes its token from GET /api/csrf/.
4. Field aliases bypass the rate limiter
The endpoint has a per-request rate limiter, so a brute-force of the
verifyOtp mutation should be slow. But GraphQL lets one request alias the same
field many times — and the limiter counts that as a single request.
First build one mutation that packs 60 OTP guesses, each under its own alias
(a0, a1, ...):
Q="mutation{ $(for i in $(seq 0 59); do printf 'a%d: verifyOtp(code:"%04d"){ ok } ' "$i" "$i"; done)}"
echo "$Q"
Then send it as a single POST. Once you've fixed CSRF in step 3 the endpoint
wants a token, so fetch one from /api/csrf/ and send it back as both the
cookie and the X-CSRFToken header, the same way a legitimate client does:
TOKEN="$(curl -s -o /dev/null -c - http://127.0.0.1:8000/api/csrf/ | awk '$6 == "csrftoken" {print $7}')"
curl -s -X POST http://127.0.0.1:8000/graphql/ \
-H 'content-type: application/json' \
-b "csrftoken=$TOKEN" -H "X-CSRFToken: $TOKEN" \
--data "$(jq -nc --arg q "$Q" '{query: $q}')" \
| jq '{attempts: (.data | length), errors: (.errors | length)}'
jq -n --arg wraps the query in a JSON request body and handles the quoting.
attempts: 60 means all 60 guesses ran inside one rate-limited request.
There's no built-in alias cap in Strawberry/Python, so you'll write one: a
small graphql-core validation rule that counts aliased fields and rejects a
request with too many. Put it in a new file graphql_lab/validation.py and
register it on the schema, then restart the service.
Hint 1
Subclass graphql.validation.ValidationRule. Implement enter_field(self, node, *_), count the ones where node.alias is set, and call
self.report_error(GraphQLError(...)) past your threshold.
Hint 2
Register the rule with the AddValidationRules([...]) extension (from
strawberry.extensions). Strawberry also ships MaxAliasesLimiter if you'd
rather use the built-in — but writing the rule is the point here.
5. A single deep query can exhaust the server
Post.author and User.posts reference each other, so a query can nest as
deep as a client likes. With no depth limit, one request can force an enormous
amount of work.
curl -s -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={ me{ posts{ author{ posts{ author{ posts{ author{ posts{ id }}}}}}}} }' \
| jq '{data_present: (.data != null), errors: (.errors | length)}'
data_present: true with no errors means the server resolved the whole chain
(vulnerable). A depth-limit error means it refused (fixed).
Add a query-depth limit to the schema, then restart the service. (A normal shallow query must still work.)
Hint
Another extension:
QueryDepthLimiter(max_depth=...)
from strawberry.extensions. Pick a limit that rejects the probe's nesting but
allows ordinary queries.
Done
You've taken a stock GraphQL service and closed the five weaknesses from Intigriti's Five easy ways to hack GraphQL targets: introspection, field suggestions, CSRF, alias-based rate-limit bypass, and unbounded query depth. Same service, hardened defaults.