mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
f03c9dc7b6
* fix(webhooks): close HMAC timing oracle on trigger reject paths The trigger handler in PR #1177 returned a uniform 404 for every unauthenticated rejection, but only the wrong-signature path computed an HMAC over the request body. The other reject paths (unknown id, disabled webhook, non-paid tier, missing X-Webhook-Signature header, missing rawBody) short-circuited before any HMAC work. Repeated near-rate-limit probes with a large attacker-controlled body could distinguish a valid-and-enabled paid webhook id from the other reject cases through response latency. WebhookService.validateSignature is now constant-time over every input shape: it always runs crypto.createHmac and crypto.timingSafeEqual against fixed-length 32-byte buffers regardless of whether the signature is missing, has the wrong prefix, is malformed hex, or is the wrong length. The trigger handler calls it unconditionally before any reject branch fires, using a stable per-process decoy secret (WebhookService.getDecoySecret) when the webhook does not exist and an empty buffer when the request has no body. Response timing now depends only on the size of the request body, which the attacker already controls and which reveals nothing webhook-specific. Six new tests pin the behaviour: validateSignature is observed firing on the unknown-id and missing-signature paths through a spy assertion, and four direct-call tests confirm validateSignature returns false without throwing for empty, wrong-prefix, malformed-hex, and wrong-length signatures. * fix(safe-log): redact Basic auth and lowercase Windows drive letters The redactSensitiveText helper now covers two cases the prior chain missed: * Authorization: Basic <base64> previously left the base64 payload intact. The existing key/value regex caught only the literal word Basic before stopping at the space. A new Basic\s+[A-Za-z0-9+/=]+ replacement runs before the key/value regex so the credential is scrubbed first. * Windows homedir paths like c:\Users\<user>\... with a lowercase drive letter previously slipped through because the regex required [A-Z]. Changed to [A-Za-z] so both letter cases are covered. Two new tests pin both fixes. * docs(webhooks): document 429, fix shared schema, comply with D27/D31 * Trigger endpoint declares the 429 response that webhookTriggerLimiter can return (500 requests per minute per source IP); both docs/openapi.yaml and the response table in docs/features/webhooks.mdx carry the new row, and a new troubleshooting accordion explains the shared-NAT scenario. * Shared Webhook schema in docs/openapi.yaml extends the action enum to include git-pull and documents the node_id property. The GET list endpoint returns these fields; the prior schema would have failed validation for any git-pull row. * docs/features/webhooks.mdx:7 rewritten from a customer-side role enumeration ("non-admins on a paid tier can view the list but cannot manage it") to a single requirement statement ("Webhooks require a Skipper or Admiral license. Managing webhooks is admin-only.") per CLAUDE.md D27/D31; the prior phrasing was customer-side fence-spec. * Two em dashes in webhook description strings I had touched in the prior OpenAPI sync commit replaced with semicolons per D18.
462 lines
19 KiB
TypeScript
462 lines
19 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import crypto from 'crypto';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
|
let WebhookService: typeof import('../services/WebhookService').WebhookService;
|
|
|
|
function adminToken(): string {
|
|
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
}
|
|
|
|
function sign(rawBody: string, secret: string): string {
|
|
return 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
}
|
|
|
|
interface WebhookFixture {
|
|
id: number;
|
|
secret: string;
|
|
}
|
|
|
|
function createWebhook(opts: { action?: string; enabled?: boolean; name?: string; stack?: string } = {}): WebhookFixture {
|
|
const db = DatabaseService.getInstance();
|
|
const nodeId = db.getDefaultNode()!.id!;
|
|
const secret = WebhookService.getInstance().generateSecret();
|
|
const id = db.addWebhook({
|
|
node_id: nodeId,
|
|
name: opts.name ?? 'trigger-test',
|
|
stack_name: opts.stack ?? 'missing-stack',
|
|
action: (opts.action ?? 'restart') as never,
|
|
secret,
|
|
enabled: opts.enabled ?? true,
|
|
});
|
|
return { id, secret };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ LicenseService } = await import('../services/LicenseService'));
|
|
({ WebhookService } = await import('../services/WebhookService'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
|
});
|
|
|
|
describe('POST /api/webhooks/:id/trigger: uniform unauthenticated 404 (M1, H3)', () => {
|
|
const expected = { error: 'Webhook not found or signature invalid' };
|
|
|
|
it('returns 404 when the webhook id is unknown', async () => {
|
|
const body = '{}';
|
|
const res = await request(app)
|
|
.post('/api/webhooks/9999999/trigger')
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, 'whatever'))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('returns the same 404 when the webhook exists but is disabled', async () => {
|
|
const { id, secret } = createWebhook({ enabled: false });
|
|
const body = '{}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, secret))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('returns the same 404 when the licence tier is not paid', async () => {
|
|
const { id, secret } = createWebhook();
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
const body = '{}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, secret))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
// The forbidden code from the prior surface must not leak.
|
|
expect(res.body.code).toBeUndefined();
|
|
});
|
|
|
|
it('returns the same 404 when the X-Webhook-Signature header is missing', async () => {
|
|
const { id } = createWebhook();
|
|
const body = '{}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('returns the same 404 when the request has no body (H3 fail-closed)', async () => {
|
|
const { id, secret } = createWebhook();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
// No Content-Type → express.json() does not run verify, so
|
|
// req.rawBody is never populated. The handler must fail closed
|
|
// instead of re-stringifying req.body to compute the HMAC.
|
|
.set('X-Webhook-Signature', sign('', secret));
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('returns the same 404 for a signature with the wrong prefix', async () => {
|
|
const { id, secret } = createWebhook();
|
|
const body = '{}';
|
|
const hex = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', `sha1=${hex}`)
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('returns the same 404 for a malformed hex signature', async () => {
|
|
const { id } = createWebhook();
|
|
const body = '{}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', 'sha256=notavalidhex-zzzz')
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('returns the same 404 when the signature does not match the body', async () => {
|
|
const { id } = createWebhook();
|
|
const body = '{"foo":"bar"}';
|
|
const wrongSig = sign(body, 'wrong-secret-of-equal-length-as-the-real-one-1234');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', wrongSig)
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual(expected);
|
|
});
|
|
|
|
it('runs validateSignature even when the webhook id is unknown (timing oracle)', async () => {
|
|
const sigSpy = vi.spyOn(WebhookService.getInstance(), 'validateSignature');
|
|
const body = '{"probe":true}';
|
|
|
|
const res = await request(app)
|
|
.post('/api/webhooks/9999999/trigger')
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', `sha256=${'a'.repeat(64)}`)
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(sigSpy).toHaveBeenCalledTimes(1);
|
|
const [usedPayload, usedSecret, usedSignature] = sigSpy.mock.calls[0];
|
|
// HMAC ran against the attacker-supplied body, not a short-circuited
|
|
// empty string; the unknown-id path must do the same work as the
|
|
// wrong-signature path.
|
|
expect(usedPayload).toBe(body);
|
|
// Decoy secret got plumbed through so the HMAC compute was real.
|
|
expect(typeof usedSecret).toBe('string');
|
|
expect((usedSecret as string).length).toBeGreaterThan(0);
|
|
expect(usedSignature).toContain('sha256=');
|
|
});
|
|
|
|
it('runs validateSignature even when the signature header is missing', async () => {
|
|
const sigSpy = vi.spyOn(WebhookService.getInstance(), 'validateSignature');
|
|
const { id } = createWebhook();
|
|
const body = '{}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(sigSpy).toHaveBeenCalledTimes(1);
|
|
const [usedPayload, , usedSignature] = sigSpy.mock.calls[0];
|
|
expect(usedPayload).toBe(body);
|
|
// Empty-string signature flowed into validateSignature instead of
|
|
// short-circuiting before the HMAC compute.
|
|
expect(usedSignature).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('WebhookService.validateSignature: constant-time over input shape', () => {
|
|
it('returns false but does not throw on an empty signature string', () => {
|
|
const result = WebhookService.getInstance().validateSignature('payload', 'secret', '');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false but does not throw on a wrong-prefix signature', () => {
|
|
const result = WebhookService.getInstance().validateSignature('payload', 'secret', 'sha1=abc');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false but does not throw on a malformed hex signature', () => {
|
|
const result = WebhookService.getInstance().validateSignature('payload', 'secret', 'sha256=not-hex-data-shorter-than-64-chars');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false but does not throw on a hex signature of the wrong length', () => {
|
|
const result = WebhookService.getInstance().validateSignature('payload', 'secret', `sha256=${'a'.repeat(32)}`);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webhooks/:id/trigger: authenticated happy path', () => {
|
|
it('returns 202 and echoes the configured action', async () => {
|
|
const { id, secret } = createWebhook({ action: 'stop' });
|
|
const body = '{}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, secret))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(202);
|
|
expect(res.body).toMatchObject({ message: 'Webhook accepted', action: 'stop' });
|
|
});
|
|
|
|
it('accepts a valid action override and echoes it', async () => {
|
|
const { id, secret } = createWebhook({ action: 'restart' });
|
|
const body = '{"action":"start"}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, secret))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(202);
|
|
expect(res.body).toMatchObject({ action: 'start' });
|
|
});
|
|
|
|
it('rejects an unknown action override with 400 after the signature passes (L2)', async () => {
|
|
const { id, secret } = createWebhook();
|
|
const body = '{"action":"nuke-the-cluster"}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, secret))
|
|
.send(body);
|
|
|
|
// Auth succeeded, so the caller learns the action was rejected.
|
|
// Pre-auth callers would still get the uniform 404 instead.
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/action must be one of/);
|
|
});
|
|
|
|
it('rejects a non-string action override with 400', async () => {
|
|
const { id, secret } = createWebhook();
|
|
const body = '{"action":42}';
|
|
|
|
const res = await request(app)
|
|
.post(`/api/webhooks/${id}/trigger`)
|
|
.set('Content-Type', 'application/json')
|
|
.set('X-Webhook-Signature', sign(body, secret))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/action must be one of/);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webhooks: name length cap (L1)', () => {
|
|
it('rejects a name longer than 100 characters', async () => {
|
|
const res = await request(app)
|
|
.post('/api/webhooks')
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({
|
|
name: 'a'.repeat(101),
|
|
stack_name: 'irrelevant-stack',
|
|
action: 'restart',
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/100 characters or fewer/);
|
|
});
|
|
|
|
it('rejects a non-string name', async () => {
|
|
const res = await request(app)
|
|
.post('/api/webhooks')
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({
|
|
name: { obj: 'not-a-string' },
|
|
stack_name: 'irrelevant-stack',
|
|
action: 'restart',
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
// 'name, stack_name, and action are required' catches this when name is
|
|
// truthy-but-not-a-string before the length check; either error message
|
|
// is acceptable for non-string input.
|
|
expect(res.body.error).toBeTruthy();
|
|
});
|
|
|
|
it('accepts a name at the 100-character boundary', async () => {
|
|
const res = await request(app)
|
|
.post('/api/webhooks')
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({
|
|
name: 'a'.repeat(100),
|
|
stack_name: 'boundary-stack',
|
|
action: 'restart',
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(typeof res.body.secret).toBe('string');
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/webhooks/:id: name length cap (L1)', () => {
|
|
it('rejects updating name to over 100 characters', async () => {
|
|
const { id } = createWebhook();
|
|
const res = await request(app)
|
|
.put(`/api/webhooks/${id}`)
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({ name: 'b'.repeat(101) });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/100 characters or fewer/);
|
|
});
|
|
|
|
it('allows partial updates that omit name', async () => {
|
|
const { id } = createWebhook({ enabled: true });
|
|
const res = await request(app)
|
|
.put(`/api/webhooks/${id}`)
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({ enabled: false });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(DatabaseService.getInstance().getWebhook(id)?.enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('WebhookService.execute: delete-during-execution race (M5)', () => {
|
|
it('does not crash when the parent webhook is deleted before recordExecution runs', async () => {
|
|
// The webhook targets a stack that does not exist on disk, so
|
|
// executeLocal fails fast at the FileSystemService.getStacks() check
|
|
// and tries to write a failure row to webhook_executions. By the time
|
|
// that insert fires the parent row is gone, so the FK CASCADE makes
|
|
// the insert fail. The fix is for recordExecution to swallow that
|
|
// error with a console.warn instead of crashing the async dispatch.
|
|
const { id } = createWebhook({ action: 'restart', stack: 'definitely-not-on-disk' });
|
|
const webhook = DatabaseService.getInstance().getWebhook(id)!;
|
|
|
|
// Mid-flight delete.
|
|
DatabaseService.getInstance().deleteWebhook(id);
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
await expect(
|
|
WebhookService.getInstance().execute(webhook, 'restart', 'test', true),
|
|
).resolves.toMatchObject({ success: false });
|
|
|
|
// recordExecution caught the FK error and logged a single warning.
|
|
const calls = warnSpy.mock.calls.map(args => args.join(' '));
|
|
expect(calls.some(line => line.includes(`webhook ${id}`))).toBe(true);
|
|
});
|
|
|
|
it('records the execution row when the webhook persists through execution', async () => {
|
|
const { id } = createWebhook({ action: 'restart', stack: 'definitely-not-on-disk' });
|
|
const webhook = DatabaseService.getInstance().getWebhook(id)!;
|
|
|
|
const result = await WebhookService.getInstance().execute(webhook, 'restart', 'test', true);
|
|
expect(result.success).toBe(false);
|
|
// Filter by webhook_id rather than asserting toHaveLength on the
|
|
// entire history: getWebhookExecutions already scopes to this row's
|
|
// id, but tests in this file share a baseline DB and an earlier
|
|
// run could re-use an id range. Read it positionally instead.
|
|
const history = DatabaseService.getInstance().getWebhookExecutions(id);
|
|
expect(history.length).toBeGreaterThanOrEqual(1);
|
|
expect(history[0].status).toBe('failure');
|
|
expect(history[0].error).toMatch(/not found/i);
|
|
});
|
|
});
|
|
|
|
describe('webhook_executions.error redaction (M6)', () => {
|
|
// Both tests force executeLocal's switch-statement try/catch path so the
|
|
// raw upstream error flows through getErrorMessage -> recordExecution ->
|
|
// redactSensitiveText. FileSystemService.getStacks is stubbed to claim the
|
|
// stack exists, and ComposeService.runCommand is stubbed to throw the
|
|
// sensitive content. Spies attach to the prototypes because both
|
|
// singletons hand out fresh instances per nodeId.
|
|
it('strips bearer tokens before persisting the execution error', async () => {
|
|
const stack = 'redact-stack-bearer';
|
|
const { id } = createWebhook({ action: 'restart', stack });
|
|
const webhook = DatabaseService.getInstance().getWebhook(id)!;
|
|
|
|
const fs = await import('../services/FileSystemService');
|
|
const compose = await import('../services/ComposeService');
|
|
vi.spyOn(fs.FileSystemService.prototype, 'getStacks').mockResolvedValue([stack]);
|
|
vi.spyOn(compose.ComposeService.prototype, 'runCommand').mockRejectedValue(
|
|
new Error('upstream rejected: Authorization: Bearer abcdef1234567890tokenvalue'),
|
|
);
|
|
|
|
const result = await WebhookService.getInstance().execute(webhook, 'restart', 'test', true);
|
|
expect(result.success).toBe(false);
|
|
|
|
const history = DatabaseService.getInstance().getWebhookExecutions(id);
|
|
expect(history[0].error).toBeTruthy();
|
|
expect(history[0].error).not.toContain('abcdef1234567890tokenvalue');
|
|
expect(history[0].error).toContain('[redacted]');
|
|
});
|
|
|
|
it('strips homedir paths before persisting the execution error', async () => {
|
|
const stack = 'redact-stack-home';
|
|
const { id } = createWebhook({ action: 'restart', stack });
|
|
const webhook = DatabaseService.getInstance().getWebhook(id)!;
|
|
|
|
const fs = await import('../services/FileSystemService');
|
|
const compose = await import('../services/ComposeService');
|
|
vi.spyOn(fs.FileSystemService.prototype, 'getStacks').mockResolvedValue([stack]);
|
|
vi.spyOn(compose.ComposeService.prototype, 'runCommand').mockRejectedValue(
|
|
new Error('compose error reading /home/user-redact-target/docker/compose.yaml'),
|
|
);
|
|
|
|
const result = await WebhookService.getInstance().execute(webhook, 'restart', 'test', true);
|
|
expect(result.success).toBe(false);
|
|
|
|
const history = DatabaseService.getInstance().getWebhookExecutions(id);
|
|
expect(history[0].error).toBeTruthy();
|
|
expect(history[0].error).not.toContain('user-redact-target');
|
|
expect(history[0].error).toContain('/home/<user>');
|
|
});
|
|
});
|