Authenticate /hook/*, and make every hook a command hook
/hook/record and /hook/approve accepted anything that reached the port. That is not "loopback only": `tailscale serve` proxies tailnet traffic to 127.0.0.1, so anyone who could reach the tunnel could forge timeline events and answer approval prompts. Both endpoints now require a 32-byte secret from $GLANCE_HOME/hook.secret (0600, created once, never rotated so nothing in flight is 403'd mid-session), compared in constant time before the body is read, as an x-glance-hook header or a ?k= parameter. Requests carrying x-forwarded-* are refused outright: a local hook process never sends them and a tunnelled caller always does. The check applies to /hook/* only, so the dashboard is unaffected. While wiring that up: the 13 passive `type: "http"` hooks could never have worked. Grok Build's http runner rejects every scheme but https, then resolves the host and blocks private/link-local/CGNAT addresses (validate_hook_url + is_blocked_ip), so neither loopback-over-http nor *.ts.net (100.64/10) can be a hook target - and it sends no header but Content-Type, so such a hook could not authenticate anyway. They were failing validation silently on every event. All of them are now command hooks running bin/glance-record.mjs, which costs a Node start and can present the secret. hooks.json is generated from hooks/hooks.template.json by scripts/gen-hooks.mjs (npm run build, glance sync-hooks). It creates the secret, derives the approval hook's timeout from approval.timeoutMs instead of hand-copying 125, and refuses to write a hook that cannot fire: bad type, non-positive timeout, non-https http URL, missing bin/ script, or a leftover placeholder. A template that embeds the token makes the output 0600 with a warning. Fail-open is unchanged: a missing, stale or rejected secret degrades to "no telemetry", and glance-approve.mjs still allows on every error path. glance status warns when the on-disk secret no longer matches the daemon's. Validated with the e2e suite (190 checks, including no-token/wrong-token/ same-length-token 403s, ?k= acceptance, x-forwarded-* refusal, and the recorder's fail-open paths) and a clean npm run build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b3b6bf3f70
commit
5eec1940be
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate hooks/hooks.json from hooks/hooks.template.json.
|
||||
*
|
||||
* Runs as part of `npm run build`, and again on `glance sync-hooks`. Two jobs:
|
||||
*
|
||||
* 1. Make sure the /hook/* shared secret exists (mode 0600, in $GLANCE_HOME). The daemon
|
||||
* requires it; without it the hook scripts are just anonymous POSTs, which is what this
|
||||
* whole mechanism exists to stop.
|
||||
* 2. Substitute the placeholders the template declares, so values that are really derived
|
||||
* from config.json - the approval hook's timeout above all - stop being hand-copied
|
||||
* constants that drift.
|
||||
*
|
||||
* It also refuses to emit a hook that cannot work. An `http` handler pointed at a non-https
|
||||
* URL is the specific mistake that made every passive hook in this plugin a no-op for a
|
||||
* while: Grok Build's http runner puts every URL through SSRF validation and rejects any
|
||||
* other scheme outright (xai-grok-hooks/src/runner/http.rs, `validate_hook_url`).
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PLUGIN_ROOT, approvalHookTimeoutSecs, hookSecret, readConfig } from "../bin/glance-lib.mjs";
|
||||
|
||||
const TEMPLATE = path.join(PLUGIN_ROOT, "hooks", "hooks.template.json");
|
||||
const OUTPUT = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
|
||||
|
||||
const cfg = readConfig();
|
||||
const token = hookSecret({ create: true });
|
||||
if (!token) {
|
||||
console.error(`gen-hooks: could not create the hook secret in ${process.env.GLANCE_HOME ?? "~/.grok/glance"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const timeoutSecs = approvalHookTimeoutSecs(cfg);
|
||||
|
||||
let text = fs.readFileSync(TEMPLATE, "utf8");
|
||||
|
||||
// The quoted form first, so a JSON-valid template can carry a value that must end up numeric.
|
||||
text = text
|
||||
.split(`"{{APPROVAL_TIMEOUT_SECS}}"`)
|
||||
.join(String(timeoutSecs))
|
||||
.split("{{APPROVAL_TIMEOUT_SECS}}")
|
||||
.join(String(timeoutSecs));
|
||||
|
||||
const embedsToken = text.includes("{{HOOK_TOKEN}}");
|
||||
text = text.split("{{HOOK_TOKEN}}").join(token);
|
||||
|
||||
// Trust the emitted bytes, not the placeholder: a template that mentions the placeholder in a
|
||||
// comment would otherwise ship the real secret in a world-readable file. (It did once.)
|
||||
const tokenIsInOutput = text.includes(token);
|
||||
if (tokenIsInOutput && !embedsToken) {
|
||||
console.error("gen-hooks: the hook secret leaked into hooks.json from somewhere unexpected");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const leftover = text.match(/\{\{[A-Z_]+\}\}/);
|
||||
if (leftover) {
|
||||
console.error(`gen-hooks: unknown placeholder ${leftover[0]} in hooks.template.json`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ validate */
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(text);
|
||||
} catch (err) {
|
||||
console.error(`gen-hooks: template did not produce valid JSON: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
let handlers = 0;
|
||||
|
||||
for (const [event, groups] of Object.entries(doc.hooks ?? {})) {
|
||||
if (!Array.isArray(groups)) {
|
||||
problems.push(`${event}: expected an array of matcher groups`);
|
||||
continue;
|
||||
}
|
||||
for (const group of groups) {
|
||||
for (const h of group.hooks ?? []) {
|
||||
handlers++;
|
||||
const where = `${event} -> ${h.command ?? h.url ?? "(no target)"}`;
|
||||
if (h.type !== "command" && h.type !== "http") {
|
||||
problems.push(`${where}: type must be "command" or "http", got ${JSON.stringify(h.type)}`);
|
||||
continue;
|
||||
}
|
||||
if (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0) {
|
||||
problems.push(`${where}: timeout must be a positive number of seconds`);
|
||||
}
|
||||
if (h.type === "http") {
|
||||
// The runner rejects every scheme but https, and treats RFC1918 / CGNAT / link-local
|
||||
// targets as SSRF. That rules out both loopback-over-http and Tailscale's 100.64/10.
|
||||
if (!/^https:\/\//.test(h.url ?? "")) {
|
||||
problems.push(
|
||||
`${where}: http handlers must use an https:// URL - Grok Build's SSRF check ` +
|
||||
`rejects anything else, so this hook would never fire`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const script = /bin\/([A-Za-z0-9._-]+)/.exec(h.command ?? "");
|
||||
if (!script) {
|
||||
problems.push(`${where}: could not tell which script this command runs`);
|
||||
} else if (!fs.existsSync(path.join(PLUGIN_ROOT, "bin", script[1]))) {
|
||||
problems.push(`${where}: bin/${script[1]} does not exist`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error("gen-hooks: refusing to write hooks.json\n");
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- write */
|
||||
|
||||
const mode = tokenIsInOutput ? 0o600 : 0o644;
|
||||
const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : null;
|
||||
|
||||
if (previous === text) {
|
||||
// Leave the mtime alone: a no-op build should not look like a change.
|
||||
fs.chmodSync(OUTPUT, mode);
|
||||
console.log(`hooks.json already current (${handlers} handlers, approval timeout ${timeoutSecs}s)`);
|
||||
} else {
|
||||
fs.writeFileSync(OUTPUT, text, { mode });
|
||||
fs.chmodSync(OUTPUT, mode);
|
||||
console.log(
|
||||
`wrote hooks/hooks.json - ${handlers} handlers, approval timeout ${timeoutSecs}s` +
|
||||
(previous === null ? " (new file)" : ""),
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenIsInOutput) {
|
||||
console.warn(
|
||||
"gen-hooks: hooks.json now contains the hook secret (via the HOOK_TOKEN placeholder); it is " +
|
||||
"mode 0600 and must not be committed.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user