diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bab5cc..a9a68793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed -- Hardened the local session daemon against browser-originated requests by validating Host and Origin headers and requiring JSON content types for API posts. +- Hardened the local session daemon against browser-originated requests by validating Host, Origin, and Fetch Metadata headers, requiring JSON content types, and requiring the Hunk CLI marker header for API posts. - Disabled the generic broker HTTP API by default so Hunk's supported session API is the only app-daemon command surface. - Bounded session daemon memory by capping HTTP request body and websocket message sizes and rejecting session registrations with oversized file, hunk, patch, comment, or note payloads. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index ae4da543..f63d0f73 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -30,6 +30,7 @@ If `hunk session list` reports no sessions while Hunk is visibly running, the ag ```bash curl -s -X POST http://127.0.0.1:47657/session-api \ -H 'content-type: application/json' \ + -H 'x-hunk-session-client: hunk-cli' \ --data '{"action":"list"}' ``` diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts index 2e6273ab..88444e55 100644 --- a/src/hunk-session/cli.test.ts +++ b/src/hunk-session/cli.test.ts @@ -13,6 +13,8 @@ import type { SessionSelectorInput } from "../core/types"; import { HUNK_SESSION_API_PATH, HUNK_SESSION_API_VERSION, + HUNK_SESSION_CLIENT_HEADER, + HUNK_SESSION_CLIENT_HEADER_VALUE, HUNK_SESSION_DAEMON_VERSION, } from "../session/protocol"; import { @@ -105,6 +107,9 @@ describe("HTTP Hunk session CLI client", () => { expect(url).toEndWith(HUNK_SESSION_API_PATH); expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get(HUNK_SESSION_CLIENT_HEADER)).toBe( + HUNK_SESSION_CLIENT_HEADER_VALUE, + ); const request = JSON.parse(String(init?.body)); requests.push(request); return Response.json(responses[request.action as keyof typeof responses]); diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 2248c02b..82cac6f6 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -3,6 +3,8 @@ import type { SessionTerminalLocation, SessionTerminalMetadata } from "@hunk/ses import { readHunkSessionDaemonCapabilities } from "../session/capabilities"; import { HUNK_SESSION_API_PATH, + HUNK_SESSION_CLIENT_HEADER, + HUNK_SESSION_CLIENT_HEADER_VALUE, type SessionDaemonCapabilities, type SessionDaemonRequest, } from "../session/protocol"; @@ -70,6 +72,7 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { method: "POST", headers: { "content-type": "application/json", + [HUNK_SESSION_CLIENT_HEADER]: HUNK_SESSION_CLIENT_HEADER_VALUE, }, body: JSON.stringify(input), }); diff --git a/src/session-broker/brokerServer.test.ts b/src/session-broker/brokerServer.test.ts index 42ddf044..8e7ebc11 100644 --- a/src/session-broker/brokerServer.test.ts +++ b/src/session-broker/brokerServer.test.ts @@ -7,12 +7,18 @@ import { createTestSessionSnapshot, } from "../../test/helpers/session-daemon-fixtures"; import { SessionBrokerState } from "@hunk/session-broker-core"; +import { HUNK_SESSION_CLIENT_HEADER, HUNK_SESSION_CLIENT_HEADER_VALUE } from "../session/protocol"; import { serveSessionBrokerDaemon } from "./brokerServer"; const originalHost = process.env.HUNK_MCP_HOST; const originalPort = process.env.HUNK_MCP_PORT; const originalUnsafeRemote = process.env.HUNK_MCP_UNSAFE_ALLOW_REMOTE; +const SESSION_API_HEADERS = { + "content-type": "application/json", + [HUNK_SESSION_CLIENT_HEADER]: HUNK_SESSION_CLIENT_HEADER_VALUE, +}; + interface HealthResponse { ok: boolean; pid: number; @@ -291,7 +297,7 @@ describe("Hunk session daemon server", () => { expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ version: 1, - daemonVersion: 3, + daemonVersion: 4, actions: [ "list", "get", @@ -376,6 +382,98 @@ describe("Hunk session daemon server", () => { } }); + test("rejects browser Fetch Metadata for HTTP and websocket requests", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveSessionBrokerDaemon(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { "sec-fetch-site": "cross-site" }, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Browser request metadata is not allowed for the local session broker.", + }); + + const handshake = await readRawWebSocketHandshake(port, ["Sec-Fetch-Site: same-site"]); + expect(handshake).toStartWith("HTTP/1.1 403"); + } finally { + server.stop(true); + } + }); + + test("allows same-origin and none Fetch Metadata from a local Origin", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveSessionBrokerDaemon(); + + try { + for (const site of ["same-origin", "none"]) { + const response = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { + origin: `http://127.0.0.1:${port}`, + "sec-fetch-site": site, + }, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ ok: true }); + } + } finally { + server.stop(true); + } + }); + + test("rejects browser-looking requests that omit Origin", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveSessionBrokerDaemon(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + }, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Browser requests to the local session broker must include an Origin.", + }); + } finally { + server.stop(true); + } + }); + + test("requires the Hunk session client header for session API posts", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveSessionBrokerDaemon(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "list" }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Expected Hunk session client header.", + }); + } finally { + server.stop(true); + } + }); + test("requires JSON content type for session API posts", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; @@ -386,7 +484,7 @@ describe("Hunk session daemon server", () => { try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { "content-type": "text/plain" }, + headers: { ...SESSION_API_HEADERS, "content-type": "text/plain" }, body: JSON.stringify({ action: "list" }), }); @@ -409,7 +507,7 @@ describe("Hunk session daemon server", () => { try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "list", filler: "x".repeat(5 * 1024 * 1024) }), }); @@ -497,9 +595,7 @@ describe("Hunk session daemon server", () => { const emptyList = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { - "content-type": "application/json", - }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "list" }), }); expect(emptyList.status).toBe(200); @@ -509,9 +605,7 @@ describe("Hunk session daemon server", () => { try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { - "content-type": "application/json", - }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "list" }), }); @@ -660,9 +754,7 @@ describe("Hunk session daemon server", () => { try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { - "content-type": "application/json", - }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "review", selector: { sessionId: "session-1" }, @@ -721,9 +813,7 @@ describe("Hunk session daemon server", () => { try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { - "content-type": "application/json", - }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "reload", selector: { sessionPath: "/tmp/live-session" }, @@ -782,7 +872,7 @@ describe("Hunk session daemon server", () => { try { const listResponse = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "comment-list", selector: { sessionId: "session-1" }, @@ -855,9 +945,7 @@ describe("Hunk session daemon server", () => { try { const response = await fetch(`http://127.0.0.1:${port}/session-api`, { method: "POST", - headers: { - "content-type": "application/json", - }, + headers: SESSION_API_HEADERS, body: JSON.stringify({ action: "comment-apply", selector: { sessionId: "session-1" }, diff --git a/src/session-broker/brokerServer.ts b/src/session-broker/brokerServer.ts index 5103a234..f68cc563 100644 --- a/src/session-broker/brokerServer.ts +++ b/src/session-broker/brokerServer.ts @@ -34,6 +34,8 @@ import { HUNK_SESSION_API_PATH, HUNK_SESSION_API_VERSION, HUNK_SESSION_CAPABILITIES_PATH, + HUNK_SESSION_CLIENT_HEADER, + HUNK_SESSION_CLIENT_HEADER_VALUE, HUNK_SESSION_DAEMON_VERSION, type SessionDaemonAction, type SessionDaemonCapabilities, @@ -96,6 +98,51 @@ function jsonError(message: string, status = 400) { return Response.json({ error: message }, { status }); } +/** Block requests whose Sec-Fetch-Site indicates cross-site or same-site browser traffic. */ +function validateFetchMetadata(request: Request) { + const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase(); + if (!site) { + return null; + } + + if (site === "none" || site === "same-origin") { + return null; + } + + return jsonError("Browser request metadata is not allowed for the local session broker.", 403); +} + +/** Block browser-looking requests that carry Sec-* headers but omit an Origin. */ +function validateBrowserRequestOrigin(request: Request) { + if (request.headers.has("origin")) { + return null; + } + + const browserHeaderNames = [ + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + ]; + if (!browserHeaderNames.some((header) => request.headers.has(header))) { + return null; + } + + return jsonError("Browser requests to the local session broker must include an Origin.", 403); +} + +/** Require an intentional native-client header for the JSON command endpoint. */ +function validateSessionClientHeader(request: Request) { + if (request.headers.get(HUNK_SESSION_CLIENT_HEADER) === HUNK_SESSION_CLIENT_HEADER_VALUE) { + return null; + } + + return jsonError("Expected Hunk session client header.", 403); +} + /** Return whether one request body was explicitly sent as JSON. */ function hasJsonContentType(request: Request) { const contentType = request.headers.get("content-type"); @@ -213,6 +260,11 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R return jsonError("Session API requests must use POST.", 405); } + const clientHeaderError = validateSessionClientHeader(request); + if (clientHeaderError) { + return clientHeaderError; + } + if (!hasJsonContentType(request)) { return jsonError("Expected Content-Type application/json.", 415); } @@ -447,6 +499,16 @@ export function serveSessionBrokerDaemon( return originError; } + const fetchMetadataError = validateFetchMetadata(request); + if (fetchMetadataError) { + return fetchMetadataError; + } + + const browserOriginError = validateBrowserRequestOrigin(request); + if (browserOriginError) { + return browserOriginError; + } + const url = new URL(request.url); if (url.pathname === "/health") { diff --git a/src/session/protocol.ts b/src/session/protocol.ts index a9c4adec..575395b1 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -25,13 +25,15 @@ import type { export const HUNK_SESSION_API_PATH = "/session-api"; export const HUNK_SESSION_CAPABILITIES_PATH = `${HUNK_SESSION_API_PATH}/capabilities`; +export const HUNK_SESSION_CLIENT_HEADER = "x-hunk-session-client"; +export const HUNK_SESSION_CLIENT_HEADER_VALUE = "hunk-cli"; export const HUNK_SESSION_API_VERSION = 1; /** * Version daemon/session compatibility separately from the HTTP action surface so newer Hunk * builds can refresh an older daemon even when it still exposes the same API endpoints. */ -export const HUNK_SESSION_DAEMON_VERSION = 3; +export const HUNK_SESSION_DAEMON_VERSION = 4; export type SessionDaemonAction = | "list"