@ultimat3/http 11.3.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +159 -4
- package/README.md +109 -6
- package/package.json +5 -5
- package/src/app-config.ts +146 -0
- package/src/config.ts +5 -2
- package/src/context.ts +65 -33
- package/src/cors.ts +2 -2
- package/src/deadline.ts +18 -1
- package/src/error-facts.ts +286 -0
- package/src/error-map.ts +130 -193
- package/src/errors.ts +46 -6
- package/src/index.ts +32 -7
- package/src/overlay.ts +1 -1
- package/src/pipeline.ts +4 -0
- package/src/rate-limit-errors.ts +23 -7
- package/src/rate-limit.ts +61 -5
- package/src/response.ts +1 -1
- package/src/stages.ts +36 -14
- package/src/type-pins.ts +39 -0
- package/src/webhook-verify.ts +133 -0
package/src/rate-limit-errors.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =
|
|
|
14
14
|
new HttpError({
|
|
15
15
|
code: 'X_RATE_LIMITED',
|
|
16
16
|
cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`,
|
|
17
|
-
fix: 'retry after the Retry-After header, or raise rateLimit
|
|
17
|
+
fix: 'retry after the Retry-After header, or raise the bucket in configureHttp({ rateLimit: { buckets } }) at module scope in a file under apps/*/',
|
|
18
18
|
meta: { key, retryAfterSeconds },
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -31,7 +31,7 @@ export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
|
|
|
31
31
|
found === 'disabled'
|
|
32
32
|
? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
|
|
33
33
|
: "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
|
|
34
|
-
fix: "createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or
|
|
34
|
+
fix: "createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
/**
|
|
@@ -56,7 +56,7 @@ const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucke
|
|
|
56
56
|
*/
|
|
57
57
|
export const rateLimitBucketConflict = (input: {
|
|
58
58
|
bucket: string;
|
|
59
|
-
/** `null` when the other declaration is
|
|
59
|
+
/** `null` when the other declaration is the app's `configureHttp()` rather than a second route. */
|
|
60
60
|
otherRoute: string | null;
|
|
61
61
|
route: string;
|
|
62
62
|
other: BucketNumbers;
|
|
@@ -66,11 +66,11 @@ export const rateLimitBucketConflict = (input: {
|
|
|
66
66
|
code: 'X_RATE_LIMIT_BUCKET_CONFLICT',
|
|
67
67
|
cause: `bucket "${input.bucket}" has two declarations: ${
|
|
68
68
|
input.otherRoute === null
|
|
69
|
-
? '
|
|
69
|
+
? 'the rateLimit.buckets the app passed to configureHttp()'
|
|
70
70
|
: `route "${input.otherRoute}"`
|
|
71
71
|
} says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${
|
|
72
72
|
input.otherRoute === null
|
|
73
|
-
? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and
|
|
73
|
+
? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and configureHttp() is not where to say so`
|
|
74
74
|
: ''
|
|
75
75
|
}`,
|
|
76
76
|
// One edit, named. Two joined by "or" leaves the reader to decide which declaration is
|
|
@@ -78,7 +78,7 @@ export const rateLimitBucketConflict = (input: {
|
|
|
78
78
|
// OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale.
|
|
79
79
|
fix:
|
|
80
80
|
input.otherRoute === null
|
|
81
|
-
? `delete
|
|
81
|
+
? `delete rateLimit.buckets.${input.bucket} from the app's configureHttp() call — the route's declaration is the one the OpenAPI operation publishes, so edit the numbers there if ${numbers(input.declared)} is wrong`
|
|
82
82
|
: `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`,
|
|
83
83
|
});
|
|
84
84
|
|
|
@@ -125,7 +125,23 @@ export const rateLimitScopeUnset = (): HttpError =>
|
|
|
125
125
|
code: 'X_RATE_LIMIT_SCOPE_UNSET',
|
|
126
126
|
cause:
|
|
127
127
|
'http.rateLimit is enabled and the deployment has not declared http.rateLimit.scope, so the numbers below it are per replica rather than per fleet',
|
|
128
|
-
fix: "
|
|
128
|
+
fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* At `defineHttpConfig`, never on the request, and the same shape as every other bucket-name
|
|
133
|
+
* refusal here: `bucketFor` resolves an unknown name to `default`, so a tenant allowance an author
|
|
134
|
+
* wrote as 5,000 would silently be the 120-burst read bucket — looser than the declaration, and
|
|
135
|
+
* visible nowhere. A whole tenant's cap is not a value to discover by watching a graph.
|
|
136
|
+
*/
|
|
137
|
+
export const tenantBucketUnknown = (name: string, declared: readonly string[]): HttpError =>
|
|
138
|
+
new HttpError({
|
|
139
|
+
code: 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
|
|
140
|
+
cause: `rateLimit.tenantBucket names "${name}" and rateLimit.buckets declares ${
|
|
141
|
+
declared.length === 0 ? 'no buckets' : declared.join(', ')
|
|
142
|
+
}`,
|
|
143
|
+
fix: `add ${name} to the same rateLimit.buckets — configureHttp({ rateLimit: { tenantBucket: '${name}', buckets: { ${name}: { capacity: 5000, refillPerSecond: 100 } } } }) — or drop tenantBucket to leave this app with no per-tenant allowance`,
|
|
144
|
+
meta: { bucket: name },
|
|
129
145
|
});
|
|
130
146
|
|
|
131
147
|
/**
|
package/src/rate-limit.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
rateLimitInvalid,
|
|
9
9
|
rateLimitNotShared,
|
|
10
10
|
rateLimitScopeUnset,
|
|
11
|
+
tenantBucketUnknown,
|
|
11
12
|
} from './rate-limit-errors';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -42,6 +43,19 @@ export interface RateLimitConfig {
|
|
|
42
43
|
/** Named buckets; a route selects one via `meta.rateLimit`. `default` is required. */
|
|
43
44
|
readonly buckets: Readonly<Record<string, Bucket>>;
|
|
44
45
|
readonly defaultBucket: string;
|
|
46
|
+
/**
|
|
47
|
+
* The bucket a whole TENANT spends, beside — never instead of — the caller's own, or `null` for
|
|
48
|
+
* an app with no per-tenant allowance.
|
|
49
|
+
*
|
|
50
|
+
* `null` by default because no number is defensible without being told: one tenant is a person
|
|
51
|
+
* and the next is five thousand seats, so a framework-chosen allowance would throttle a real
|
|
52
|
+
* deployment on the day it installed the framework (axiom 8). It is still the one knob that
|
|
53
|
+
* answers the failure it exists for — `rateLimitKey` was `actor > org > ip`, EXCLUSIVE, so an
|
|
54
|
+
* authenticated request never touched an org bucket at all: a tenant with 8,000 seats whose
|
|
55
|
+
* integration entered a retry loop spent 8,000 per-actor bursts against one shared pool, every
|
|
56
|
+
* one of them under its own limit, and nothing an operator could set would have refused it.
|
|
57
|
+
*/
|
|
58
|
+
readonly tenantBucket: string | null;
|
|
45
59
|
/**
|
|
46
60
|
* What this deployment requires of the store. `'shared'` says these numbers are the whole
|
|
47
61
|
* fleet's allowance, and a per-process store then refuses to boot — because N replicas each
|
|
@@ -61,6 +75,7 @@ export interface RateLimitConfig {
|
|
|
61
75
|
export const DEFAULT_RATE_LIMIT: Omit<RateLimitConfig, 'scope'> = {
|
|
62
76
|
enabled: true,
|
|
63
77
|
defaultBucket: 'default',
|
|
78
|
+
tenantBucket: null,
|
|
64
79
|
buckets: {
|
|
65
80
|
default: { capacity: 120, refillPerSecond: 2 },
|
|
66
81
|
// Login/signup style endpoints: slow, no burst.
|
|
@@ -78,6 +93,12 @@ export const resolveRateLimitConfig = (
|
|
|
78
93
|
input: Partial<RateLimitConfig> | undefined,
|
|
79
94
|
): RateLimitConfig => {
|
|
80
95
|
const merged = { ...DEFAULT_RATE_LIMIT, ...input };
|
|
96
|
+
// Here and not at the first request, for `assertRateLimitScope`'s reason: an unknown name falls
|
|
97
|
+
// through `bucketFor` to `default`, so a tenant allowance somebody wrote as 5,000 would silently
|
|
98
|
+
// be the 120-burst read bucket — looser than what the author declared, and invisible.
|
|
99
|
+
if (merged.tenantBucket !== null && !Object.hasOwn(merged.buckets, merged.tenantBucket)) {
|
|
100
|
+
throw tenantBucketUnknown(merged.tenantBucket, Object.keys(merged.buckets));
|
|
101
|
+
}
|
|
81
102
|
if (input?.scope !== undefined) return { ...merged, scope: input.scope };
|
|
82
103
|
if (!merged.enabled) return { ...merged, scope: 'process' };
|
|
83
104
|
throw rateLimitScopeUnset();
|
|
@@ -275,18 +296,53 @@ export interface RateLimitKeyParts {
|
|
|
275
296
|
}
|
|
276
297
|
|
|
277
298
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
299
|
+
* The namespace the tenant allowance is counted in. Deliberately NOT the route name the caller's
|
|
300
|
+
* own key carries: a per-route tenant bucket would give one org its whole allowance once per
|
|
301
|
+
* route, which is not a tenant cap at all — it is the same number multiplied by the route table.
|
|
281
302
|
*/
|
|
282
|
-
export const
|
|
303
|
+
export const TENANT_SCOPE = 'tenant';
|
|
304
|
+
|
|
305
|
+
/** One key and the bucket it is spent from. A request spends a LIST of these, never one. */
|
|
306
|
+
export interface RateLimitSpend {
|
|
307
|
+
readonly key: string;
|
|
308
|
+
/** A name resolved against `config.rateLimit.buckets` by the limiter, never a `Bucket`. */
|
|
309
|
+
readonly bucket: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Every bucket one request spends, in the order it spends them.
|
|
314
|
+
*
|
|
315
|
+
* The CALLER's key first — actor > org > ip, so an authenticated actor gets its own bucket and an
|
|
316
|
+
* anonymous request falls back to the connection address — and then, when the app declared a
|
|
317
|
+
* tenant bucket and this caller has an org, that org's own key.
|
|
318
|
+
*
|
|
319
|
+
* The second entry is the finding this function was rewritten for. The precedence used to be
|
|
320
|
+
* EXCLUSIVE: `orgId` was consulted only when `actorId` was null, which `actorView` makes
|
|
321
|
+
* unreachable for every authenticated request, so no request ever touched an org bucket. A tenant
|
|
322
|
+
* with 8,000 seats therefore had 8,000 × the per-actor burst against one shared connection pool,
|
|
323
|
+
* with every individual bucket comfortably inside its limit.
|
|
324
|
+
*
|
|
325
|
+
* The caller's key is spent FIRST so a single hostile actor is refused by its own allowance before
|
|
326
|
+
* it can spend its tenant's — and, because the stage stops at the first refusal, a throttled
|
|
327
|
+
* caller costs the tenant nothing.
|
|
328
|
+
*/
|
|
329
|
+
export const rateLimitSpends = (
|
|
330
|
+
parts: RateLimitKeyParts,
|
|
331
|
+
buckets: { readonly route: string; readonly tenant: string | null },
|
|
332
|
+
): readonly RateLimitSpend[] => {
|
|
283
333
|
const subject =
|
|
284
334
|
parts.actorId !== null
|
|
285
335
|
? `actor:${parts.actorId}`
|
|
286
336
|
: parts.orgId !== null
|
|
287
337
|
? `org:${parts.orgId}`
|
|
288
338
|
: `ip:${parts.ip ?? 'unknown'}`;
|
|
289
|
-
|
|
339
|
+
const spends: RateLimitSpend[] = [
|
|
340
|
+
{ key: `${parts.routeName}|${subject}`, bucket: buckets.route },
|
|
341
|
+
];
|
|
342
|
+
if (buckets.tenant !== null && parts.orgId !== null) {
|
|
343
|
+
spends.push({ key: `${TENANT_SCOPE}|org:${parts.orgId}`, bucket: buckets.tenant });
|
|
344
|
+
}
|
|
345
|
+
return spends;
|
|
290
346
|
};
|
|
291
347
|
|
|
292
348
|
export interface RateLimiter {
|
package/src/response.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Response constructors. Every response in the framework is built here so that
|
|
2
2
|
// content types, charsets and cache semantics are decided once instead of per route.
|
|
3
3
|
import { TIMEZONE_HEADER } from '@ultimat3/time';
|
|
4
|
-
import { toProblem } from './error-
|
|
4
|
+
import { toProblem } from './error-facts';
|
|
5
5
|
|
|
6
6
|
type HeaderSource = { readonly headers?: HeadersInit | undefined } | undefined;
|
|
7
7
|
|
package/src/stages.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type HttpConfig, stripBasePath } from './config';
|
|
|
19
19
|
import { actorView, elapsedMs, type RequestContext } from './context';
|
|
20
20
|
import { corsHeaders, preflight } from './cors';
|
|
21
21
|
import { checkCsrf, selfOrigin } from './csrf';
|
|
22
|
-
import { factsOf, retryAfterOf } from './error-
|
|
22
|
+
import { factsOf, retryAfterOf } from './error-facts';
|
|
23
23
|
import { errorPageResponse } from './error-page';
|
|
24
24
|
import {
|
|
25
25
|
bodyInvalid,
|
|
@@ -37,7 +37,7 @@ import { acceptsHtml } from './html-render';
|
|
|
37
37
|
import { readCookie } from './locale';
|
|
38
38
|
import { compose, type Middleware } from './middleware';
|
|
39
39
|
import { overlayResponse } from './overlay';
|
|
40
|
-
import { type RateLimiter,
|
|
40
|
+
import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
|
|
41
41
|
import { rateLimited } from './rate-limit-errors';
|
|
42
42
|
import type { UltimateRequest } from './request';
|
|
43
43
|
import { addVary, applyCacheHeaders, problem, redirect, SHARED_CACHE_VARY } from './response';
|
|
@@ -211,23 +211,45 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
211
211
|
'rate-limit': async (_request, ctx) => {
|
|
212
212
|
if (!config.rateLimit.enabled) return undefined;
|
|
213
213
|
const actor = actorView(ctx.actor);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
214
|
+
// A LIST, and the second entry is why: the key builder used to pick ONE subject —
|
|
215
|
+
// actor > org > ip, exclusive — so an authenticated request never touched a tenant bucket
|
|
216
|
+
// and one org's 8,000 seats each ran their own allowance against one shared pool.
|
|
217
|
+
const spends = rateLimitSpends(
|
|
218
|
+
{
|
|
219
|
+
actorId: actor?.id ?? null,
|
|
220
|
+
orgId: actor?.orgId ?? null,
|
|
221
|
+
ip: ctx.ip,
|
|
222
|
+
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
|
|
226
|
+
tenant: config.rateLimit.tenantBucket,
|
|
227
|
+
},
|
|
223
228
|
);
|
|
229
|
+
let answer: RateLimitDecision | undefined;
|
|
230
|
+
let refusedKey: string | undefined;
|
|
231
|
+
for (const spend of spends) {
|
|
232
|
+
const decision = await limiter.check(spend.key, spend.bucket);
|
|
233
|
+
// The bucket closest to refusing is the one the caller has to plan against: reporting
|
|
234
|
+
// `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
|
|
235
|
+
// tells a client it may proceed and then refuses its next call.
|
|
236
|
+
if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
|
|
237
|
+
if (!decision.allowed) {
|
|
238
|
+
// The first refusal ends the spend, so a caller its own bucket already refused costs
|
|
239
|
+
// its tenant nothing — one noisy actor may not drain the allowance it shares.
|
|
240
|
+
answer = decision;
|
|
241
|
+
refusedKey = spend.key;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (answer === undefined) return undefined;
|
|
224
246
|
// Recorded before the throw so the 429 can carry Retry-After and the
|
|
225
247
|
// RateLimit-* headers rather than making the client guess.
|
|
226
|
-
ctx.rateLimit =
|
|
227
|
-
for (const [name, value] of Object.entries(limiter.headers(
|
|
248
|
+
ctx.rateLimit = answer;
|
|
249
|
+
for (const [name, value] of Object.entries(limiter.headers(answer))) {
|
|
228
250
|
ctx.headers.set(name, value);
|
|
229
251
|
}
|
|
230
|
-
if (
|
|
252
|
+
if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
|
|
231
253
|
return undefined;
|
|
232
254
|
},
|
|
233
255
|
|
package/src/type-pins.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// test file and a claim written there can never fail. Nothing here emits or is imported — a
|
|
4
4
|
// regression is a build error, the only enforcement that counts (axiom 3).
|
|
5
5
|
|
|
6
|
+
import type { Ctx } from '@ultimat3/core';
|
|
7
|
+
import type { HttpConfig, HttpConfigInput } from './config';
|
|
8
|
+
import type { RequestContext } from './context';
|
|
6
9
|
import type { AuthzDecision } from './hooks';
|
|
7
10
|
|
|
8
11
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
@@ -46,3 +49,39 @@ export type _AuthzDenyNeedsAReason = Assert<
|
|
|
46
49
|
export type _AuthzDenyCodeIsOptional = Assert<
|
|
47
50
|
{ allowed: false; reason: string } extends AuthzDecision ? true : false
|
|
48
51
|
>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every key of the RESOLVED config is settable on the input, so nothing this package tunes is
|
|
55
|
+
* reachable only by editing this package.
|
|
56
|
+
*
|
|
57
|
+
* The whole HTTP tuning surface was unreachable from a shipped app until 12.0.0 — one fixed
|
|
58
|
+
* literal in `@ultimat3/cli` was its only construction — and the half of that defect a rule can
|
|
59
|
+
* see is this one: a key added to `HttpConfig` and forgotten on `HttpConfigInput` has a default
|
|
60
|
+
* nobody can override, silently, forever. `scripts/config-readers.ts` cannot see it either: that
|
|
61
|
+
* ratchet walks `AppConfig` and asks whether a key is READ, and this is the mirror question — can
|
|
62
|
+
* a key be WRITTEN. A build error naming the key beats both.
|
|
63
|
+
*/
|
|
64
|
+
type UnsettableHttpKey = Exclude<keyof HttpConfig, keyof HttpConfigInput>;
|
|
65
|
+
|
|
66
|
+
export type _EveryHttpConfigKeyIsSettable = Assert<
|
|
67
|
+
[UnsettableHttpKey] extends [never] ? true : false
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
// There is deliberately NO second pin claiming "every settable key is app-declarable or
|
|
71
|
+
// boot-owned". `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>`, so that union is
|
|
72
|
+
// `keyof HttpConfigInput` by construction and the assertion is vacuously true whatever anyone
|
|
73
|
+
// edits — a claim that cannot fail is not a claim. The derivation IS the enforcement there; this
|
|
74
|
+
// file only pins what a derivation cannot say.
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `RequestContext` IS a `Ctx`, so `asCtx` stays a checked widening rather than an assertion.
|
|
78
|
+
*
|
|
79
|
+
* `asCtx` already carries this claim at its own call site and this pin is not a duplicate of it:
|
|
80
|
+
* `asCtx` is a function body, and a future edit answering a failure there with a cast would delete
|
|
81
|
+
* the enforcement and leave the comment. A pin has nothing to cast.
|
|
82
|
+
*
|
|
83
|
+
* The direction that matters is this one and not the reverse — `Ctx extends RequestContext` is
|
|
84
|
+
* FALSE by design, because core's `Ctx` carries no `requestHeaders`, which is precisely what
|
|
85
|
+
* `assertInRequest` exists to prove one way at runtime.
|
|
86
|
+
*/
|
|
87
|
+
export type _RequestContextIsACtx = Assert<RequestContext extends Ctx ? true : false>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The inbound half of the framework's webhook mechanism: prove a request was signed by the holder
|
|
2
|
+
// of a shared secret, recently, over the bytes it actually carries. It is a plain function and not
|
|
3
|
+
// a pipeline stage because a receiver is an ordinary `api/` route — the secret is per sender, and
|
|
4
|
+
// only the route knows which one applies.
|
|
5
|
+
//
|
|
6
|
+
// THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here. That
|
|
7
|
+
// module is at the tier both halves can reach: `@ultimat3/jobs` (tier 3) signs a delivery and its
|
|
8
|
+
// boundary forbids this package, and this package (tier 2) may not reach tier 3. What stays here
|
|
9
|
+
// is the POLICY — what counts as fresh, how large a body may be, and which refusal a receiver
|
|
10
|
+
// answers with.
|
|
11
|
+
|
|
12
|
+
import type { Clock } from '@ultimat3/core';
|
|
13
|
+
import {
|
|
14
|
+
isCanonicalWebhookField,
|
|
15
|
+
parseWebhookSignatureHeader,
|
|
16
|
+
readWithinLimit,
|
|
17
|
+
systemClock,
|
|
18
|
+
timingSafeEqual,
|
|
19
|
+
WEBHOOK_FIELD_MAX,
|
|
20
|
+
WEBHOOK_ID_HEADER,
|
|
21
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
22
|
+
WEBHOOK_TOPIC_HEADER,
|
|
23
|
+
webhookMac,
|
|
24
|
+
} from '@ultimat3/core';
|
|
25
|
+
import { bodyInvalid, webhookSignatureInvalid, webhookSignatureStale } from './errors';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How far a delivery's timestamp may sit from this clock, either way. Five minutes is the window
|
|
29
|
+
* every sender in the wild already assumes, and it is a REPLAY BOUND, not a latency allowance: a
|
|
30
|
+
* captured request stops being usable after it, which is the only thing that keeps an intercepted
|
|
31
|
+
* delivery from being replayable forever.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_WEBHOOK_TOLERANCE_MS = 300_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Restated rather than read from `HttpConfig.bodyLimitBytes` (same number, `config.ts`): this
|
|
37
|
+
* function runs inside a route handler with a raw `Request` and no pipeline config in scope, and a
|
|
38
|
+
* receiver that must hold a 4 MB payload says so here rather than by widening every route's cap.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_WEBHOOK_BODY_LIMIT = 1_048_576;
|
|
41
|
+
|
|
42
|
+
export interface WebhookVerifyOptions {
|
|
43
|
+
/** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
|
|
44
|
+
readonly secret: string;
|
|
45
|
+
/** Defaults to `DEFAULT_WEBHOOK_TOLERANCE_MS`. */
|
|
46
|
+
readonly toleranceMs?: number;
|
|
47
|
+
/** Defaults to `DEFAULT_WEBHOOK_BODY_LIMIT`. Enforced while the body streams. */
|
|
48
|
+
readonly maxBytes?: number;
|
|
49
|
+
/** Defaults to `systemClock`. A window no test can freeze is a window no test pins. */
|
|
50
|
+
readonly clock?: Clock;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface VerifiedWebhook {
|
|
54
|
+
/**
|
|
55
|
+
* The sender's id for this event, signed and therefore unforgeable. It is the DEDUPE key: a
|
|
56
|
+
* delivery replayed inside the tolerance window verifies again by design, and this is what lets
|
|
57
|
+
* a receiver notice. The seen-set is the app's table — the framework has nowhere to keep one.
|
|
58
|
+
*/
|
|
59
|
+
readonly eventId: string;
|
|
60
|
+
/** The sender's routing label. Carried and signed, never interpreted (axiom 8). */
|
|
61
|
+
readonly topic: string;
|
|
62
|
+
/**
|
|
63
|
+
* The exact text the signature covers. Parse THIS, never `request.json()` — the body stream is
|
|
64
|
+
* spent, and a re-serialisation would not be the bytes that were signed.
|
|
65
|
+
*/
|
|
66
|
+
readonly body: string;
|
|
67
|
+
readonly signedAtMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Prove the request came from the holder of `secret`, inside the tolerance window, over the bytes
|
|
72
|
+
* it carries — and answer what was signed.
|
|
73
|
+
*
|
|
74
|
+
* The order is deliberate: the mac is checked BEFORE the window, so `X_WEBHOOK_SIGNATURE_STALE`
|
|
75
|
+
* means "authentic and old" and never "unreadable and old". An operator reading it goes to a clock
|
|
76
|
+
* or a replay, which is what that code is for.
|
|
77
|
+
*/
|
|
78
|
+
export async function verifyWebhookSignature(
|
|
79
|
+
request: Request,
|
|
80
|
+
options: WebhookVerifyOptions,
|
|
81
|
+
): Promise<VerifiedWebhook> {
|
|
82
|
+
const pathname = new URL(request.url).pathname;
|
|
83
|
+
const signature = parseWebhookSignatureHeader(request.headers.get(WEBHOOK_SIGNATURE_HEADER));
|
|
84
|
+
if (signature === undefined) {
|
|
85
|
+
throw webhookSignatureInvalid(
|
|
86
|
+
pathname,
|
|
87
|
+
`no readable ${WEBHOOK_SIGNATURE_HEADER} on the request`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const eventId = request.headers.get(WEBHOOK_ID_HEADER) ?? '';
|
|
92
|
+
const topic = request.headers.get(WEBHOOK_TOPIC_HEADER) ?? '';
|
|
93
|
+
if (!isCanonicalWebhookField(eventId) || !isCanonicalWebhookField(topic)) {
|
|
94
|
+
throw webhookSignatureInvalid(
|
|
95
|
+
pathname,
|
|
96
|
+
`${WEBHOOK_ID_HEADER} and ${WEBHOOK_TOPIC_HEADER} must each be 1-${WEBHOOK_FIELD_MAX} characters and carry no ":"`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const maxBytes = options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT;
|
|
101
|
+
// Through core's counting reader, the same one `UltimateRequest.#read` uses: a sender that
|
|
102
|
+
// announces no length must not be able to make this handler hold an unbounded payload before the
|
|
103
|
+
// signature it was never going to pass is even computed.
|
|
104
|
+
const read = await readWithinLimit(request.body, maxBytes);
|
|
105
|
+
if ('over' in read) {
|
|
106
|
+
throw bodyInvalid(pathname, [`body is at least ${read.over} bytes, limit is ${maxBytes}`]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The mac is core's, over the RAW bytes: an HMAC is over a byte stream, so hashing the prefix
|
|
110
|
+
// and then the body is identical to hashing one string — and it never round-trips a body that is
|
|
111
|
+
// not valid UTF-8 through a decoder before the mac is taken over it.
|
|
112
|
+
const expected = webhookMac({
|
|
113
|
+
secret: options.secret,
|
|
114
|
+
timestampText: signature.timestampText,
|
|
115
|
+
eventId,
|
|
116
|
+
topic,
|
|
117
|
+
body: read.bytes,
|
|
118
|
+
});
|
|
119
|
+
// `timingSafeEqual`, never `===`: this is a mac comparison, and where the two first differ is
|
|
120
|
+
// exactly what a timing oracle needs to forge one byte at a time.
|
|
121
|
+
if (!timingSafeEqual(expected, signature.mac)) {
|
|
122
|
+
throw webhookSignatureInvalid(pathname, 'the signature does not match the body that arrived');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const signedAtMs = signature.timestampSeconds * 1_000;
|
|
126
|
+
const toleranceMs = options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS;
|
|
127
|
+
const skewMs = Math.abs((options.clock ?? systemClock).now().getTime() - signedAtMs);
|
|
128
|
+
// Both directions: a sender whose clock runs ahead is the same replay window pointed the other
|
|
129
|
+
// way, and accepting the future half doubles it.
|
|
130
|
+
if (skewMs > toleranceMs) throw webhookSignatureStale(pathname, skewMs, toleranceMs);
|
|
131
|
+
|
|
132
|
+
return { eventId, topic, body: new TextDecoder().decode(read.bytes), signedAtMs };
|
|
133
|
+
}
|