HTTP Hooks and Policies
Every HTTP and HTTPS request from the guest is parsed on the host before it is sent upstream, so you can decide per request whether it may go out, rewrite it, answer it yourself, or rewrite the response. There are three ways to program that policy:
| What | Runs in | Use it for | |
|---|---|---|---|
| Declarative policy | createHttpHooks() options: host allowlists, internal hosts, secrets |
the Gondolin daemon (gondolind) |
everything it can express; it costs nothing per request |
| Tier-2 policies | self-contained JavaScript, createDjsHooks() or --hooks-script |
the daemon, in a sandboxed JavaScript engine | path/method/header rules, small rewrites; also from the CLI and with remote daemons |
| Hooks in your process | isRequestAllowed, isIpAllowed, onRequest, onResponse callbacks |
your Node.js process | anything that needs your program's state, Node.js APIs or I/O (fetch, databases, logging) |
They combine: declarative policy always runs first, then a tier-2 policy, then your callbacks (see Order of Evaluation). Prefer the cheapest tier that can express a rule.
import { VM, createDjsHooks, createHttpHooks } from "@earendil-works/gondolin";
const { httpHooks, env } = createHttpHooks({
// declarative: evaluated by the daemon
allowedHosts: ["api.example.com"],
secrets: {
API_KEY: { hosts: ["api.example.com"], value: process.env.API_KEY! },
},
// tier 2: runs in the daemon
...createDjsHooks(`
function onRequest(request) {
if (new URL(request.url).pathname.startsWith("/admin")) {
throw new HttpRequestBlockedError("not allowed", 403);
}
}
`),
// tier 3: runs in this process
onResponse: (response, request) => {
console.log(request.method, request.url, response.status);
},
});
const vm = await VM.create({ httpHooks, env });What the guest can reach without any hooks, and options such as DNS, mapped TCP and body limits, are covered in Network Access. Secret placeholders are covered in Secrets.
Declarative Policy
createHttpHooks(options) returns { httpHooks, env, allowedHosts,
secretManager }. Pass httpHooks and env to VM.create(); the daemon
evaluates the declarative options itself, without calling back into your
process.
import { VM, createHttpHooks } from "@earendil-works/gondolin";
const { httpHooks, env } = createHttpHooks({
allowedHosts: ["api.github.com", "*.githubusercontent.com"],
allowedInternalHosts: ["git.corp.example"],
secrets: {
GITHUB_TOKEN: {
hosts: ["api.github.com"],
value: process.env.GITHUB_TOKEN!,
},
},
});
const vm = await VM.create({ httpHooks, env });
const result = await vm.exec(
'curl -sS -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user',
);
console.log(result.stdout);
await vm.close();| Option | Meaning |
|---|---|
allowedHosts |
host patterns (* wildcards) mediated requests may go to; omitted: all hosts, []: none (see Allowing Hosts) |
allowedInternalHosts |
host patterns that may resolve to internal addresses; implicitly allowed (see Internal Hosts) |
blockInternalRanges |
block internal addresses for all other hosts (default: true) |
secrets |
secrets by env var name: { hosts, value, placeholder? } (see Secrets) |
replaceSecretsInQuery |
also substitute placeholders in the URL query string (default: false) |
secretPlaceholderMode |
"shared" (default) or "unique" placeholder format |
isRequestAllowed, isIpAllowed, onRequest, onResponse |
callbacks: your own functions (tier 3) or a spread createDjsHooks() policy (tier 2) |
The CLI flags --allow-host and --host-secret build the same declarative
policy.
Pass the returned httpHooks object as it is. Spreading it
({ ...httpHooks, onResponse }) or replacing one of its methods still works,
but the daemon can no longer see the declarative options and calls the
generated functions in your process for every request instead. Add callbacks
through the createHttpHooks() options instead.
Hooks in Your Process
Hooks are ordinary JavaScript functions in your Node.js process. The daemon
sends each request phase that has a hook to your process and waits for the
answer. Pass them as createHttpHooks() options (combined with the declarative
policy, recommended) or directly as VM.create({ httpHooks: { ... } }).
import {
HttpRequestBlockedError,
VM,
createHttpHooks,
} from "@earendil-works/gondolin";
const { httpHooks, env } = createHttpHooks({
allowedHosts: ["httpbin.org"],
isRequestAllowed: (request) => request.method !== "DELETE",
isIpAllowed: ({ ip }) => !ip.startsWith("203.0.113."),
onRequest: async (request) => {
if (request.method === "POST") {
const body = await request.clone().text();
if (body.includes("forbidden")) {
throw new HttpRequestBlockedError("body rejected", 422, "Unprocessable Content");
}
}
request.headers.set("x-sandbox", "gondolin");
},
onResponse: (response, request) => {
console.log(request.url, response.status);
},
});
const vm = await VM.create({ httpHooks, env });
await vm.exec("curl -sS -X POST -d hello https://httpbin.org/post");
await vm.close();All four hooks may be synchronous or return a promise.
isRequestAllowed
isRequestAllowed(request) receives a WHATWG Request without body (body is
always null) and returns true to allow it or false to deny it with
403 Forbidden. It is called for each request and redirect hop after the
onRequest hooks ran, and, when no onRequest hook is registered, also
before the request body is read.
isIpAllowed
isIpAllowed(info) is called for every address the request's host resolves to,
with { hostname, ip, family, port, protocol } (family is 4 or 6,
protocol is "http" or "https"). Addresses for which it returns false
are skipped; if none is left, the request is denied. It is checked again when
the upstream connection is opened (DNS rebinding protection).
How it combines with the internal-range block:
- With
createHttpHooks(), it only runs after the allowlist and the internal-range block allowed the address, so it can only narrow them. - Passed directly (
VM.create({ httpHooks: { isIpAllowed } })), it replaces the built-in internal-range block: returnfalsefor internal addresses yourself if you still want them blocked.
onRequest
onRequest(request) receives the WHATWG Request that is about to be sent
(absolute URL, headers, lazily read body) and returns:
undefinedto continue. Changes made torequest.headersin place are kept.- a
Requestto continue with a rewritten request (a different URL, method, headers or body). The rewritten request is checked against the allowlist and internal-range block again. - a
Responseto answer the guest directly ("short-circuit"). No upstream request is made, upstream DNS and IP checks are skipped, andonResponseis not called.
If onRequest makes its own fetch() from your process and returns that
Response, that request is outside Gondolin's egress policy: it goes out with
your process's network access.
GET and HEAD requests that carry a body are rejected with 400 when an
onRequest hook is registered.
onResponse
onResponse(response, request) receives the upstream Response and the
request that produced it (the final hop, after secret substitution). Return
undefined to forward the response unchanged or a Response to replace it.
Compressed upstream responses (Content-Encoding: gzip, deflate, ...) are
decoded for the hook, which sees them without Content-Encoding, like a
fetch() response. When the hook returns undefined the guest receives the
original, still compressed, bytes.
Request and Response Bodies
Bodies are transferred to your process only when a hook reads them
(text(), json(), arrayBuffer(), streaming body); an unread body is
streamed through by the daemon.
- Request bodies are one-shot streams. To inspect a body and still forward the
request, read from
request.clone(); a clone reads independently instead of buffering a second copy. Returning (or continuing with) a request whose body the hook consumed is refused with400 Bad Request. - A response body read in
onResponseis forwarded as received when the hook returnsundefined. - Bodies read by hooks are limited by
maxHttpBodyBytes(requests,413) andmaxHttpResponseBodyBytes(responses,502); both default to 64 MiB (see Body Size Limits).
Blocking and Errors
Throw HttpRequestBlockedError(message, status = 403, statusText =
"Forbidden") from any hook to answer the guest with that status and message.
Any other exception, a rejected promise or an invalid return value fails
closed: the guest gets 502 Bad Gateway.
Round-Trip Cost
Each registered hook phase costs one round trip between the daemon and your
process per request (and per redirect hop); phases without a hook cost
nothing. On an Apple M5 Max a no-op onRequest/onResponse pair adds about
0.29 ms per request. Hooks run on your process's event loop, so slow hooks
delay the requests waiting behind them. Move rules that do not need your
process into a tier-2 policy or the declarative options.
Tier-2 Policies
A tier-2 policy is self-contained JavaScript that the daemon runs itself, on
djs (Earendil's embeddable JavaScript engine, compiled into gondolind). It
uses the same hook names and return values as hooks in your process, but needs
no round trip to your process: it is cheaper per request and also works with
the static gondolin CLI and with remote daemons.
import { VM, createDjsHooks, createHttpHooks } from "@earendil-works/gondolin";
const policy = `
const DENY = ["/admin", "/internal"];
async function onRequest(request) {
const url = new URL(request.url);
if (DENY.some((p) => url.pathname.startsWith(p))) {
throw new HttpRequestBlockedError("path not allowed", 403);
}
if (request.method === "POST") {
const body = await request.clone().text();
if (body.includes("DROP TABLE")) return new Response("no", { status: 400 });
}
request.headers.set("x-sandbox", "gondolin");
}
function onResponse(response) {
const r = new Response(response.body, response);
r.headers.delete("server");
return r;
}
`;
const { httpHooks, env } = createHttpHooks({
allowedHosts: ["api.example.com"],
secrets: { API_KEY: { hosts: ["api.example.com"], value: process.env.API_KEY! } },
...createDjsHooks(policy),
});
const vm = await VM.create({ httpHooks, env });Spread the result of createDjsHooks() into createHttpHooks() to combine it
with allowlists and secrets, or pass it on its own as
VM.create({ httpHooks: createDjsHooks(policy) }). A VM can have one tier-2
policy.
From the CLI
Both CLIs take a policy file with --hooks-script FILE:
gondolin bash --allow-host api.example.com --hooks-script policy.js.js(and any other extension) is a script defining the hooks as global functions,.mjsan ES module exporting them,.ts/.mtsa TypeScript module.- For modules, the CLI reads the files the policy imports with relative
specifiers (
./lib/hosts.ts,./deny.json), transitively from next to the script, and sends them along; the daemon itself never reads files. Bare specifiers (lodash) and imports above the script's directory are not resolved. - It combines with
--allow-hostand--host-secret: the declarative policy runs first, and secrets are substituted after the hook.
For SDK users, djsHooksOptionsFromFile(file) does the same as the CLI and
returns options for createDjsHooks():
import { createDjsHooks, djsHooksOptionsFromFile } from "@earendil-works/gondolin";
const hooks = createDjsHooks(djsHooksOptionsFromFile("./policy/index.ts"));The Contract
A policy defines (script) or exports (module, kind: "module" or
"typescript") any of isRequestAllowed(request), isIpAllowed(info),
onRequest(request) and onResponse(response, request), with the same
meaning and return values as hooks in your process.
Only the phases it defines are registered, so a policy with only
onResponse keeps the fast path for requests.
// policy.mjs
import deny from "./deny.json" with { type: "json" };
export function isRequestAllowed(request) {
return !deny.paths.some((p) => new URL(request.url).pathname.startsWith(p));
}Environment
The policy can use Request, Response, Headers, URL,
URLSearchParams, HttpRequestBlockedError, TextEncoder/TextDecoder,
atob/btoa, console (written to the daemon log, visible with the net
debug flag) and the standard JavaScript built-ins.
- Bodies stay in the daemon until the policy reads them (
text(),json(),bytes(),arrayBuffer()); an unread body costs nothing and is streamed through. - As in your process, forwarding a request whose body the hook consumed is
refused (
400): read fromrequest.clone(). - Compressed responses (
gzip,deflate) are decoded foronResponse, as for hooks in your process.
Options
createDjsHooks(sourceOrOptions) takes the source string or an options
object:
| Option | Meaning |
|---|---|
source |
the policy (or pass the source string directly) |
kind |
"script" (default), "module" or "typescript" (an ES module whose types are erased, not checked) |
filename |
name in stack traces and the module's key (default policy.js / policy.mjs / policy.ts) |
modules |
{ key: source } a module policy can import (./lib.js resolves against filename); the key's extension picks the language: .ts/.mts/.cts TypeScript, .json a JSON module (import deny from "./deny.json" with { type: "json" }), else JavaScript. There is no file or network access: anything else must be bundled into the policy |
limits |
{ fuel, memoryBytes, timeoutMs } per call (defaults: 50M fuel ≈ 0.2 s of interpretation, 64 MiB heap, 30 s wall clock including body reads) |
const hooks = createDjsHooks({
kind: "typescript",
filename: "policy.ts",
source: `
import { ALLOWED } from "./hosts.ts";
export function isRequestAllowed(request: Request): boolean {
return ALLOWED.includes(new URL(request.url).hostname);
}
`,
modules: {
"hosts.ts": `export const ALLOWED: string[] = ["api.example.com"];`,
},
limits: { timeoutMs: 5_000 },
});Evaluation
The daemon compiles the policy once, when the VM is configured; a syntax
error, a missing import or an exception at the top level fails VM.create()
(or the CLI) before the VM boots. Every call then starts from that compiled
state: globals a call changes are gone in the next call, and nothing is shared
between requests or VMs. Builtins are frozen. Date.now() is the daemon's
clock at the start of the call.
Fail Closed
An exception, a rejected or never-settling promise, exhausting fuel, memory or
time, a malformed return value (an invalid header, a non-HTTP URL, a bad
status) or an engine error deny the request with 502 Bad Gateway, like a
throwing hook in your process. HttpRequestBlockedError answers with its own
status. The policy cannot catch the budget limits.
A daemon without tier-2 support (older releases, or built without the
djs-hooks feature) refuses a configuration with a tier-2 policy instead of
ignoring it.
Restrictions
Compared with hooks in your process:
- No closures over your program's state and no Node.js or npm APIs (
fs,process,Buffer,require): state comes from the source (generate it from your configuration) or frommodules. - No outbound I/O: no
fetch(), timers (setTimeout),AbortSignal,crypto.subtleorcrypto.randomUUID. - No streams: bodies are whole values within the body limits and the policy's
heap.
request.body/response.bodyis an opaque handle that can only be passed on (new Response(response.body, response)keeps the original body);formData(),blob()andReadableStreamare missing. Fetch-onlyRequestfields (signal,cache,mode,credentials, ...) are absent. instanceofchecks against Node.js's own classes do not apply;URL,Headersand friends are the daemon's implementations (URLs parse exactly as the egress pipeline parses them).
Calling the Functions Directly
The functions createDjsHooks() returns are ordinary HttpHooks. Called
directly in your process (outside a VM, for example in tests), a script policy
runs in a node:vm context with Node.js's own fetch classes; globals persist
between calls there, unlike in the daemon. Module and TypeScript policies only
run in the daemon and throw when called directly.
Performance
Typical cost on an Apple M5 Max (shared machine; host/bench, measured from
the guest per request): a no-op onRequest/onResponse pair adds about
0.23 ms per request as a tier-2 policy against 0.29 ms in your process, and a
policy reading 32 MiB bodies keeps ~415 MB/s downloads (in your process:
~325 MB/s). In the daemon itself, an empty call is ~8 µs and a header
allowlist policy ~38 µs (cargo run --release -p gondolin-hooks-djs --example
bench). Engine details and research notes are in
native/docs/djs-hooks.md.
Order of Evaluation
For each request (and again for each redirect hop):
- Request head. The daemon validates the request. When no
onRequesthook is registered in any tier,isRequestAllowed, the allowlist, the internal-range block andisIpAllowedare checked now, before the body is read, so a denied upload is refused early. onRequest. The tier-2 policy runs first, then your process's hook, which sees the request as tier 2 left it (including a replaced body). AResponseor a block in tier 2 ends the phase.- Secrets. Placeholders are substituted after all
onRequesthooks, so these hooks see placeholders, not secret values, and placeholders a hook adds are substituted too. - Policy checks on the final request:
isRequestAllowed(tier 2, then tier 3), the allowlist, the internal-range block, thenisIpAllowed(tier 2, then tier 3) for each resolved address. Every check must allow; the first denial wins. - Upstream request. The connection is opened to an allowed address and the address is checked again at connect time.
- Redirects are followed by the host; the next hop starts again at step 2.
onResponse. Tier 2 first, then your process's hook; aResponsefrom tier 2 is what tier 3 sees.
Because of steps 3, 4 and 6, hooks are not guaranteed to see only
placeholders: isRequestAllowed and onResponse receive the request after
substitution, and on redirect hops onRequest sees a request derived from the
previous hop, which may contain real secret values (cross-origin hops drop
Authorization and Cookie). Do not log request headers or URLs from hooks
unless that is acceptable. See Secrets.