Harden hostd socket auth boundary
--- Build: pass | Tests: FAIL — 26 failed
This commit is contained in:
parent
5c685f1285
commit
7124c1c074
5 changed files with 45 additions and 11 deletions
|
|
@ -40,7 +40,7 @@ describe('callAuthorizedHostd', () => {
|
|||
{
|
||||
auth: {
|
||||
caller: 'operator',
|
||||
tenantId: 'mevy',
|
||||
tenantId: '',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export async function callAuthorizedHostd(
|
|||
...opts.hostdOptions,
|
||||
auth: {
|
||||
...opts.hostdOptions?.auth,
|
||||
caller: opts.caller,
|
||||
tenantId: opts.tenantId,
|
||||
caller: 'operator',
|
||||
tenantId: '',
|
||||
},
|
||||
});
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue