feat: add token-based authentication to session hooks

Implement hook token validation to secure session start requests.
Each hook server generates a unique token that must be provided
in the x-hapi-hook-token header. Updates hook-forwarder command
to support both positional and named arguments for port and token.
This commit is contained in:
weishu
2025-12-23 16:02:04 +08:00
parent 61d3a1bed1
commit 7f9145b6de
5 changed files with 117 additions and 16 deletions
+1 -1
View File
@@ -147,7 +147,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
});
logger.debug(`[START] Hook server started on port ${hookServer.port}`);
const hookSettingsPath = generateHookSettingsFile(hookServer.port);
const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token);
logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`);
// Print log file path
+2 -2
View File
@@ -30,14 +30,14 @@ function shellJoin(parts: string[]): string {
/**
* Generate a temporary settings file with SessionStart hook configuration.
*/
export function generateHookSettingsFile(port: number): string {
export function generateHookSettingsFile(port: number, token: string): string {
const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks');
mkdirSync(hooksDir, { recursive: true });
const filename = `session-hook-${process.pid}.json`;
const filepath = join(hooksDir, filename);
const { command, args } = getHappyCliCommand(['hook-forwarder', String(port)]);
const { command, args } = getHappyCliCommand(['hook-forwarder', '--port', String(port), '--token', token]);
const hookCommand = shellJoin([command, ...args]);
const settings = {
+56 -4
View File
@@ -11,22 +11,73 @@ function parsePort(value: string | undefined): number | null {
return null;
}
const port = Number.parseInt(value, 10);
if (!port || Number.isNaN(port)) {
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return null;
}
return port;
}
function parseArgs(args: string[]): { port: number | null; token: string | null } {
let port: number | null = null;
let token: string | null = null;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === '--port' || arg === '-p') {
port = parsePort(args[i + 1]);
i += 1;
continue;
}
if (arg.startsWith('--port=')) {
port = parsePort(arg.slice('--port='.length));
continue;
}
if (arg === '--token' || arg === '-t') {
token = args[i + 1] ?? null;
i += 1;
continue;
}
if (arg.startsWith('--token=')) {
token = arg.slice('--token='.length);
continue;
}
if (!port) {
port = parsePort(arg);
continue;
}
if (!token) {
token = arg;
}
}
return { port, token };
}
export async function runSessionHookForwarder(args: string[]): Promise<void> {
const port = parsePort(args[0]);
const { port, token } = parseArgs(args);
if (!port) {
logError('Invalid or missing port argument');
process.exitCode = 1;
return;
}
if (!token) {
logError('Missing hook token');
process.exitCode = 1;
return;
}
try {
const chunks: Buffer[] = [];
process.stdin.resume();
@@ -49,7 +100,8 @@ export async function runSessionHookForwarder(args: string[]): Promise<void> {
path: '/hook/session-start',
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length
'Content-Length': body.length,
'x-hapi-hook-token': token
}
}, (res) => {
if (res.statusCode && res.statusCode >= 400) {
+33 -8
View File
@@ -2,17 +2,22 @@ import { describe, it, expect } from 'vitest'
import { request } from 'node:http'
import { startHookServer, type SessionHookData } from './startHookServer'
const sendHookRequest = async (port: number, body: string): Promise<{ statusCode?: number; body: string }> => {
const sendHookRequest = async (port: number, body: string, token?: string): Promise<{ statusCode?: number; body: string }> => {
return await new Promise((resolve, reject) => {
const headers: Record<string, string | number> = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
if (token) {
headers['x-hapi-hook-token'] = token
}
const req = request({
host: '127.0.0.1',
port,
path: '/hook/session-start',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
headers
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(chunk as Buffer))
@@ -41,7 +46,7 @@ describe('startHookServer', () => {
try {
const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' })
const response = await sendHookRequest(server.port, body)
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(200)
} finally {
server.stop()
@@ -60,7 +65,7 @@ describe('startHookServer', () => {
})
try {
const response = await sendHookRequest(server.port, '{"session_id":')
const response = await sendHookRequest(server.port, '{"session_id":', server.token)
expect(response.statusCode).toBe(400)
expect(response.body).toBe('invalid json')
} finally {
@@ -80,7 +85,7 @@ describe('startHookServer', () => {
try {
const body = JSON.stringify({ extra: 'ok' })
const response = await sendHookRequest(server.port, body)
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(422)
expect(response.body).toBe('missing session_id')
} finally {
@@ -89,4 +94,24 @@ describe('startHookServer', () => {
expect(hookCalled).toBe(false)
})
it('returns 401 when hook token is missing', async () => {
let hookCalled = false
const server = await startHookServer({
onSessionHook: () => {
hookCalled = true
}
})
try {
const body = JSON.stringify({ session_id: 'session-123' })
const response = await sendHookRequest(server.port, body)
expect(response.statusCode).toBe(401)
expect(response.body).toBe('unauthorized')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
})
+25 -1
View File
@@ -6,6 +6,7 @@
*/
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
import { randomBytes } from 'node:crypto';
import { logger } from '@/ui/logger';
/**
@@ -24,24 +25,46 @@ export interface SessionHookData {
export interface HookServerOptions {
/** Called when a session hook is received with a valid session ID. */
onSessionHook: (sessionId: string, data: SessionHookData) => void;
/** Optional token to require for hook requests. */
token?: string;
}
export interface HookServer {
/** The port the server is listening on. */
port: number;
/** Token required for hook requests. */
token: string;
/** Stop the server. */
stop: () => void;
}
function readHookToken(req: IncomingMessage): string | null {
const header = req.headers['x-hapi-hook-token'];
if (Array.isArray(header)) {
return header[0] ?? null;
}
return header ?? null;
}
/**
* Start a dedicated HTTP server for receiving Claude session hooks.
*/
export async function startHookServer(options: HookServerOptions): Promise<HookServer> {
const { onSessionHook } = options;
const hookToken = options.token || randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.method === 'POST' && req.url === '/hook/session-start') {
const requestPath = req.url?.split('?')[0];
if (req.method === 'POST' && requestPath === '/hook/session-start') {
const providedToken = readHookToken(req);
if (providedToken !== hookToken) {
logger.debug('[hookServer] Unauthorized hook request');
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized');
req.resume();
return;
}
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
@@ -122,6 +145,7 @@ export async function startHookServer(options: HookServerOptions): Promise<HookS
resolve({
port,
token: hookToken,
stop: () => {
server.close();
logger.debug('[hookServer] Stopped');