From 682a3c8515759ee6bc16a724b0e90bf973f966e2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 14:48:35 +0900 Subject: [PATCH] fix(hooks): prevent SSRF via URL scheme validation and extend disable mechanism to HTTP hooks - Restrict HTTP hook URLs to http: and https: schemes only (blocks file://, data://, ftp://) - Extend hook disable config to cover HTTP hooks by matching against hook URL identifier - Update all 5 hook executors (pre-tool-use, post-tool-use, stop, pre-compact, user-prompt-submit) - Add 6 new tests for URL scheme validation (file, data, ftp rejection + http, https, invalid URL) --- .../execute-http-hook.test.ts | 78 +++++++++++++++++-- .../claude-code-hooks/execute-http-hook.ts | 13 ++++ src/hooks/claude-code-hooks/post-tool-use.ts | 6 +- src/hooks/claude-code-hooks/pre-compact.ts | 6 +- src/hooks/claude-code-hooks/pre-tool-use.ts | 6 +- src/hooks/claude-code-hooks/stop.ts | 7 +- .../claude-code-hooks/user-prompt-submit.ts | 7 +- 7 files changed, 102 insertions(+), 21 deletions(-) diff --git a/src/hooks/claude-code-hooks/execute-http-hook.test.ts b/src/hooks/claude-code-hooks/execute-http-hook.test.ts index bc7e1f598..682611875 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -33,7 +33,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) expect(mockFetch).toHaveBeenCalledTimes(1) - const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe("http://localhost:8080/hooks/pre-tool-use") expect(options.method).toBe("POST") expect(options.body).toBe(stdinData) @@ -44,7 +44,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Content-Type"]).toBe("application/json") }) @@ -72,7 +72,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -88,7 +88,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -104,7 +104,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer ") }) @@ -121,11 +121,77 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] expect(options.signal).toBeDefined() }) }) + describe("#given hook URL scheme validation", () => { + it("#when URL uses file:// scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "file:///etc/passwd" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "file:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses data: scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "data:text/plain,hello" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "data:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses ftp:// scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "ftp://localhost/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "ftp:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses http:// scheme #then allows hook execution", async () => { + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("#when URL uses https:// scheme #then allows hook execution", async () => { + const hook: HookHttp = { type: "http", url: "https://example.com/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("#when URL is invalid #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "not-a-valid-url" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("HTTP hook URL is invalid: not-a-valid-url") + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + describe("#given a successful HTTP response", () => { it("#when response has JSON body #then returns parsed output", async () => { mockFetch.mockImplementation(() => diff --git a/src/hooks/claude-code-hooks/execute-http-hook.ts b/src/hooks/claude-code-hooks/execute-http-hook.ts index 3ad2c5e57..1e72817cf 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.ts @@ -2,6 +2,7 @@ import type { HookHttp } from "./types" import type { CommandResult } from "../../shared/command-executor/execute-hook-command" const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30 +const ALLOWED_SCHEMES = new Set(["http:", "https:"]) export function interpolateEnvVars( value: string, @@ -39,6 +40,18 @@ export async function executeHttpHook( hook: HookHttp, stdin: string ): Promise { + try { + const parsed = new URL(hook.url) + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + return { + exitCode: 1, + stderr: `HTTP hook URL scheme "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`, + } + } + } catch { + return { exitCode: 1, stderr: `HTTP hook URL is invalid: ${hook.url}` } + } + const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S const headers = resolveHeaders(hook) diff --git a/src/hooks/claude-code-hooks/post-tool-use.ts b/src/hooks/claude-code-hooks/post-tool-use.ts index b119252c2..3ba1f7208 100644 --- a/src/hooks/claude-code-hooks/post-tool-use.ts +++ b/src/hooks/claude-code-hooks/post-tool-use.ts @@ -96,12 +96,12 @@ export async function executePostToolUseHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) { - log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PostToolUse", hookName, extendedConfig ?? null)) { + log("PostToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName }) continue } - const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) diff --git a/src/hooks/claude-code-hooks/pre-compact.ts b/src/hooks/claude-code-hooks/pre-compact.ts index 09d2425e0..a3aa01b62 100644 --- a/src/hooks/claude-code-hooks/pre-compact.ts +++ b/src/hooks/claude-code-hooks/pre-compact.ts @@ -52,12 +52,12 @@ export async function executePreCompactHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) { - log("PreCompact hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PreCompact", hookName, extendedConfig ?? null)) { + log("PreCompact hook command skipped (disabled by config)", { command: hookName }) continue } - const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index ec16369ec..97bfaf04a 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -79,12 +79,12 @@ export async function executePreToolUseHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) { - log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PreToolUse", hookName, extendedConfig ?? null)) { + log("PreToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName }) continue } - const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) diff --git a/src/hooks/claude-code-hooks/stop.ts b/src/hooks/claude-code-hooks/stop.ts index 81cf821b9..5b4423eb8 100644 --- a/src/hooks/claude-code-hooks/stop.ts +++ b/src/hooks/claude-code-hooks/stop.ts @@ -4,7 +4,7 @@ import type { ClaudeHooksConfig, } from "./types" import { findMatchingHooks, log } from "../../shared" -import { dispatchHook } from "./dispatch-hook" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { getTodoPath } from "./todo" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -70,8 +70,9 @@ export async function executeStopHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) { - log("Stop hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("Stop", hookName, extendedConfig ?? null)) { + log("Stop hook command skipped (disabled by config)", { command: hookName }) continue } diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts index 5f1cbc2e4..e714eb6bd 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -4,7 +4,7 @@ import type { ClaudeHooksConfig, } from "./types" import { findMatchingHooks, log } from "../../shared" -import { dispatchHook } from "./dispatch-hook" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" const USER_PROMPT_SUBMIT_TAG_OPEN = "" @@ -82,8 +82,9 @@ export async function executeUserPromptSubmitHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) { - log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("UserPromptSubmit", hookName, extendedConfig ?? null)) { + log("UserPromptSubmit hook command skipped (disabled by config)", { command: hookName }) continue }