Description
A recursive user-defined function overflows the goroutine stack. In Go this is a
fatal error: stack overflow, not a panic — recover() cannot catch it, so it takes
the calling process down with it.
This matters more than a normal crash for two reasons:
- The README states "It never panics (enforced by fuzz tests)". That reads as a safety
guarantee, and a caller who believes it will run untrusted queries without sandboxing.
The fuzz tests appear to cover malformed input; they don't cover hostile queries.
- There are already depth guards for other recursion paths (
maxRecurseDepth,
jsonParseDepthLimit), so the intent to bound recursion seems established — user-defined
function calls just aren't covered.
There is no mitigation available to a caller. You cannot recover() it, and you cannot
inspect a compiled *Program to see whether it recurses, so the only defence would be to
run every query in a separate process.
For context on why we care: we hit this while evaluating fastjq as a gojq replacement in
the Datadog Agent, where jq programs arrive from remote configuration and are executed
in-process. A single such program is a remote process-kill.
Reproducer
Query:
Input:
Expected output:
jq 1.8.1 and gojq both terminate with a normal error. jq:
$ echo '{"a":1}' | jq 'def f: f; f'
jq: error: Recursion depth exceeded
At minimum, fastjq should return an error from Run/RunAll/RunFunc.
Actual output:
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0x14020160490 stack=[0x14020160000, 0x14040160000]
fatal error: stack overflow
goroutine 1 gp=0x140000021c0 m=9 mp=0x14000a80008 [running]:
github.com/DataDog/fastjq.bindCallContexts(...)
.../fastjq/exec.go:5868 +0x764
github.com/DataDog/fastjq.execCall(...)
.../fastjq/exec.go:5841 +0xe8
github.com/DataDog/fastjq.execMulti(...)
.../fastjq/exec.go:284 +0x4c8
github.com/DataDog/fastjq.execCall.func1(...)
.../fastjq/exec.go:5848
github.com/DataDog/fastjq.execState.withExecContext(...)
.../fastjq/context.go:141
[repeats until the stack limit]
Minimal Go reproducer — note that the recover() never runs:
package main
import (
"fmt"
"github.com/DataDog/fastjq"
)
func main() {
defer func() {
// Never reached: a stack overflow is a fatal error, not a panic.
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
p, err := fastjq.Compile(`def f: f; f`)
if err != nil {
fmt.Println("compile error (acceptable outcome):", err)
return
}
out, err := p.RunAll([]byte(`{"a":1}`))
fmt.Println("returned:", out, err) // never reached
}
Other queries that compile and look likely to behave the same way
I did not run these, since each one costs a dead process, but they all Compile
successfully and have the same shape:
def f: .|f; f
def f(x): f(x); f(.)
[recurse(.)]
Worth covering whichever guard fixes this.
Suggested fix
A call-depth counter in execCall (or on execContext) that returns a normal error past
some limit, mirroring the existing maxRecurseDepth behaviour. A fuzz seed or unit test per
recursion shape above would keep it fixed.
Separately, I'd suggest narrowing the README's "never panics" wording to what the fuzz
tests actually establish (e.g. "never panics on malformed input"), so callers don't infer a
guarantee for untrusted queries.
Environment
- fastjq version:
c3336f252fa23bbda9a611024f6398904330cd9a (master, untagged)
- Go version:
go version go1.25.4 darwin/arm64
- OS / arch: macOS, arm64
Description
A recursive user-defined function overflows the goroutine stack. In Go this is a
fatal error: stack overflow, not a panic —recover()cannot catch it, so it takesthe calling process down with it.
This matters more than a normal crash for two reasons:
guarantee, and a caller who believes it will run untrusted queries without sandboxing.
The fuzz tests appear to cover malformed input; they don't cover hostile queries.
maxRecurseDepth,jsonParseDepthLimit), so the intent to bound recursion seems established — user-definedfunction calls just aren't covered.
There is no mitigation available to a caller. You cannot
recover()it, and you cannotinspect a compiled
*Programto see whether it recurses, so the only defence would be torun every query in a separate process.
For context on why we care: we hit this while evaluating fastjq as a gojq replacement in
the Datadog Agent, where jq programs arrive from remote configuration and are executed
in-process. A single such program is a remote process-kill.
Reproducer
Query:
Input:
{"a":1}Expected output:
jq 1.8.1 and gojq both terminate with a normal error. jq:
At minimum, fastjq should return an
errorfromRun/RunAll/RunFunc.Actual output:
Minimal Go reproducer — note that the
recover()never runs:Other queries that compile and look likely to behave the same way
I did not run these, since each one costs a dead process, but they all
Compilesuccessfully and have the same shape:
Worth covering whichever guard fixes this.
Suggested fix
A call-depth counter in
execCall(or onexecContext) that returns a normal error pastsome limit, mirroring the existing
maxRecurseDepthbehaviour. A fuzz seed or unit test perrecursion shape above would keep it fixed.
Separately, I'd suggest narrowing the README's "never panics" wording to what the fuzz
tests actually establish (e.g. "never panics on malformed input"), so callers don't infer a
guarantee for untrusted queries.
Environment
c3336f252fa23bbda9a611024f6398904330cd9a(master, untagged)go version go1.25.4 darwin/arm64