Refine HTTP hook redirect enforcement

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-03 17:30:57 +09:00
parent d081e8ef4f
commit b8f4037622
2 changed files with 22 additions and 4 deletions
@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types"
@@ -82,6 +84,20 @@ describe("executeHttpHook TLS security", () => {
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it("#when hook uses http://localhost #then does not log insecure warning", async () => {
mock.module("../../shared", () => ({
log: mockLog,
}))
mockLog.mockReset()
const { executeHttpHook } = await importFreshExecuteHttpHook()
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(0)
expect(mockLog).not.toHaveBeenCalled()
})
it("#when hook uses http://127.0.0.1 #then allows execution", async () => {
const { executeHttpHook } = await import("./execute-http-hook")
const hook: HookHttp = { type: "http", url: "http://127.0.0.1:8080/hooks" }
@@ -139,7 +155,7 @@ describe("executeHttpHook TLS security", () => {
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it("#when hook uses plain http:// URL #then writes warning log", async () => {
it("#when hook uses plain remote http:// URL #then writes warning log", async () => {
mock.module("../../shared", () => ({
log: mockLog,
}))
@@ -187,7 +203,7 @@ describe("executeHttpHook TLS security", () => {
process.env = { ...originalEnv, NODE_ENV: "production" }
})
it("#when hook uses https:// URL #then fetch uses manual redirect handling", async () => {
it("#when hook uses https:// URL #then fetch rejects redirects manually", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(new Response("redirect", { status: 302, statusText: "Found" }))
)
@@ -4,7 +4,7 @@ import { log } from "../../shared"
const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30
const ALLOWED_SCHEMES = new Set(["http:", "https:"])
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"])
function isLocalhost(url: URL): boolean {
return LOCALHOST_HOSTNAMES.has(url.hostname)
@@ -64,8 +64,8 @@ export async function executeHttpHook(
}
if (isPlainHttp(parsed)) {
log("HTTP hook URL uses insecure protocol", { url: hook.url })
if (!isLocalhost(parsed)) {
log("HTTP hook URL uses insecure protocol", { url: hook.url })
return {
exitCode: 1,
stderr: "HTTP hook URL must use HTTPS. Plain HTTP is only allowed for localhost, 127.0.0.1, and ::1.",
@@ -81,6 +81,7 @@ export async function executeHttpHook(
method: "POST",
headers,
body: stdin,
// Reject all redirects so HTTPS hooks cannot be silently rewritten to a different origin or protocol.
redirect: "manual",
signal: AbortSignal.timeout(timeoutS * 1000),
})
@@ -104,6 +105,7 @@ export async function executeHttpHook(
return { exitCode: parsed.exitCode, stdout: body, stderr: "" }
}
} catch {
// Non-JSON bodies are allowed and returned as stdout below.
}
return { exitCode: 0, stdout: body, stderr: "" }