fix: guard external hook content (#1827) (thanks @mertcicekci0)

This commit is contained in:
Peter Steinberger 2026-01-26 13:31:31 +00:00
parent e3bb045055
commit d3486b344d
11 changed files with 124 additions and 4 deletions

View File

@ -38,6 +38,7 @@ Status: unreleased.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
## 2026.1.24-3

View File

@ -83,6 +83,8 @@ Notes:
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
- Fallback order: `hooks.gmail.model``agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
- Gmail hook content is wrapped with external-content safety boundaries by default.
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).

View File

@ -96,6 +96,8 @@ Mapping options (summary):
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp).
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
(dangerous; only for trusted internal sources).
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
@ -148,3 +150,6 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- Avoid including sensitive raw payloads in webhook logs.
- Hook payloads are treated as untrusted and wrapped with safety boundaries by default.
If you must disable this for a specific hook, set `allowUnsafeExternalContent: true`
in that hook's mapping (dangerous).

View File

@ -18,6 +18,8 @@ export type HookMappingConfig = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
/** DANGEROUS: Disable external content safety wrapping for this hook. */
allowUnsafeExternalContent?: boolean;
channel?:
| "last"
| "whatsapp"
@ -48,6 +50,8 @@ export type HooksGmailConfig = {
includeBody?: boolean;
maxBytes?: number;
renewEveryMinutes?: number;
/** DANGEROUS: Disable external content safety wrapping for Gmail hooks. */
allowUnsafeExternalContent?: boolean;
serve?: {
bind?: string;
port?: number;

View File

@ -16,6 +16,7 @@ export const HookMappingSchema = z
messageTemplate: z.string().optional(),
textTemplate: z.string().optional(),
deliver: z.boolean().optional(),
allowUnsafeExternalContent: z.boolean().optional(),
channel: z
.union([
z.literal("last"),
@ -97,6 +98,7 @@ export const HooksGmailSchema = z
includeBody: z.boolean().optional(),
maxBytes: z.number().int().positive().optional(),
renewEveryMinutes: z.number().int().positive().optional(),
allowUnsafeExternalContent: z.boolean().optional(),
serve: z
.object({
bind: z.string().optional(),

View File

@ -308,6 +308,80 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("wraps external hook content by default", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({ kind: "agentTurn", message: "Hello" }),
message: "Hello",
sessionKey: "hook:gmail:msg-1",
lane: "cron",
});
expect(res.status).toBe("ok");
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as { prompt?: string };
expect(call?.prompt).toContain("EXTERNAL, UNTRUSTED");
expect(call?.prompt).toContain("Hello");
});
});
it("skips external content wrapping when hooks.gmail opts out", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
hooks: {
gmail: {
allowUnsafeExternalContent: true,
},
},
}),
deps,
job: makeJob({ kind: "agentTurn", message: "Hello" }),
message: "Hello",
sessionKey: "hook:gmail:msg-2",
lane: "cron",
});
expect(res.status).toBe("ok");
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as { prompt?: string };
expect(call?.prompt).not.toContain("EXTERNAL, UNTRUSTED");
expect(call?.prompt).toContain("Hello");
});
});
it("ignores hooks.gmail.model when not in the allowlist", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);

View File

@ -242,10 +242,15 @@ export async function runCronIsolatedAgentTurn(params: {
const formattedTime =
formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString();
const timeLine = `Current time: ${formattedTime} (${userTimezone})`;
const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
// SECURITY: Wrap external hook content with security boundaries to prevent prompt injection.
// External content (emails, webhooks) should never be treated as trusted instructions.
// SECURITY: Wrap external hook content with security boundaries to prevent prompt injection
// unless explicitly allowed via a dangerous config override.
const isExternalHook = isExternalHookSession(baseSessionKey);
const allowUnsafeExternalContent =
agentPayload?.allowUnsafeExternalContent === true ||
(isGmailHook && params.cfg.hooks?.gmail?.allowUnsafeExternalContent === true);
const shouldWrapExternal = isExternalHook && !allowUnsafeExternalContent;
let commandBody: string;
if (isExternalHook) {
@ -258,7 +263,9 @@ export async function runCronIsolatedAgentTurn(params: {
`${suspiciousPatterns.slice(0, 3).join(", ")}`,
);
}
}
if (shouldWrapExternal) {
// Wrap external content with security boundaries
const hookType = getHookType(baseSessionKey);
const safeContent = buildSafeExternalPrompt({
@ -272,7 +279,6 @@ export async function runCronIsolatedAgentTurn(params: {
commandBody = `${safeContent}\n\n${timeLine}`.trim();
} else {
// Internal/trusted source - use original format
const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
commandBody = `${base}\n${timeLine}`.trim();
}

View File

@ -19,6 +19,7 @@ export type CronPayload =
model?: string;
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
deliver?: boolean;
channel?: CronMessageChannel;
to?: string;
@ -33,6 +34,7 @@ export type CronPayloadPatch =
model?: string;
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
deliver?: boolean;
channel?: CronMessageChannel;
to?: string;

View File

@ -19,6 +19,7 @@ export type HookMappingResolved = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
allowUnsafeExternalContent?: boolean;
channel?: HookMessageChannel;
to?: string;
model?: string;
@ -52,6 +53,7 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat";
sessionKey?: string;
deliver?: boolean;
allowUnsafeExternalContent?: boolean;
channel?: HookMessageChannel;
to?: string;
model?: string;
@ -90,6 +92,7 @@ type HookTransformResult = Partial<{
name: string;
sessionKey: string;
deliver: boolean;
allowUnsafeExternalContent: boolean;
channel: HookMessageChannel;
to: string;
model: string;
@ -103,11 +106,22 @@ type HookTransformFn = (
export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] {
const presets = hooks?.presets ?? [];
const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent;
const mappings: HookMappingConfig[] = [];
if (hooks?.mappings) mappings.push(...hooks.mappings);
for (const preset of presets) {
const presetMappings = hookPresetMappings[preset];
if (presetMappings) mappings.push(...presetMappings);
if (!presetMappings) continue;
if (preset === "gmail" && typeof gmailAllowUnsafe === "boolean") {
mappings.push(
...presetMappings.map((mapping) => ({
...mapping,
allowUnsafeExternalContent: gmailAllowUnsafe,
})),
);
continue;
}
mappings.push(...presetMappings);
}
if (mappings.length === 0) return [];
@ -175,6 +189,7 @@ function normalizeHookMapping(
messageTemplate: mapping.messageTemplate,
textTemplate: mapping.textTemplate,
deliver: mapping.deliver,
allowUnsafeExternalContent: mapping.allowUnsafeExternalContent,
channel: mapping.channel,
to: mapping.to,
model: mapping.model,
@ -220,6 +235,7 @@ function buildActionFromMapping(
wakeMode: mapping.wakeMode ?? "now",
sessionKey: renderOptional(mapping.sessionKey, ctx),
deliver: mapping.deliver,
allowUnsafeExternalContent: mapping.allowUnsafeExternalContent,
channel: mapping.channel,
to: renderOptional(mapping.to, ctx),
model: renderOptional(mapping.model, ctx),
@ -256,6 +272,10 @@ function mergeAction(
name: override.name ?? baseAgent?.name,
sessionKey: override.sessionKey ?? baseAgent?.sessionKey,
deliver: typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver,
allowUnsafeExternalContent:
typeof override.allowUnsafeExternalContent === "boolean"
? override.allowUnsafeExternalContent
: baseAgent?.allowUnsafeExternalContent,
channel: override.channel ?? baseAgent?.channel,
to: override.to ?? baseAgent?.to,
model: override.model ?? baseAgent?.model,

View File

@ -46,6 +46,7 @@ type HookDispatchers = {
model?: string;
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
}) => string;
};
@ -173,6 +174,7 @@ export function createHooksRequestHandler(
model: mapped.action.model,
thinking: mapped.action.thinking,
timeoutSeconds: mapped.action.timeoutSeconds,
allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent,
});
sendJson(res, 202, { ok: true, runId });
return true;

View File

@ -41,6 +41,7 @@ export function createGatewayHooksRequestHandler(params: {
model?: string;
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
}) => {
const sessionKey = value.sessionKey.trim() ? value.sessionKey.trim() : `hook:${randomUUID()}`;
const mainSessionKey = resolveMainSessionKeyFromConfig();
@ -64,6 +65,7 @@ export function createGatewayHooksRequestHandler(params: {
deliver: value.deliver,
channel: value.channel,
to: value.to,
allowUnsafeExternalContent: value.allowUnsafeExternalContent,
},
state: { nextRunAtMs: now },
};