Files
viettranx a556161d0d feat(hooks/script): goja ES5.1 script handler + deny-all sandbox
Wave 1 Phase 02 — pure runtime + sandbox. Dispatcher wiring, edition gate,
and DB migration land in Phase 03.

Handler:
- internal/hooks/handlers/script.go — ScriptHandler with two-layer semaphore
  (global 10 / per-tenant 3), hashicorp/golang-lru v2 program cache (cap 500),
  InvalidateHook hook for Phase 03, watchdog goroutine interrupting on ctx
  cancel, sanitized error text (strips \n + goja " at <frame>" inline tails)
- internal/hooks/handlers/script_sandbox.go — deny-all/allowlist hardening,
  SetMaxCallStackSize(256), prototype-chain nullify running BEFORE deny pass
  so Function.prototype.constructor can still be reached
- internal/hooks/handlers/script_runtime.go — LRU compile, JSON round-trip +
  deep-freeze event binding, 4KiB truncating stdout capture, strict return
  parser enforcing {decision, reason?, updatedInput?}

Types:
- HandlerScript HandlerType = "script"
- DecisionAsk / DecisionDefer reserved (Wave 1 treats as block + warn)
- hooks.ScriptResult + WithScriptResult / ScriptResultFrom ctx helpers for
  Phase 03 dispatcher to apply builtin-source UpdatedInput

Tests (go test -race green):
- script_test.go — 12 unit cases (happy/error/timeout/ask/defer/truncate/H1)
- script_sandbox_corpus_test.go — 27 cases: escape primitives (constructor
  chain, Reflect, Proxy, Symbol, Promise, __proto__ walk, eval, Function,
  Date chain, JSON replacer, toJSON DoS), resource bombs (recursion, loop,
  memory, ReDoS), typeof sanity (Reflect/Proxy/Symbol/Promise/GoError/
  globalThis/eval/Function undefined), mutation defense (toolInput/rawInput
  frozen, Go-side map unchanged)

Deps: github.com/dop251/goja, github.com/hashicorp/golang-lru/v2.

Gates: go vet clean, go test -race clean, go build ./... + sqliteonly clean.
2026-04-16 14:17:47 +07:00

98 lines
4.2 KiB
Go

package handlers
import (
"fmt"
"github.com/dop251/goja"
)
// MaxCallStackSize caps goja's recursion depth. Prevents Go goroutine stack
// overflow on scripts like `function f(){f()}; f()`. Value 256 chosen because
// no legitimate hook script needs deeper recursion; attackers overflow at ~1K.
const MaxCallStackSize = 256
// DisabledGlobals — every global goja ships, verified against upstream
// builtin_global.go + builtin_typedarrays.go. Deny-all strategy: we re-enable
// only the names in AllowedGlobals after this pass so future goja releases
// that add new globals cannot leak escape primitives by default.
var DisabledGlobals = []string{
"Object", "Function", "Array", "String", "Number", "BigInt", "RegExp",
"Date", "Boolean", "Proxy", "Reflect", "Error", "AggregateError",
"TypeError", "ReferenceError", "SyntaxError", "RangeError", "EvalError",
"URIError", "GoError", "Math", "JSON", "Symbol", "WeakSet", "WeakMap",
"Map", "Set", "Promise", "globalThis", "NaN", "Infinity",
"isNaN", "parseInt", "parseFloat", "isFinite", "decodeURI",
"decodeURIComponent", "encodeURI", "encodeURIComponent", "escape",
"unescape", "eval",
// Typed arrays (builtin_typedarrays.go)
"ArrayBuffer", "DataView", "Uint8Array", "Uint8ClampedArray", "Int8Array",
"Uint16Array", "Int16Array", "Uint32Array", "Int32Array", "Float32Array",
"Float64Array", "BigInt64Array", "BigUint64Array",
}
// AllowedGlobals — minimal safe subset re-enabled after the deny pass.
// Scripts syntactically need `[]`, `{}`, `new Error("x")`. Prototype nullify
// covers the `.constructor.constructor` escape path through these allowed
// names.
var AllowedGlobals = []string{
"Math", "JSON", "Number", "String", "Boolean", "RegExp", "Date",
"Array", "Object", "Error", "TypeError", "RangeError", "SyntaxError",
"parseInt", "parseFloat", "isNaN", "isFinite",
"encodeURIComponent", "decodeURIComponent",
"Infinity", "NaN",
}
// prototypeNullifyScript breaks the `.constructor.constructor` prototype-escape
// chain. Even when Array/Object/Function are allowlisted, their constructor
// chain can yield a Function object that evals arbitrary source. Nulling the
// constructor slot on each prototype defeats that chain. try/catch wraps each
// write so a locked prototype from a future goja release does not abort the
// whole hardening sequence.
const prototypeNullifyScript = `
(function() {
try { Object.defineProperty(Array.prototype, 'constructor', {value: undefined, writable: false, configurable: false}); } catch (_) {}
try { Object.defineProperty(Object.prototype, 'constructor', {value: undefined, writable: false, configurable: false}); } catch (_) {}
if (typeof Function !== 'undefined' && Function.prototype) {
try { Object.defineProperty(Function.prototype, 'constructor', {value: undefined, writable: false, configurable: false}); } catch (_) {}
}
})();
`
// applyHardening runs the full sandbox sequence on a fresh runtime:
// 1. cap the recursion depth before any user frames exist,
// 2. nullify `.constructor` slots on the exposed prototypes (Array, Object,
// Function, and built-in function prototypes like Date/RegExp/Error/etc)
// BEFORE the deny pass — `Function` must still be globally accessible to
// reach `Function.prototype`,
// 3. undefine every ships-by-default global that is NOT in the allowlist.
//
// The allowlist items are left untouched — save+restore would fail on ES
// non-writable globals (NaN, Infinity, undefined). Leaving them at their
// original engine value is correct because the deny pass never touched them.
//
// Any failure propagates so the caller can fail closed with DecisionError
// rather than execute user code against a half-hardened runtime.
func applyHardening(rt *goja.Runtime) error {
rt.SetMaxCallStackSize(MaxCallStackSize)
// Step 2 first so `Function` is still reachable.
if _, err := rt.RunString(prototypeNullifyScript); err != nil {
return err
}
allow := make(map[string]struct{}, len(AllowedGlobals))
for _, n := range AllowedGlobals {
allow[n] = struct{}{}
}
for _, name := range DisabledGlobals {
if _, keep := allow[name]; keep {
continue
}
if err := rt.Set(name, goja.Undefined()); err != nil {
return fmt.Errorf("sandbox: disable %q: %w", name, err)
}
}
return nil
}