@ultimat3/http 23.0.0 → 25.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 +75 -31
- package/README.md +89 -26
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +62 -32
- package/src/context.ts +11 -11
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +10 -8
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +62 -0
- package/src/error-map-tier-1.ts +73 -0
- package/src/error-map-tier-2.ts +120 -0
- package/src/error-map-tier-3.ts +100 -0
- package/src/error-map-tier-4.ts +116 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +3 -4
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +46 -96
- package/src/forwarded.ts +36 -10
- package/src/health-disclosure.ts +66 -0
- package/src/hooks.ts +6 -5
- package/src/html-render.ts +3 -17
- package/src/index.ts +36 -27
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +26 -1
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +32 -13
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +30 -21
- package/src/rate-limit-stage.ts +132 -0
- package/src/rate-limit.ts +40 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +12 -5
- package/src/response.ts +10 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +36 -8
- package/src/server.ts +101 -30
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +50 -54
- package/src/validate.ts +2 -2
package/src/server.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
beginWork,
|
|
8
8
|
configureLifecycle,
|
|
9
9
|
drain,
|
|
10
|
+
healthBody,
|
|
10
11
|
healthzPayload,
|
|
11
12
|
lifecycleState,
|
|
12
13
|
logger,
|
|
@@ -14,17 +15,20 @@ import {
|
|
|
14
15
|
markReady,
|
|
15
16
|
onShutdown,
|
|
16
17
|
readyzPayload,
|
|
18
|
+
systemClock,
|
|
17
19
|
} from '@ultimat3/core';
|
|
18
20
|
import type { Server } from 'bun';
|
|
19
21
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
20
22
|
import { HttpError, serverNotStarted } from './errors';
|
|
23
|
+
import { disclosesHealthDetail } from './health-disclosure';
|
|
21
24
|
import type { ServerHooks } from './hooks';
|
|
22
25
|
import type { Middleware } from './middleware';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
26
|
+
import { httpPipeline, type Pipeline } from './pipeline';
|
|
27
|
+
import { type RateLimitStore, rateLimiter } from './rate-limit';
|
|
25
28
|
import { withRouteBuckets } from './rate-limit-buckets';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
29
|
+
import { adoptRateLimitStore } from './rate-limit-installed';
|
|
30
|
+
import { jsonResponse } from './response';
|
|
31
|
+
import { describeRoutes, httpRouter, type Route, type RouteDescription } from './router';
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
|
|
@@ -33,13 +37,13 @@ import { createRouter, describeRoutes, type Route, type RouteDescription } from
|
|
|
33
37
|
* A websocket mount and something already answering its path. Same code as two routes claiming
|
|
34
38
|
* one, because it is the same fact: one path, two declarations, and the framework picks — Bun's
|
|
35
39
|
* native route table is matched BEFORE `fetch`, so the route wins and the upgrade never reaches
|
|
36
|
-
* the mount. Refused at `
|
|
40
|
+
* the mount. Refused at `httpServer`, not discovered as a socket that will not open.
|
|
37
41
|
*/
|
|
38
42
|
const websocketPathTaken = (path: string, answered: string): HttpError =>
|
|
39
43
|
new HttpError({
|
|
40
44
|
code: 'X_ROUTE_CONFLICT',
|
|
41
45
|
cause: `the websocket mount claims ${path}, and ${answered} already answers it — Bun matches its native route table before \`fetch\`, so the upgrade would never reach the mount`,
|
|
42
|
-
fix: `x routes
|
|
46
|
+
fix: `x routes --json # then move the mount's path, or the declaration at ${path}`,
|
|
43
47
|
});
|
|
44
48
|
|
|
45
49
|
/** Core owns the state machine; this alias exists so callers need one import. */
|
|
@@ -61,7 +65,7 @@ export interface ServerOptions {
|
|
|
61
65
|
* Where the rate limiter keeps its counters. Omitted means `memoryRateLimitStore()`, which is
|
|
62
66
|
* one process' worth of state — correct for dev and tests, and N × every configured number for
|
|
63
67
|
* N replicas. An app that runs more than one process declares `rateLimit.scope: 'shared'` and
|
|
64
|
-
* passes a store that says the same, or `
|
|
68
|
+
* passes a store that says the same, or `httpServer` refuses here.
|
|
65
69
|
*/
|
|
66
70
|
readonly rateLimitStore?: RateLimitStore;
|
|
67
71
|
/**
|
|
@@ -98,6 +102,8 @@ export interface ServerOptions {
|
|
|
98
102
|
*/
|
|
99
103
|
export interface UpgradeTarget {
|
|
100
104
|
upgrade(request: Request, options: { data: unknown }): boolean;
|
|
105
|
+
/** The peer's socket address — a mount that answers its own health paths decides detail by it. */
|
|
106
|
+
requestIP(request: Request): { readonly address: string } | null;
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
/**
|
|
@@ -138,7 +144,7 @@ export interface ServerHandle {
|
|
|
138
144
|
url(): string;
|
|
139
145
|
describe(): readonly RouteDescription[];
|
|
140
146
|
start(): ServerHandle;
|
|
141
|
-
/** Runs core's three-phase drain
|
|
147
|
+
/** Runs core's three-phase drain, on the deadline `drain.deadlineMs` set (`lifecycleForRole`). */
|
|
142
148
|
stop(): Promise<void>;
|
|
143
149
|
/**
|
|
144
150
|
* Runs one request through the entire lifecycle with no socket. This is the
|
|
@@ -147,20 +153,41 @@ export interface ServerHandle {
|
|
|
147
153
|
fetch(request: Request): Promise<Response>;
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
/** How often the `inflight` hook re-reads Bun's count of unfinished responses. */
|
|
157
|
+
const BODY_POLL_MS = 5;
|
|
158
|
+
|
|
150
159
|
const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
|
|
151
160
|
|
|
152
|
-
export const
|
|
161
|
+
export const httpServer = (options: ServerOptions): ServerHandle => {
|
|
162
|
+
// The store this server is handed is also the one actions and queries spend their declared
|
|
163
|
+
// `rateLimit:` from — adopted BEFORE the pipeline, whose boot check reads it, and never over a
|
|
164
|
+
// store the boot already chose (`keepChosen`). Given back on `stop()`, or by ANY refusal from
|
|
165
|
+
// here to the handle — the pipeline's checks, `configureLifecycle`, `assertMountPathFree` — so a
|
|
166
|
+
// boot that never produced a server leaves nothing behind it.
|
|
167
|
+
const releaseStore =
|
|
168
|
+
options.rateLimitStore === undefined
|
|
169
|
+
? () => undefined
|
|
170
|
+
: adoptRateLimitStore(options.rateLimitStore, { keepChosen: true });
|
|
171
|
+
try {
|
|
172
|
+
return buildServer(options, releaseStore);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
releaseStore();
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHandle => {
|
|
153
180
|
const role = options.role ?? roleFromEnv();
|
|
154
|
-
const table =
|
|
155
|
-
// Merged here as well as in `
|
|
181
|
+
const table = httpRouter(options.routes);
|
|
182
|
+
// Merged here as well as in `httpPipeline`, and for the store's sake: the limiter below is
|
|
156
183
|
// built from `config.rateLimit`, so a table without the routes' own buckets would resolve a
|
|
157
184
|
// declared name to `default` — the very hole this closes. `withRouteBuckets` is idempotent, so
|
|
158
185
|
// the pipeline's second pass changes nothing. `handle.config` is the merged one for the same
|
|
159
186
|
// reason: `server.config.rateLimit.buckets` has to be what the limiter runs on.
|
|
160
187
|
const config = withRouteBuckets(options.config ?? defineHttpConfig(), table.routes);
|
|
161
188
|
// The store feeds the limiter seam `PipelineDeps` already had, rather than becoming a second
|
|
162
|
-
// one: the bucket maths stays in `
|
|
163
|
-
const pipeline =
|
|
189
|
+
// one: the bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
|
|
190
|
+
const pipeline = httpPipeline({
|
|
164
191
|
table,
|
|
165
192
|
config,
|
|
166
193
|
...(options.hooks === undefined ? {} : { hooks: options.hooks }),
|
|
@@ -168,17 +195,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
168
195
|
...(options.rateLimitStore === undefined
|
|
169
196
|
? {}
|
|
170
197
|
: {
|
|
171
|
-
limiter:
|
|
198
|
+
limiter: rateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
|
|
172
199
|
}),
|
|
173
200
|
});
|
|
174
201
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// "the app said 15 seconds" are different claims and `null` is what keeps them apart.
|
|
180
|
-
if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
|
|
181
|
-
// Same rule: only a declared grace is applied. Core waits it out between the readiness flip and
|
|
202
|
+
// No deadline is set here. `drain.deadlineMs` is the one budget, applied per role by
|
|
203
|
+
// `lifecycleForRole` before any server exists; `http.drainTimeoutMs` re-set it on the web role
|
|
204
|
+
// only, after that, and was deleted in 25.0.0 for being a second answer (axiom 1).
|
|
205
|
+
// Only a declared grace is applied. Core waits it out between the readiness flip and
|
|
182
206
|
// the `accept` hook below, so endpoints stop routing here before the socket closes.
|
|
183
207
|
const readinessGraceMs = options.drain?.readinessGraceMs;
|
|
184
208
|
if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
|
|
@@ -186,6 +210,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
186
210
|
const mount = options.websocket;
|
|
187
211
|
let server: BunServer | undefined;
|
|
188
212
|
let unregister: (() => void) | undefined;
|
|
213
|
+
let unregisterBodies: (() => void) | undefined;
|
|
189
214
|
let unregisterClose: (() => void) | undefined;
|
|
190
215
|
let stopListening: (() => void) | undefined;
|
|
191
216
|
|
|
@@ -193,10 +218,21 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
193
218
|
* Core owns the health state, the in-flight count and the drain deadline so every
|
|
194
219
|
* role reports identically. `HealthPayload` is `{ ok, status, body }` — core stays
|
|
195
220
|
* HTTP-free and hands us the status code as data, which we render here.
|
|
221
|
+
*
|
|
222
|
+
* The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a
|
|
223
|
+
* peer `healthDetailPeers` lists: these two paths answer outside the pipeline, unauthenticated.
|
|
196
224
|
*/
|
|
197
|
-
const healthResponse = (payload: HealthPayload): Response =>
|
|
198
|
-
|
|
199
|
-
|
|
225
|
+
const healthResponse = (payload: HealthPayload, request: Request, socket: BunServer): Response =>
|
|
226
|
+
jsonResponse(
|
|
227
|
+
healthBody(
|
|
228
|
+
payload.body,
|
|
229
|
+
role,
|
|
230
|
+
disclosesHealthDetail({
|
|
231
|
+
config,
|
|
232
|
+
headers: request.headers,
|
|
233
|
+
socketAddress: socket.requestIP(request)?.address ?? null,
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
200
236
|
{ status: payload.status, headers: { 'cache-control': 'no-store' } },
|
|
201
237
|
);
|
|
202
238
|
|
|
@@ -232,7 +268,10 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
232
268
|
const out: Record<string, NativeHandler> = {};
|
|
233
269
|
for (const description of describeRoutes(table)) {
|
|
234
270
|
if (description.params.length > 0) continue;
|
|
235
|
-
|
|
271
|
+
// `encodeURI`: `Bun.serve` refuses a non-ASCII key with a bare `TypeError`, so one route named
|
|
272
|
+
// `/precios-españa` kept the whole server from starting. Encoded as `URL.pathname` spells
|
|
273
|
+
// it; any other spelling falls through to `fetch`, where the router decodes it.
|
|
274
|
+
out[encodeURI(`${prefix}${description.path}`)] = dispatch;
|
|
236
275
|
}
|
|
237
276
|
return out;
|
|
238
277
|
};
|
|
@@ -289,11 +328,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
289
328
|
...nativeRoutes(),
|
|
290
329
|
// Health endpoints answer outside the pipeline on purpose: a draining or
|
|
291
330
|
// rate-limited process must still be able to say what it is doing.
|
|
292
|
-
'/healthz': () =>
|
|
331
|
+
'/healthz': (request: Request, socket: BunServer) =>
|
|
332
|
+
healthResponse(healthzPayload(), request, socket),
|
|
293
333
|
// `?deep=1` always answers on the dependencies, whatever `health.readiness` says.
|
|
294
|
-
'/readyz': (request: Request) =>
|
|
334
|
+
'/readyz': (request: Request, socket: BunServer) =>
|
|
295
335
|
healthResponse(
|
|
296
336
|
readyzPayload({ deep: new URL(request.url).searchParams.get('deep') === '1' }),
|
|
337
|
+
request,
|
|
338
|
+
socket,
|
|
297
339
|
),
|
|
298
340
|
},
|
|
299
341
|
};
|
|
@@ -321,14 +363,40 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
321
363
|
// 'accept' runs first on SIGTERM, but only after core's readiness grace: readyz flips to
|
|
322
364
|
// 503 the moment the drain starts, the socket stays open for `readinessGraceMs`, so the
|
|
323
365
|
// endpoints controller stops routing here before this closes it — not a 502 in between.
|
|
366
|
+
//
|
|
367
|
+
// NOT awaited: Bun's `stop(false)` stops the listener at once but resolves only when every
|
|
368
|
+
// request AND every websocket on it has closed — so one open browser tab held this hook, and
|
|
369
|
+
// every `accept` hook after it (worker, scheduler), until the drain deadline, leaving the
|
|
370
|
+
// `close` hooks 0 ms. Handlers are core's to wait for (`beginWork()`), bodies the `inflight`
|
|
371
|
+
// hook's below, sockets the `close` hook's `stop(true)`. The same shape as
|
|
372
|
+
// `realtime/src/sync-listen.ts`. A rejection is the close hook's to surface, not this one's.
|
|
324
373
|
unregister = onShutdown(
|
|
325
374
|
`http:${role}`,
|
|
326
|
-
|
|
327
|
-
|
|
375
|
+
() => {
|
|
376
|
+
server?.stop(false).catch(() => undefined);
|
|
328
377
|
},
|
|
329
378
|
{ phase: 'accept' },
|
|
330
379
|
);
|
|
331
|
-
//
|
|
380
|
+
// The BODIES. `beginWork()` ends when a handler returns its Response, and a streamed body —
|
|
381
|
+
// a page render, a file, an LLM stream — is still being written after that; awaiting
|
|
382
|
+
// `stop(false)` above was what used to wait for it, websockets and all. Bun's
|
|
383
|
+
// `pendingRequests` counts exactly the responses not yet finished (a client that went away
|
|
384
|
+
// included as finished), never an upgraded socket, so this waits for bodies alone and costs
|
|
385
|
+
// the request path nothing. Polled, and bounded by the drain's own deadline: a body that
|
|
386
|
+
// never ends (SSE) is cut by the `close` hook's `stop(true)`, not waited on forever.
|
|
387
|
+
unregisterBodies = onShutdown(
|
|
388
|
+
`http:${role}:bodies`,
|
|
389
|
+
async (reason) => {
|
|
390
|
+
while (
|
|
391
|
+
(server?.pendingRequests ?? 0) > 0 &&
|
|
392
|
+
systemClock.monotonic() < reason.deadlineAt
|
|
393
|
+
) {
|
|
394
|
+
await Bun.sleep(BODY_POLL_MS);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
{ phase: 'inflight' },
|
|
398
|
+
);
|
|
399
|
+
// 'close' runs after core has waited out the in-flight phase, bodies included.
|
|
332
400
|
unregisterClose = onShutdown(
|
|
333
401
|
`http:${role}:close`,
|
|
334
402
|
async () => {
|
|
@@ -350,9 +418,12 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
350
418
|
// core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
|
|
351
419
|
// holds: the unregisters are the closure's, not the drain's.
|
|
352
420
|
const releaseHooks = (): void => {
|
|
421
|
+
releaseStore();
|
|
353
422
|
unregister?.();
|
|
423
|
+
unregisterBodies?.();
|
|
354
424
|
unregisterClose?.();
|
|
355
425
|
unregister = undefined;
|
|
426
|
+
unregisterBodies = undefined;
|
|
356
427
|
unregisterClose = undefined;
|
|
357
428
|
};
|
|
358
429
|
if (server === undefined) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// How a route handler, a page loader or an action sets a cookie on the response it cannot build:
|
|
2
|
+
// one serialized line, from core's one serializer, appended to the context's response-header bag —
|
|
3
|
+
// the bag the `response` stage APPENDS `set-cookie` from, so two cookies are two lines, never one.
|
|
4
|
+
|
|
5
|
+
import { CookieInvalidError, type SetCookieOptions, serializeSetCookie } from '@ultimat3/core';
|
|
6
|
+
import { assertInRequest } from './context';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What identifies the cookie being cleared, plus the attributes its prefix or partition requires.
|
|
10
|
+
* A browser deletes only the cookie whose name, `Path` and `Domain` all match — `Partitioned` too
|
|
11
|
+
* for a CHIPS cookie — so these must be what `setCookie` was given. No `maxAge`, `expires` or
|
|
12
|
+
* `priority`: the clearing line decides those itself.
|
|
13
|
+
*/
|
|
14
|
+
export type DeleteCookieOptions = Pick<
|
|
15
|
+
SetCookieOptions,
|
|
16
|
+
'path' | 'domain' | 'secure' | 'httpOnly' | 'sameSite' | 'partitioned'
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
const DELETE_FIX = 'deleteCookie(name, { path, domain }) # the one way to clear a cookie';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Clearing has ONE spelling, `deleteCookie`, so a reader searching for where a cookie dies finds
|
|
23
|
+
* every site. `maxAge: 0` and an `expires` already past both clear in a browser, so both are
|
|
24
|
+
* refused here rather than left as a second, unsearchable way to do the same thing.
|
|
25
|
+
*/
|
|
26
|
+
function refuseClearing(name: string, options: SetCookieOptions, now: Date): void {
|
|
27
|
+
if (options.maxAge === 0) {
|
|
28
|
+
throw new CookieInvalidError('maxAge', name, 'is 0, which deletes the cookie', DELETE_FIX);
|
|
29
|
+
}
|
|
30
|
+
const { expires } = options;
|
|
31
|
+
if (expires instanceof Date && expires.getTime() <= now.getTime()) {
|
|
32
|
+
throw new CookieInvalidError(
|
|
33
|
+
'expires',
|
|
34
|
+
name,
|
|
35
|
+
'is not after now, so it deletes the cookie',
|
|
36
|
+
DELETE_FIX,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Set cookie `name` on the response to the request in scope. Defaults are core's:
|
|
43
|
+
* `Path=/; HttpOnly; Secure; SameSite=Lax`. Every call APPENDS a line — the same name on two
|
|
44
|
+
* paths is two cookies — and an invalid cookie is `X_COOKIE_INVALID` before anything is appended.
|
|
45
|
+
* Outside a request it throws `X_NO_REQUEST`: a cookie nobody will receive is a silent failure.
|
|
46
|
+
*/
|
|
47
|
+
export const setCookie = (name: string, value: string, options: SetCookieOptions = {}): void => {
|
|
48
|
+
const ctx = assertInRequest('setCookie()');
|
|
49
|
+
refuseClearing(name, options, ctx.now());
|
|
50
|
+
ctx.headers.append('set-cookie', serializeSetCookie(name, value, options));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear cookie `name`: an empty value and `Max-Age=0`, with the `Path` and `Domain` it was set
|
|
55
|
+
* with (defaults the same as `setCookie`'s, so a cookie set with none is cleared with none).
|
|
56
|
+
*/
|
|
57
|
+
export const deleteCookie = (name: string, options: DeleteCookieOptions = {}): void => {
|
|
58
|
+
const ctx = assertInRequest('deleteCookie()');
|
|
59
|
+
ctx.headers.append('set-cookie', serializeSetCookie(name, '', { ...options, maxAge: 0 }));
|
|
60
|
+
};
|
package/src/stages.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
anonymousActor,
|
|
9
|
+
BUILD_ID_HEADER,
|
|
9
10
|
inflightCount,
|
|
10
11
|
isAnonymous,
|
|
11
12
|
isDraining,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
import { signInRedirect } from './auth-redirect';
|
|
16
17
|
import { finalizeCacheHeaders } from './cache-stage';
|
|
17
18
|
import { type HttpConfig, stripBasePath } from './config';
|
|
18
|
-
import {
|
|
19
|
+
import { elapsedMs, type RequestContext } from './context';
|
|
19
20
|
import { corsHeaders, preflight } from './cors';
|
|
20
21
|
import { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
|
|
21
22
|
import { factsOf, retryAfterOf } from './error-facts';
|
|
@@ -38,13 +39,17 @@ import { compose, type Middleware } from './middleware';
|
|
|
38
39
|
import { navigationGate, redirectForRouter } from './navigation';
|
|
39
40
|
import { overlayResponse } from './overlay';
|
|
40
41
|
import { resolvePreferences } from './preferences';
|
|
41
|
-
import
|
|
42
|
-
import {
|
|
42
|
+
import type { RateLimiter } from './rate-limit';
|
|
43
|
+
import {
|
|
44
|
+
refuseExhaustedAddress,
|
|
45
|
+
spendRequestBuckets,
|
|
46
|
+
spendUnauthenticated,
|
|
47
|
+
} from './rate-limit-stage';
|
|
43
48
|
import type { UltimateRequest } from './request';
|
|
44
49
|
import { addVary, problem, redirect } from './response';
|
|
45
50
|
import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
|
|
46
51
|
import { responseSecurityHeaders } from './security-headers';
|
|
47
|
-
import {
|
|
52
|
+
import { validateBody } from './validate';
|
|
48
53
|
|
|
49
54
|
export type StageName =
|
|
50
55
|
| 'request-id'
|
|
@@ -93,6 +98,10 @@ export interface Stage extends StageDoc {
|
|
|
93
98
|
*/
|
|
94
99
|
const SHED_RETRY_AFTER_SECONDS = '1';
|
|
95
100
|
|
|
101
|
+
/** The two names a policy travels under — the enforced one and `reportOnly`'s. */
|
|
102
|
+
const isCspHeader = (name: string): boolean =>
|
|
103
|
+
name === 'content-security-policy' || name === 'content-security-policy-report-only';
|
|
104
|
+
|
|
96
105
|
/**
|
|
97
106
|
* The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
|
|
98
107
|
* would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
|
|
@@ -165,12 +174,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
165
174
|
const answered = preflight(request.raw, config.cors);
|
|
166
175
|
if (answered !== undefined) return answered;
|
|
167
176
|
|
|
168
|
-
ctx.clientBuildId = request.header(
|
|
177
|
+
ctx.clientBuildId = request.header(BUILD_ID_HEADER);
|
|
169
178
|
// Stamped BEFORE the assertion, so the refusal carries it too. A skew answer that
|
|
170
179
|
// withholds the server's own id tells a client it is stale without telling it what to
|
|
171
180
|
// become — and the one caller that can act on that, the service worker holding the
|
|
172
181
|
// stale id, then has no way to tell this 409 from any other.
|
|
173
|
-
if (config.buildId !== null) ctx.headers.set(
|
|
182
|
+
if (config.buildId !== null) ctx.headers.set(BUILD_ID_HEADER, config.buildId);
|
|
174
183
|
request.assertBuild();
|
|
175
184
|
|
|
176
185
|
// A `/<locale>/` prefix is stripped BEFORE the match, never by middleware: middleware wraps
|
|
@@ -210,6 +219,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
210
219
|
// A route's own authenticator REPLACES the app's, never runs beside it: a bearer mount must
|
|
211
220
|
// not also accept the session cookie the app's hook would resolve (`RouteMeta.authenticate`).
|
|
212
221
|
const authenticate = ctx.route?.meta.authenticate ?? hooks.authenticate;
|
|
222
|
+
// A required route is where credentials are guessed: an address that has spent its failures
|
|
223
|
+
// is refused before the credential store is asked, a right guess included.
|
|
224
|
+
if (ctx.route?.meta.auth === 'required') {
|
|
225
|
+
await refuseExhaustedAddress(ctx, limiter, config.rateLimit);
|
|
226
|
+
}
|
|
213
227
|
if (authenticate !== undefined) {
|
|
214
228
|
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
215
229
|
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
@@ -221,53 +235,21 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
237
|
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
238
|
+
// This refusal leaves before `rate-limit` runs, so it is metered here or nowhere: the
|
|
239
|
+
// address's own allowance, spent only by a failure (`spendUnauthenticated`).
|
|
240
|
+
await spendUnauthenticated(ctx, limiter, config.rateLimit);
|
|
224
241
|
throw unauthenticated(ctx.url.pathname);
|
|
225
242
|
}
|
|
226
243
|
return undefined;
|
|
227
244
|
},
|
|
228
245
|
|
|
229
246
|
'rate-limit': async (_request, ctx) => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const spends = rateLimitSpends(
|
|
236
|
-
{
|
|
237
|
-
actorId: actor?.id ?? null,
|
|
238
|
-
orgId: actor?.orgId ?? null,
|
|
239
|
-
ip: ctx.ip,
|
|
240
|
-
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
|
|
244
|
-
tenant: config.rateLimit.tenantBucket,
|
|
245
|
-
},
|
|
247
|
+
await spendRequestBuckets(
|
|
248
|
+
ctx,
|
|
249
|
+
limiter,
|
|
250
|
+
config.rateLimit,
|
|
251
|
+
ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
246
252
|
);
|
|
247
|
-
let answer: RateLimitDecision | undefined;
|
|
248
|
-
let refusedKey: string | undefined;
|
|
249
|
-
for (const spend of spends) {
|
|
250
|
-
const decision = await limiter.check(spend.key, spend.bucket);
|
|
251
|
-
// The bucket closest to refusing is the one the caller has to plan against: reporting
|
|
252
|
-
// `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
|
|
253
|
-
// tells a client it may proceed and then refuses its next call.
|
|
254
|
-
if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
|
|
255
|
-
if (!decision.allowed) {
|
|
256
|
-
// The first refusal ends the spend, so a caller its own bucket already refused costs
|
|
257
|
-
// its tenant nothing — one noisy actor may not drain the allowance it shares.
|
|
258
|
-
answer = decision;
|
|
259
|
-
refusedKey = spend.key;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
if (answer === undefined) return undefined;
|
|
264
|
-
// Recorded before the throw so the 429 can carry Retry-After and the
|
|
265
|
-
// RateLimit-* headers rather than making the client guess.
|
|
266
|
-
ctx.rateLimit = answer;
|
|
267
|
-
for (const [name, value] of Object.entries(limiter.headers(answer))) {
|
|
268
|
-
ctx.headers.set(name, value);
|
|
269
|
-
}
|
|
270
|
-
if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
|
|
271
253
|
return undefined;
|
|
272
254
|
},
|
|
273
255
|
|
|
@@ -292,7 +274,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
292
274
|
body: async (request, ctx) => {
|
|
293
275
|
const schema = ctx.route?.meta.input;
|
|
294
276
|
if (schema === undefined) return undefined;
|
|
295
|
-
const outcome = await
|
|
277
|
+
const outcome = await validateBody(schema, await request.bodyRaw());
|
|
296
278
|
if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
|
|
297
279
|
ctx.input = outcome.value;
|
|
298
280
|
return undefined;
|
|
@@ -394,7 +376,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
394
376
|
// never handed a page. Which of the two a browser gets is the ENVIRONMENT — the overlay
|
|
395
377
|
// prints the cause, the fix and the stack, which is what a visitor may never see.
|
|
396
378
|
if (acceptsHtml(request.raw)) {
|
|
397
|
-
|
|
379
|
+
// A 4xx is not a defect to debug, it is a page a visitor will see, so dev asks for the
|
|
380
|
+
// app's own file first (#492, O-492). A 5xx keeps the overlay, and so does a 4xx the app
|
|
381
|
+
// has no file for: in dev the diagnostic beats the framework's generic page.
|
|
382
|
+
// A throw from the hook is caught by `recoverWith` and degrades to the problem document.
|
|
383
|
+
const override =
|
|
384
|
+
config.dev && facts.status >= 500
|
|
385
|
+
? undefined
|
|
386
|
+
: await hooks.errorPage?.(facts.status, ctx);
|
|
387
|
+
if (config.dev && override === undefined) {
|
|
398
388
|
// Asked for inside the branch, never above it: the overlay is the only surface a notice
|
|
399
389
|
// has, so a production process — or an agent that asked for json — must not pay a
|
|
400
390
|
// diagnostic's per-request cost to produce findings nothing will render.
|
|
@@ -420,10 +410,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
420
410
|
...(seconds === undefined ? {} : { retryAfterSeconds: seconds }),
|
|
421
411
|
signInPath: config.signInPath,
|
|
422
412
|
},
|
|
423
|
-
|
|
424
|
-
// caught by `recoverWith` and degrades to the problem document, which is the answer a
|
|
425
|
-
// page whose renderer failed can still give.
|
|
426
|
-
{ override: await hooks.errorPage?.(facts.status, ctx), headers: retryAfter },
|
|
413
|
+
{ override, headers: retryAfter },
|
|
427
414
|
);
|
|
428
415
|
}
|
|
429
416
|
return problem(error, {
|
|
@@ -446,7 +433,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
446
433
|
if (handedOver !== undefined) ctx.response = handedOver;
|
|
447
434
|
const response = ctx.response;
|
|
448
435
|
if (response === undefined) return undefined;
|
|
449
|
-
|
|
436
|
+
// `Set-Cookie` is the one header that is a LIST of field lines: `set` in this loop kept the
|
|
437
|
+
// last cookie only, and dropped any the handler had put on its own Response.
|
|
438
|
+
for (const [name, value] of ctx.headers) {
|
|
439
|
+
if (name === 'set-cookie') response.headers.append(name, value);
|
|
440
|
+
else response.headers.set(name, value);
|
|
441
|
+
}
|
|
450
442
|
for (const [name, value] of Object.entries(
|
|
451
443
|
corsHeaders(config.cors, request.header('origin')),
|
|
452
444
|
)) {
|
|
@@ -462,7 +454,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
462
454
|
for (const [name, value] of Object.entries(
|
|
463
455
|
responseSecurityHeaders(config.security, ctx.https),
|
|
464
456
|
)) {
|
|
465
|
-
|
|
457
|
+
// A policy the handler set is KEPT and the app's is added beside it — comma-joined, two
|
|
458
|
+
// policies, both enforced — so a handler can only narrow (a `sandbox` on an uploaded file),
|
|
459
|
+
// never widen. Overwritten, that `sandbox` never reached a browser.
|
|
460
|
+
if (isCspHeader(name) && response.headers.has(name)) response.headers.append(name, value);
|
|
461
|
+
else response.headers.set(name, value);
|
|
466
462
|
}
|
|
467
463
|
response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
|
|
468
464
|
return undefined;
|
package/src/validate.ts
CHANGED
|
@@ -55,12 +55,12 @@ const outcome = <Out>(result: {
|
|
|
55
55
|
export const validateSync = <Out>(schema: Schema<Out>, value: unknown): ValidationOutcome<Out> => {
|
|
56
56
|
const result = schema['~standard'].validate(value);
|
|
57
57
|
if (result instanceof Promise) {
|
|
58
|
-
return { ok: false, issues: ['schema is async; use
|
|
58
|
+
return { ok: false, issues: ['schema is async; use validateBody() for request bodies'] };
|
|
59
59
|
}
|
|
60
60
|
return outcome<Out>(result);
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
export const
|
|
63
|
+
export const validateBody = async <Out>(
|
|
64
64
|
schema: Schema<Out>,
|
|
65
65
|
value: unknown,
|
|
66
66
|
): Promise<ValidationOutcome<Out>> => outcome<Out>(await schema['~standard'].validate(value));
|