Files
oh-my-opencode/src/hooks/claude-code-hooks/execute-http-hook.ts
T
YeonGyu-Kim 682a3c8515 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)
2026-03-02 15:20:31 +09:00

93 lines
2.5 KiB
TypeScript

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,
allowedEnvVars: string[]
): string {
const allowedSet = new Set(allowedEnvVars)
return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (_match, bracedVar: string | undefined, bareVar: string | undefined) => {
const varName = (bracedVar ?? bareVar) as string
if (allowedSet.has(varName)) {
return process.env[varName] ?? ""
}
return ""
})
}
function resolveHeaders(
hook: HookHttp
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (!hook.headers) return headers
const allowedEnvVars = hook.allowedEnvVars ?? []
for (const [key, value] of Object.entries(hook.headers)) {
headers[key] = interpolateEnvVars(value, allowedEnvVars)
}
return headers
}
export async function executeHttpHook(
hook: HookHttp,
stdin: string
): Promise<CommandResult> {
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)
try {
const response = await fetch(hook.url, {
method: "POST",
headers,
body: stdin,
signal: AbortSignal.timeout(timeoutS * 1000),
})
if (!response.ok) {
return {
exitCode: 1,
stderr: `HTTP hook returned status ${response.status}: ${response.statusText}`,
stdout: await response.text().catch(() => ""),
}
}
const body = await response.text()
if (!body) {
return { exitCode: 0, stdout: "", stderr: "" }
}
try {
const parsed = JSON.parse(body) as { exitCode?: number }
if (typeof parsed.exitCode === "number") {
return { exitCode: parsed.exitCode, stdout: body, stderr: "" }
}
} catch {
}
return { exitCode: 0, stdout: body, stderr: "" }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { exitCode: 1, stderr: `HTTP hook error: ${message}` }
}
}