fix: guard external hook content (#1827) (thanks @mertcicekci0)
This commit is contained in:
parent
e3bb045055
commit
d3486b344d
@ -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
|
||||||
|
|||||||
@ -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)).
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user