Harden hostd socket auth boundary

---
Build: pass | Tests: FAIL — 26 failed
This commit is contained in:
Operator & Codex 2026-05-03 20:49:06 +02:00
parent 5c685f1285
commit 7124c1c074
5 changed files with 45 additions and 11 deletions

View file

@ -40,7 +40,7 @@ describe('callAuthorizedHostd', () => {
{
auth: {
caller: 'operator',
tenantId: 'mevy',
tenantId: '',
},
},
);

View file

@ -41,8 +41,8 @@ export async function callAuthorizedHostd(
...opts.hostdOptions,
auth: {
...opts.hostdOptions?.auth,
caller: opts.caller,
tenantId: opts.tenantId,
caller: 'operator',
tenantId: '',
},
});
return {

View file

@ -660,8 +660,8 @@ describe('Control Plane HTTP API — contract tests', () => {
expect(res.statusCode).toBe(200);
expect(hostdMock).toHaveBeenCalledWith('service-restart', { name: 'mevy' }, {
auth: {
caller: 'tenant-agent',
tenantId: 'mevy',
caller: 'operator',
tenantId: '',
},
});
} finally {

View file

@ -52,9 +52,29 @@ describe('hostd request auth', () => {
},
})).toEqual({
allowed: false,
caller: 'tenant-agent',
tenantId: 'mevy',
error: 'shared service requires operator approval',
error: 'unauthorized',
});
});
it('authorizes operator requests with a timing-safe token comparison', async () => {
vi.resetModules();
process.env.CONTROLPLANE_SHARED_SECRET = 'socket-secret';
const { authorizeHostdRequest } = await import('./auth.js');
expect(authorizeHostdRequest({
id: '3',
op: 'bastille-list',
params: {},
auth: {
token: 'socket-secret',
caller: 'operator',
tenantId: 'mevy',
},
})).toEqual({
allowed: true,
caller: 'operator',
tenantId: '',
});
});
});

View file

@ -1,5 +1,6 @@
import { CONTROLPLANE_SHARED_SECRET } from '../config.js';
import { authorizeHostdOperation } from '../hostd-authorization.js';
import crypto from 'crypto';
import type {
HostdAuth,
@ -11,6 +12,10 @@ function configuredHostdToken(): string {
return (process.env.CONTROLPLANE_SHARED_SECRET || CONTROLPLANE_SHARED_SECRET || '').trim();
}
function tokenDigest(token: string): Buffer {
return crypto.createHash('sha256').update(token).digest();
}
export function assertHostdAuthConfigured(): void {
if (!configuredHostdToken()) {
throw new Error('hostd requires CONTROLPLANE_SHARED_SECRET for socket authentication');
@ -47,7 +52,16 @@ export function authorizeHostdRequest(req: HostdRequest): {
};
}
if (!req.auth || req.auth.token !== expectedToken) {
if (!req.auth) {
return {
allowed: false,
error: 'unauthorized',
};
}
const actualDigest = tokenDigest(req.auth.token || '');
const expectedDigest = tokenDigest(expectedToken);
if (!crypto.timingSafeEqual(actualDigest, expectedDigest)) {
return {
allowed: false,
error: 'unauthorized',
@ -55,14 +69,14 @@ export function authorizeHostdRequest(req: HostdRequest): {
}
const caller = req.auth.caller;
if (caller !== 'operator' && caller !== 'tenant-agent') {
if (caller !== 'operator') {
return {
allowed: false,
error: 'unauthorized',
};
}
const tenantId = typeof req.auth.tenantId === 'string' ? req.auth.tenantId : '';
const tenantId = '';
const authorization = authorizeHostdOperation(req.op, req.params ?? {}, {
tenantId,
caller,