mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
* 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.
This commit is contained in:
@@ -39,4 +39,27 @@ describe('redactSensitiveText', () => {
|
||||
expect(text).not.toContain('user.windows');
|
||||
expect(text).toContain('D:\\Users\\<user>\\Sencho\\compose.yaml');
|
||||
});
|
||||
|
||||
it('strips Windows-style homedir usernames when the drive letter is lowercase', () => {
|
||||
const text = redactSensitiveText('failed: c:\\Users\\user.lowercase\\app\\compose.yaml');
|
||||
|
||||
expect(text).not.toContain('user.lowercase');
|
||||
expect(text).toContain('c:\\Users\\<user>\\app\\compose.yaml');
|
||||
});
|
||||
|
||||
it('redacts Basic auth credentials embedded after Authorization header', () => {
|
||||
const text = redactSensitiveText(
|
||||
'upstream 401: Authorization: Basic dXNlcjpwYXNzd29yZA== rejected by registry',
|
||||
);
|
||||
|
||||
expect(text).not.toContain('dXNlcjpwYXNzd29yZA');
|
||||
expect(text).toContain('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts a bare Basic auth scheme without an Authorization header', () => {
|
||||
const text = redactSensitiveText('curl error: header Basic c2VjcmV0OnZhbHVl was rejected');
|
||||
|
||||
expect(text).not.toContain('c2VjcmV0OnZhbHVl');
|
||||
expect(text).toContain('Basic [redacted]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,6 +172,70 @@ describe('POST /api/webhooks/:id/trigger: uniform unauthenticated 404 (M1, H3)',
|
||||
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', () => {
|
||||
|
||||
@@ -147,6 +147,15 @@ webhooksRouter.get('/:id/history', authMiddleware, async (req: Request, res: Res
|
||||
// Every unauthenticated rejection returns the same 404 with the same body so
|
||||
// callers cannot enumerate webhook ids or fingerprint the instance's licence
|
||||
// tier from the response surface. Successful authentication still returns 202.
|
||||
//
|
||||
// The handler also runs the HMAC computation on every path (using a decoy
|
||||
// secret and an empty buffer when the real ones are missing) so the wall-
|
||||
// clock cost of a reject path matches the wall-clock cost of a real-shape
|
||||
// wrong-secret path. Without this, repeated near-rate-limit probes with a
|
||||
// large attacker-controlled body could distinguish a valid-and-enabled
|
||||
// webhook id on a paid tier from the other reject cases via response
|
||||
// latency. Timing now depends only on the size of the request body, which
|
||||
// the attacker already controls and which reveals nothing webhook-specific.
|
||||
webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const unauthenticated = (): void => {
|
||||
res.status(404).json({ error: 'Webhook not found or signature invalid' });
|
||||
@@ -156,22 +165,27 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request,
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(id);
|
||||
|
||||
if (!webhook || !webhook.enabled) return unauthenticated();
|
||||
if (LicenseService.getInstance().getTier() !== 'paid') return unauthenticated();
|
||||
|
||||
const signature = req.headers['x-webhook-signature'] as string;
|
||||
if (!signature) return unauthenticated();
|
||||
|
||||
// Fail closed when the raw body was not captured. express.json()'s verify
|
||||
// callback populates req.rawBody for every parsed body; an absent rawBody
|
||||
// means the request had no body or an unparseable content-type. Falling
|
||||
// back to JSON.stringify(req.body) would compare the HMAC against a
|
||||
// re-serialised payload that is not byte-equal to what the client signed.
|
||||
if (!req.rawBody) return unauthenticated();
|
||||
const payload = req.rawBody.toString('utf-8');
|
||||
const tier = LicenseService.getInstance().getTier();
|
||||
const signature = req.headers['x-webhook-signature'] as string | undefined;
|
||||
|
||||
// Unconditional HMAC. The decoy secret keeps the work non-skippable when
|
||||
// the webhook does not exist; an empty buffer keeps it non-skippable
|
||||
// when express.json()'s verify callback did not capture a body. The
|
||||
// empty-string signature still flows through validateSignature, which
|
||||
// is constant-time over every shape and will return false against the
|
||||
// all-zero sentinel. Reject conditions are checked after the HMAC has
|
||||
// already run so the timing of every reject path matches the timing of
|
||||
// a real-shape wrong-secret request.
|
||||
const svc = WebhookService.getInstance();
|
||||
if (!svc.validateSignature(payload, webhook.secret, signature)) return unauthenticated();
|
||||
const payload = (req.rawBody ?? Buffer.alloc(0)).toString('utf-8');
|
||||
const secretForHmac = webhook?.secret ?? WebhookService.getDecoySecret();
|
||||
const sigOk = svc.validateSignature(payload, secretForHmac, signature ?? '');
|
||||
|
||||
if (!webhook || !webhook.enabled) return unauthenticated();
|
||||
if (tier !== 'paid') return unauthenticated();
|
||||
if (!signature) return unauthenticated();
|
||||
if (!req.rawBody) return unauthenticated();
|
||||
if (!sigOk) return unauthenticated();
|
||||
|
||||
// Use action from body if provided, otherwise use webhook default.
|
||||
// Validate against the action allowlist before queueing execution so an
|
||||
|
||||
@@ -18,6 +18,12 @@ const REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class WebhookService {
|
||||
private static instance: WebhookService;
|
||||
// Stable per-process decoy secret used to keep HMAC work non-skippable on
|
||||
// reject paths (unknown webhook id, disabled, non-paid tier, etc.). Never
|
||||
// accepts a signature: the trigger handler decides the final 202 / 404
|
||||
// outcome from independent conditions and only consults the HMAC result
|
||||
// when every other check has already passed.
|
||||
private static decoySecret: string | null = null;
|
||||
|
||||
public static getInstance(): WebhookService {
|
||||
if (!WebhookService.instance) {
|
||||
@@ -26,27 +32,39 @@ export class WebhookService {
|
||||
return WebhookService.instance;
|
||||
}
|
||||
|
||||
public static getDecoySecret(): string {
|
||||
if (!WebhookService.decoySecret) {
|
||||
WebhookService.decoySecret = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
return WebhookService.decoySecret;
|
||||
}
|
||||
|
||||
public generateSecret(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
public validateSignature(payload: string, secret: string, signature: string): boolean {
|
||||
// Always perform HMAC and timingSafeEqual against a fixed-length
|
||||
// buffer so the wall-clock cost of this call is independent of the
|
||||
// shape of the input signature. Without this, the early-return paths
|
||||
// (missing header, wrong prefix, malformed hex) would skip the HMAC
|
||||
// and let an attacker distinguish those cases from a real-shape
|
||||
// wrong-secret case through repeated near-rate-limit probes with a
|
||||
// large attacker-controlled body. Timing now depends only on the
|
||||
// size of `payload`, which the attacker already controls and which
|
||||
// does not reveal anything about the webhook id or licence tier.
|
||||
const expected = crypto.createHmac('sha256', secret).update(payload).digest();
|
||||
const provided = Buffer.alloc(32);
|
||||
let formatOk = false;
|
||||
|
||||
const parts = signature.split('=');
|
||||
if (parts.length !== 2 || parts[0] !== 'sha256') return false;
|
||||
|
||||
const expected = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected, 'hex'),
|
||||
Buffer.from(parts[1], 'hex'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
if (parts.length === 2 && parts[0] === 'sha256' && /^[0-9a-fA-F]{64}$/.test(parts[1])) {
|
||||
provided.write(parts[1], 'hex');
|
||||
formatOk = true;
|
||||
}
|
||||
|
||||
const sigEq = crypto.timingSafeEqual(expected, provided);
|
||||
return formatOk && sigEq;
|
||||
}
|
||||
|
||||
public async gitSourceExists(stackName: string, nodeId: number): Promise<boolean> {
|
||||
|
||||
@@ -18,10 +18,11 @@ export function redactSensitiveText(value: unknown): string {
|
||||
const s = typeof value === 'string' ? value : String(value);
|
||||
return s
|
||||
.replace(/Bearer\s+[A-Za-z0-9\-._~+/=]+/gi, 'Bearer [redacted]')
|
||||
.replace(/Basic\s+[A-Za-z0-9+/=]+/gi, 'Basic [redacted]')
|
||||
.replace(/[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-jwt]')
|
||||
.replace(/https?:\/\/[^/\s:@]+:[^/\s@]+@/gi, 'https://[redacted]@')
|
||||
.replace(/((?:authorization|token|password|secret|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, '$1[redacted]')
|
||||
.replace(/\/home\/[^/\s'"]+/g, '/home/<user>')
|
||||
.replace(/\/Users\/[^/\s'"]+/g, '/Users/<user>')
|
||||
.replace(/([A-Z]):\\Users\\[^\\/\s'"]+/g, '$1:\\Users\\<user>');
|
||||
.replace(/([A-Za-z]):\\Users\\[^\\/\s'"]+/g, '$1:\\Users\\<user>');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user