-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmiddleware.ts
More file actions
139 lines (129 loc) · 5.89 KB
/
Copy pathmiddleware.ts
File metadata and controls
139 lines (129 loc) · 5.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
* Drop-in HMAC-verify middleware for Sluice webhook receivers.
*
* Express:
*
* import { sluiceExpress } from '@sluice/client/middleware';
* app.post('/hook', sluiceExpress(process.env.SLUICE_WEBHOOK_SECRET), (req, res) => {
* // req.body, parsed JSON envelope (Sluice payload)
* // req.sluice, { verified: true, eventHash, subscriptionId? }
* res.sendStatus(200);
* });
*
* Fastify:
*
* import { sluiceFastify } from '@sluice/client/middleware';
* await fastify.register(sluiceFastify, { secret: process.env.SLUICE_WEBHOOK_SECRET });
*
* Both read the raw request body (so HMAC matches byte-for-byte), compare
* the supplied signature in constant time, parse JSON, attach a `.sluice`
* marker, and 401 on mismatch. If no secret is configured the middleware
* still parses JSON but flags `verified: false`, useful for local dev.
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Compute the same sha256=hex tag the matcher writes into X-Sluice-Signature. */
export function computeSignature(body: string | Buffer, secret: string): string {
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
}
/** Constant-time compare two `sha256=hex` signatures. */
export function verifyHmacSignature(rawBody: string | Buffer, signature: string | undefined, secret: string): boolean {
if (!signature) return false;
const expected = computeSignature(rawBody, secret);
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length) return false;
try { return timingSafeEqual(a, b); } catch { return false; }
}
/* ─────────────────── Express ─────────────────── */
interface ExpressReq {
headers: Record<string, string | string[] | undefined>;
body?: unknown;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(event: string, fn: (chunk: any) => void): this;
// a custom field we attach so handler code knows what happened
sluice?: SluiceReqContext;
}
interface ExpressRes {
status(code: number): ExpressRes;
json(body: unknown): ExpressRes;
end(): void;
}
type ExpressNext = (err?: unknown) => void;
export interface SluiceReqContext {
verified: boolean;
eventHash?: string;
subscriptionId?: number;
/** Raw bytes, kept so handlers that want to re-verify or forward can. */
rawBody: Buffer;
}
/**
* Express middleware factory. Must be mounted BEFORE any other body parser
* for this route (otherwise the raw bytes will already have been consumed).
*/
export function sluiceExpress(secret: string | undefined): (req: ExpressReq, res: ExpressRes, next: ExpressNext) => void {
return (req, res, next) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const sigHeader = req.headers['x-sluice-signature'];
const sig = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
const verified = secret ? verifyHmacSignature(raw, sig, secret) : false;
if (secret && !verified) { res.status(401).json({ error: 'invalid X-Sluice-Signature' }); return; }
let parsed: { event_hash?: string; subscription_id?: number } | undefined;
try { parsed = JSON.parse(raw.toString('utf8')); }
catch { res.status(400).json({ error: 'body is not valid JSON' }); return; }
req.body = parsed;
req.sluice = {
verified,
eventHash: parsed?.event_hash,
subscriptionId: parsed?.subscription_id,
rawBody: raw,
};
next();
});
req.on('error', (e: Error) => next(e));
};
}
/* ─────────────────── Fastify ─────────────────── */
interface FastifyInstance {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addContentTypeParser(contentType: string, opts: { parseAs: 'buffer' }, fn: (req: unknown, body: Buffer, done: (err: Error | null, body?: unknown) => void) => void): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addHook(name: 'preHandler', fn: (req: any, reply: any) => Promise<void>): void;
}
interface FastifyOpts { secret?: string }
/**
* Fastify plugin, `await fastify.register(sluiceFastify, { secret })`.
* Registers a JSON content-type parser that captures raw bytes for HMAC
* verify, then a preHandler hook that runs the verify on every request.
*/
export async function sluiceFastify(fastify: FastifyInstance, opts: FastifyOpts): Promise<void> {
const secret = opts.secret;
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
try { done(null, { __sluiceRaw: body, parsed: JSON.parse(body.toString('utf8')) }); }
catch (e) { done(e as Error); }
});
fastify.addHook('preHandler', async (req, reply) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrapped = (req as any).body as { __sluiceRaw?: Buffer; parsed?: { event_hash?: string; subscription_id?: number } } | undefined;
if (!wrapped || !wrapped.__sluiceRaw) return;
const sig = (req as { headers: Record<string, string | string[] | undefined> }).headers['x-sluice-signature'];
const sigStr = Array.isArray(sig) ? sig[0] : sig;
const verified = secret ? verifyHmacSignature(wrapped.__sluiceRaw, sigStr, secret) : false;
if (secret && !verified) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(reply as any).code(401).send({ error: 'invalid X-Sluice-Signature' });
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req as any).body = wrapped.parsed;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req as any).sluice = {
verified,
eventHash: wrapped.parsed?.event_hash,
subscriptionId: wrapped.parsed?.subscription_id,
rawBody: wrapped.__sluiceRaw,
};
});
}