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: 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. - 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. - 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). - Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
## 2026.1.24-3 ## 2026.1.24-3

View File

@ -83,6 +83,8 @@ Notes:
- Per-hook `model`/`thinking` in the mapping still overrides these defaults. - 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). - 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. - 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 To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)). 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. - 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 - Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp). (`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`. - `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. 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. - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens. - Use a dedicated hook token; do not reuse gateway auth tokens.
- Avoid including sensitive raw payloads in webhook logs. - 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; messageTemplate?: string;
textTemplate?: string; textTemplate?: string;
deliver?: boolean; deliver?: boolean;
/** DANGEROUS: Disable external content safety wrapping for this hook. */
allowUnsafeExternalContent?: boolean;
channel?: channel?:
| "last" | "last"
| "whatsapp" | "whatsapp"
@ -48,6 +50,8 @@ export type HooksGmailConfig = {
includeBody?: boolean; includeBody?: boolean;
maxBytes?: number; maxBytes?: number;
renewEveryMinutes?: number; renewEveryMinutes?: number;
/** DANGEROUS: Disable external content safety wrapping for Gmail hooks. */
allowUnsafeExternalContent?: boolean;
serve?: { serve?: {
bind?: string; bind?: string;
port?: number; port?: number;

View File

@ -16,6 +16,7 @@ export const HookMappingSchema = z
messageTemplate: z.string().optional(), messageTemplate: z.string().optional(),
textTemplate: z.string().optional(), textTemplate: z.string().optional(),
deliver: z.boolean().optional(), deliver: z.boolean().optional(),
allowUnsafeExternalContent: z.boolean().optional(),
channel: z channel: z
.union([ .union([
z.literal("last"), z.literal("last"),
@ -97,6 +98,7 @@ export const HooksGmailSchema = z
includeBody: z.boolean().optional(), includeBody: z.boolean().optional(),
maxBytes: z.number().int().positive().optional(), maxBytes: z.number().int().positive().optional(),
renewEveryMinutes: z.number().int().positive().optional(), renewEveryMinutes: z.number().int().positive().optional(),
allowUnsafeExternalContent: z.boolean().optional(),
serve: z serve: z
.object({ .object({
bind: z.string().optional(), 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 () => { it("ignores hooks.gmail.model when not in the allowlist", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const storePath = await writeSessionStore(home); const storePath = await writeSessionStore(home);

View File

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

View File

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

View File

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

View File

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

View File

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