@ultimat3/http 22.15.0 → 24.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 +41 -15
- package/README.md +39 -9
- package/package.json +6 -5
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +36 -4
- package/src/context.ts +1 -1
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +8 -3
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +60 -0
- package/src/error-map-tier-1.ts +71 -0
- package/src/error-map-tier-2.ts +118 -0
- package/src/error-map-tier-3.ts +92 -0
- package/src/error-map-tier-4.ts +92 -0
- package/src/error-map.ts +27 -452
- package/src/error-page.ts +1 -2
- package/src/errors.ts +3 -3
- package/src/forwarded.ts +16 -5
- package/src/health-disclosure.ts +69 -0
- package/src/hooks.ts +12 -1
- package/src/html-render.ts +3 -17
- package/src/index.ts +7 -8
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +13 -0
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +20 -5
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +27 -18
- package/src/rate-limit-stage.ts +123 -0
- package/src/rate-limit.ts +18 -0
- package/src/request.ts +10 -3
- package/src/response.ts +8 -0
- package/src/router.ts +20 -7
- package/src/server.ts +27 -6
- package/src/stages.ts +38 -46
package/src/request.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
// context so they cannot drift from what the pipeline resolved.
|
|
4
4
|
|
|
5
5
|
import type { Actor } from '@ultimat3/core';
|
|
6
|
-
import { readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { readCookie, readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { RequestContext } from './context';
|
|
8
8
|
import { bodyInvalid, buildSkew } from './errors';
|
|
9
|
-
import { readCookie } from './locale';
|
|
10
9
|
import type { Schema } from './validate';
|
|
11
10
|
import { validate, validateSync } from './validate';
|
|
12
11
|
|
|
@@ -203,7 +202,15 @@ export class UltimateRequest {
|
|
|
203
202
|
// An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
|
|
204
203
|
// `content-length: 0`, and reading that as `undefined` failed every schema with 400.
|
|
205
204
|
const form = type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data';
|
|
206
|
-
|
|
205
|
+
// No declared type is only "no body" when there are no bytes. `{"a":1}` sent bare used to
|
|
206
|
+
// read as `undefined`, so a schema whose fields are all optional validated a request nobody
|
|
207
|
+
// parsed. `bodyBytes()` stays the door for a body that is bytes by design (a signed upload).
|
|
208
|
+
if (type === '') {
|
|
209
|
+
if (declared === 0 || (await this.bodyBytes()).byteLength === 0) return undefined;
|
|
210
|
+
throw bodyInvalid(this.pathname, [
|
|
211
|
+
'the request carries a body and no content-type header saying what it is',
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
207
214
|
// A multipart body is only a FORM with the boundary its header must announce. Checked before
|
|
208
215
|
// the empty-form shortcut below, so a zero-length body cannot launder a malformed header into
|
|
209
216
|
// `{}` — without it the parser would have refused, and an empty body must refuse the same.
|
package/src/response.ts
CHANGED
|
@@ -216,6 +216,14 @@ export const applyCacheHeaders = (response: Response, hint: CacheHint): Response
|
|
|
216
216
|
response.headers.set('surrogate-key', hint.tags.join(' '));
|
|
217
217
|
response.headers.set('cache-tag', hint.tags.join(','));
|
|
218
218
|
}
|
|
219
|
+
// A response REWRITTEN to private or no-store may already carry keys: the `cache-headers` stage
|
|
220
|
+
// turns an `isr` document's shared header into `private` for a signed-in visitor, and the keys
|
|
221
|
+
// the render wrote beside it would stay on what is now a per-user document — purge keys an
|
|
222
|
+
// intermediary has no business storing it under.
|
|
223
|
+
if (!shared) {
|
|
224
|
+
response.headers.delete('surrogate-key');
|
|
225
|
+
response.headers.delete('cache-tag');
|
|
226
|
+
}
|
|
219
227
|
// `cookie` is not optional on the shared path. A `public` response is stored by a CDN under the
|
|
220
228
|
// URL, and every session in this framework travels in a cookie — so without it the first
|
|
221
229
|
// signed-in render of a public page is what every later visitor is served. `SHARED_CACHE_VARY`
|
package/src/router.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// PRECEDENCE (deterministic, tested in router.test.ts):
|
|
6
6
|
// 1. static segment `/posts/new`
|
|
7
7
|
// 2. param segment `/posts/:id`
|
|
8
|
-
// 3. wildcard segment `/posts/*rest` (matches
|
|
8
|
+
// 3. wildcard segment `/posts/*rest` (matches ZERO or more remaining segments)
|
|
9
9
|
// Depth-first with backtracking: `/posts/new` beats `/posts/:id` even though the
|
|
10
10
|
// param branch would also match, and a dead end in the static branch still falls
|
|
11
11
|
// back to the param branch. Two routes that would tie are a build error
|
|
@@ -241,19 +241,32 @@ const candidates = (
|
|
|
241
241
|
): void => {
|
|
242
242
|
if (index === segments.length) {
|
|
243
243
|
if (current.routes.size > 0) search.out.push({ node: current, params });
|
|
244
|
+
// A catch-all with an EMPTY rest: `/docs` for `/docs/*path`, after any static route at
|
|
245
|
+
// `/docs` itself. It is the path `@ultimat3/render`'s static build writes for that page
|
|
246
|
+
// (`fillPath` fills an absent rest with `''`), and its `compilePattern` matches it — a server
|
|
247
|
+
// that 404ed it served a page the export and the ISR lookup both said existed.
|
|
248
|
+
const rest = current.wildcard;
|
|
249
|
+
if (rest !== undefined && rest.node.routes.size > 0) {
|
|
250
|
+
search.out.push({ node: rest.node, params: { ...params, [rest.name]: '' } });
|
|
251
|
+
}
|
|
244
252
|
return;
|
|
245
253
|
}
|
|
246
254
|
const segment = segments[index];
|
|
247
255
|
if (segment === undefined) return;
|
|
256
|
+
// Decoded ONCE, for every branch. `undefined` is a malformed escape, which fails only the
|
|
257
|
+
// branches that needed the decoded form: the raw static lookup below still runs.
|
|
258
|
+
const decoded = decodeSegment(segment);
|
|
248
259
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
const staticChild =
|
|
260
|
+
// Raw first, then the decoded spelling: a route file is named in Unicode (`precios-españa`) and
|
|
261
|
+
// `URL.pathname` encodes it (`precios-espa%C3%B1a`), so a raw-only lookup 404ed every non-ASCII
|
|
262
|
+
// literal. A static route still wins the precedence it always won.
|
|
263
|
+
const staticChild =
|
|
264
|
+
current.statics.get(segment) ??
|
|
265
|
+
(decoded !== undefined && decoded !== segment ? current.statics.get(decoded) : undefined);
|
|
253
266
|
if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params, search);
|
|
254
267
|
|
|
255
268
|
if (current.param !== undefined) {
|
|
256
|
-
const value =
|
|
269
|
+
const value = decoded;
|
|
257
270
|
if (value === undefined) search.undecodable ??= segment;
|
|
258
271
|
else {
|
|
259
272
|
const next = { ...params, [current.param.name]: value };
|
|
@@ -330,7 +343,7 @@ const paramsOf = (path: string): readonly string[] =>
|
|
|
330
343
|
.filter((segment) => segment.startsWith(':') || segment.startsWith('*'))
|
|
331
344
|
.map((segment) => segment.slice(1));
|
|
332
345
|
|
|
333
|
-
/** Feeds the `/_x` dashboard, the manifest emitter and `x routes
|
|
346
|
+
/** Feeds the `/_x` dashboard, the manifest emitter and `x routes --json`. */
|
|
334
347
|
export const describeRoutes = (table: RouteTable): readonly RouteDescription[] =>
|
|
335
348
|
table.routes
|
|
336
349
|
.map((route) => ({
|
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,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
import type { Server } from 'bun';
|
|
19
20
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
20
21
|
import { HttpError, serverNotStarted } from './errors';
|
|
22
|
+
import { disclosesHealthDetail } from './health-disclosure';
|
|
21
23
|
import type { ServerHooks } from './hooks';
|
|
22
24
|
import type { Middleware } from './middleware';
|
|
23
25
|
import { createPipeline, type Pipeline } from './pipeline';
|
|
@@ -39,7 +41,7 @@ const websocketPathTaken = (path: string, answered: string): HttpError =>
|
|
|
39
41
|
new HttpError({
|
|
40
42
|
code: 'X_ROUTE_CONFLICT',
|
|
41
43
|
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
|
|
44
|
+
fix: `x routes --json # then move the mount's path, or the declaration at ${path}`,
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
/** Core owns the state machine; this alias exists so callers need one import. */
|
|
@@ -98,6 +100,8 @@ export interface ServerOptions {
|
|
|
98
100
|
*/
|
|
99
101
|
export interface UpgradeTarget {
|
|
100
102
|
upgrade(request: Request, options: { data: unknown }): boolean;
|
|
103
|
+
/** The peer's socket address — a mount that answers its own health paths decides detail by it. */
|
|
104
|
+
requestIP(request: Request): { readonly address: string } | null;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/**
|
|
@@ -193,10 +197,21 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
193
197
|
* Core owns the health state, the in-flight count and the drain deadline so every
|
|
194
198
|
* role reports identically. `HealthPayload` is `{ ok, status, body }` — core stays
|
|
195
199
|
* HTTP-free and hands us the status code as data, which we render here.
|
|
200
|
+
*
|
|
201
|
+
* The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a
|
|
202
|
+
* peer `healthDetailPeers` lists: these two paths answer outside the pipeline, unauthenticated.
|
|
196
203
|
*/
|
|
197
|
-
const healthResponse = (payload: HealthPayload): Response =>
|
|
204
|
+
const healthResponse = (payload: HealthPayload, request: Request, socket: BunServer): Response =>
|
|
198
205
|
json(
|
|
199
|
-
|
|
206
|
+
healthBody(
|
|
207
|
+
payload.body,
|
|
208
|
+
role,
|
|
209
|
+
disclosesHealthDetail({
|
|
210
|
+
config,
|
|
211
|
+
headers: request.headers,
|
|
212
|
+
socketAddress: socket.requestIP(request)?.address ?? null,
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
200
215
|
{ status: payload.status, headers: { 'cache-control': 'no-store' } },
|
|
201
216
|
);
|
|
202
217
|
|
|
@@ -232,7 +247,10 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
232
247
|
const out: Record<string, NativeHandler> = {};
|
|
233
248
|
for (const description of describeRoutes(table)) {
|
|
234
249
|
if (description.params.length > 0) continue;
|
|
235
|
-
|
|
250
|
+
// `encodeURI`: `Bun.serve` refuses a non-ASCII key with a bare `TypeError`, so one route named
|
|
251
|
+
// `/precios-españa` kept the whole server from starting. Encoded as `URL.pathname` spells
|
|
252
|
+
// it; any other spelling falls through to `fetch`, where the router decodes it.
|
|
253
|
+
out[encodeURI(`${prefix}${description.path}`)] = dispatch;
|
|
236
254
|
}
|
|
237
255
|
return out;
|
|
238
256
|
};
|
|
@@ -289,11 +307,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
289
307
|
...nativeRoutes(),
|
|
290
308
|
// Health endpoints answer outside the pipeline on purpose: a draining or
|
|
291
309
|
// rate-limited process must still be able to say what it is doing.
|
|
292
|
-
'/healthz': () =>
|
|
310
|
+
'/healthz': (request: Request, socket: BunServer) =>
|
|
311
|
+
healthResponse(healthzPayload(), request, socket),
|
|
293
312
|
// `?deep=1` always answers on the dependencies, whatever `health.readiness` says.
|
|
294
|
-
'/readyz': (request: Request) =>
|
|
313
|
+
'/readyz': (request: Request, socket: BunServer) =>
|
|
295
314
|
healthResponse(
|
|
296
315
|
readyzPayload({ deep: new URL(request.url).searchParams.get('deep') === '1' }),
|
|
316
|
+
request,
|
|
317
|
+
socket,
|
|
297
318
|
),
|
|
298
319
|
},
|
|
299
320
|
};
|
package/src/stages.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { signInRedirect } from './auth-redirect';
|
|
16
16
|
import { finalizeCacheHeaders } from './cache-stage';
|
|
17
17
|
import { type HttpConfig, stripBasePath } from './config';
|
|
18
|
-
import {
|
|
18
|
+
import { elapsedMs, type RequestContext } from './context';
|
|
19
19
|
import { corsHeaders, preflight } from './cors';
|
|
20
20
|
import { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
|
|
21
21
|
import { factsOf, retryAfterOf } from './error-facts';
|
|
@@ -38,8 +38,12 @@ import { compose, type Middleware } from './middleware';
|
|
|
38
38
|
import { navigationGate, redirectForRouter } from './navigation';
|
|
39
39
|
import { overlayResponse } from './overlay';
|
|
40
40
|
import { resolvePreferences } from './preferences';
|
|
41
|
-
import
|
|
42
|
-
import {
|
|
41
|
+
import type { RateLimiter } from './rate-limit';
|
|
42
|
+
import {
|
|
43
|
+
refuseExhaustedAddress,
|
|
44
|
+
spendRequestBuckets,
|
|
45
|
+
spendUnauthenticated,
|
|
46
|
+
} from './rate-limit-stage';
|
|
43
47
|
import type { UltimateRequest } from './request';
|
|
44
48
|
import { addVary, problem, redirect } from './response';
|
|
45
49
|
import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
|
|
@@ -93,6 +97,10 @@ export interface Stage extends StageDoc {
|
|
|
93
97
|
*/
|
|
94
98
|
const SHED_RETRY_AFTER_SECONDS = '1';
|
|
95
99
|
|
|
100
|
+
/** The two names a policy travels under — the enforced one and `reportOnly`'s. */
|
|
101
|
+
const isCspHeader = (name: string): boolean =>
|
|
102
|
+
name === 'content-security-policy' || name === 'content-security-policy-report-only';
|
|
103
|
+
|
|
96
104
|
/**
|
|
97
105
|
* The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
|
|
98
106
|
* would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
|
|
@@ -181,7 +189,9 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
181
189
|
if (routed instanceof Response) return routed;
|
|
182
190
|
const match = matchRoute(input.table, ctx.method, routed);
|
|
183
191
|
if (!match.ok) {
|
|
184
|
-
if (match.reason === 'not-found')
|
|
192
|
+
if (match.reason === 'not-found') {
|
|
193
|
+
throw hooks.explainMiss?.(ctx.method, pathname) ?? routeNotFound(ctx.method, pathname);
|
|
194
|
+
}
|
|
185
195
|
if (match.reason === 'path-invalid') throw pathInvalid(pathname, match.segment);
|
|
186
196
|
ctx.headers.set('allow', match.allow.join(', '));
|
|
187
197
|
throw methodNotAllowed(ctx.method, pathname, match.allow);
|
|
@@ -208,6 +218,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
208
218
|
// A route's own authenticator REPLACES the app's, never runs beside it: a bearer mount must
|
|
209
219
|
// not also accept the session cookie the app's hook would resolve (`RouteMeta.authenticate`).
|
|
210
220
|
const authenticate = ctx.route?.meta.authenticate ?? hooks.authenticate;
|
|
221
|
+
// A required route is where credentials are guessed: an address that has spent its failures
|
|
222
|
+
// is refused before the credential store is asked, a right guess included.
|
|
223
|
+
if (ctx.route?.meta.auth === 'required') {
|
|
224
|
+
await refuseExhaustedAddress(ctx, limiter, config.rateLimit);
|
|
225
|
+
}
|
|
211
226
|
if (authenticate !== undefined) {
|
|
212
227
|
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
213
228
|
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
@@ -219,53 +234,21 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
219
234
|
}
|
|
220
235
|
}
|
|
221
236
|
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
237
|
+
// This refusal leaves before `rate-limit` runs, so it is metered here or nowhere: the
|
|
238
|
+
// address's own allowance, spent only by a failure (`spendUnauthenticated`).
|
|
239
|
+
await spendUnauthenticated(ctx, limiter, config.rateLimit);
|
|
222
240
|
throw unauthenticated(ctx.url.pathname);
|
|
223
241
|
}
|
|
224
242
|
return undefined;
|
|
225
243
|
},
|
|
226
244
|
|
|
227
245
|
'rate-limit': async (_request, ctx) => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const spends = rateLimitSpends(
|
|
234
|
-
{
|
|
235
|
-
actorId: actor?.id ?? null,
|
|
236
|
-
orgId: actor?.orgId ?? null,
|
|
237
|
-
ip: ctx.ip,
|
|
238
|
-
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
239
|
-
},
|
|
240
|
-
{
|
|
241
|
-
route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
|
|
242
|
-
tenant: config.rateLimit.tenantBucket,
|
|
243
|
-
},
|
|
246
|
+
await spendRequestBuckets(
|
|
247
|
+
ctx,
|
|
248
|
+
limiter,
|
|
249
|
+
config.rateLimit,
|
|
250
|
+
ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
244
251
|
);
|
|
245
|
-
let answer: RateLimitDecision | undefined;
|
|
246
|
-
let refusedKey: string | undefined;
|
|
247
|
-
for (const spend of spends) {
|
|
248
|
-
const decision = await limiter.check(spend.key, spend.bucket);
|
|
249
|
-
// The bucket closest to refusing is the one the caller has to plan against: reporting
|
|
250
|
-
// `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
|
|
251
|
-
// tells a client it may proceed and then refuses its next call.
|
|
252
|
-
if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
|
|
253
|
-
if (!decision.allowed) {
|
|
254
|
-
// The first refusal ends the spend, so a caller its own bucket already refused costs
|
|
255
|
-
// its tenant nothing — one noisy actor may not drain the allowance it shares.
|
|
256
|
-
answer = decision;
|
|
257
|
-
refusedKey = spend.key;
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (answer === undefined) return undefined;
|
|
262
|
-
// Recorded before the throw so the 429 can carry Retry-After and the
|
|
263
|
-
// RateLimit-* headers rather than making the client guess.
|
|
264
|
-
ctx.rateLimit = answer;
|
|
265
|
-
for (const [name, value] of Object.entries(limiter.headers(answer))) {
|
|
266
|
-
ctx.headers.set(name, value);
|
|
267
|
-
}
|
|
268
|
-
if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
|
|
269
252
|
return undefined;
|
|
270
253
|
},
|
|
271
254
|
|
|
@@ -444,7 +427,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
444
427
|
if (handedOver !== undefined) ctx.response = handedOver;
|
|
445
428
|
const response = ctx.response;
|
|
446
429
|
if (response === undefined) return undefined;
|
|
447
|
-
|
|
430
|
+
// `Set-Cookie` is the one header that is a LIST of field lines: `set` in this loop kept the
|
|
431
|
+
// last cookie only, and dropped any the handler had put on its own Response.
|
|
432
|
+
for (const [name, value] of ctx.headers) {
|
|
433
|
+
if (name === 'set-cookie') response.headers.append(name, value);
|
|
434
|
+
else response.headers.set(name, value);
|
|
435
|
+
}
|
|
448
436
|
for (const [name, value] of Object.entries(
|
|
449
437
|
corsHeaders(config.cors, request.header('origin')),
|
|
450
438
|
)) {
|
|
@@ -460,7 +448,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
460
448
|
for (const [name, value] of Object.entries(
|
|
461
449
|
responseSecurityHeaders(config.security, ctx.https),
|
|
462
450
|
)) {
|
|
463
|
-
|
|
451
|
+
// A policy the handler set is KEPT and the app's is added beside it — comma-joined, two
|
|
452
|
+
// policies, both enforced — so a handler can only narrow (a `sandbox` on an uploaded file),
|
|
453
|
+
// never widen. Overwritten, that `sandbox` never reached a browser.
|
|
454
|
+
if (isCspHeader(name) && response.headers.has(name)) response.headers.append(name, value);
|
|
455
|
+
else response.headers.set(name, value);
|
|
464
456
|
}
|
|
465
457
|
response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
|
|
466
458
|
return undefined;
|