@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.
Files changed (54) hide show
  1. package/CLAUDE.md +75 -31
  2. package/README.md +89 -26
  3. package/package.json +9 -6
  4. package/src/app-config.ts +3 -1
  5. package/src/auth-redirect.ts +56 -17
  6. package/src/bearer-mount.ts +81 -26
  7. package/src/cache-policy.ts +10 -2
  8. package/src/config.ts +62 -32
  9. package/src/context.ts +11 -11
  10. package/src/correlation.ts +2 -2
  11. package/src/cors.ts +3 -2
  12. package/src/csrf.ts +17 -8
  13. package/src/deadline.ts +10 -8
  14. package/src/error-facts.ts +18 -2
  15. package/src/error-map-http.ts +94 -0
  16. package/src/error-map-tier-0.ts +62 -0
  17. package/src/error-map-tier-1.ts +73 -0
  18. package/src/error-map-tier-2.ts +120 -0
  19. package/src/error-map-tier-3.ts +100 -0
  20. package/src/error-map-tier-4.ts +116 -0
  21. package/src/error-map.ts +27 -474
  22. package/src/error-page.ts +3 -4
  23. package/src/error-titles.ts +93 -0
  24. package/src/errors.ts +46 -96
  25. package/src/forwarded.ts +36 -10
  26. package/src/health-disclosure.ts +66 -0
  27. package/src/hooks.ts +6 -5
  28. package/src/html-render.ts +3 -17
  29. package/src/index.ts +36 -27
  30. package/src/locale-prefix.ts +5 -1
  31. package/src/locale.ts +0 -27
  32. package/src/navigation.ts +26 -1
  33. package/src/overlay.ts +3 -1
  34. package/src/peer-identity.ts +10 -6
  35. package/src/pipeline.ts +32 -13
  36. package/src/preferences.ts +1 -1
  37. package/src/problem-meta.ts +7 -21
  38. package/src/rate-limit-buckets.ts +26 -2
  39. package/src/rate-limit-errors.ts +36 -10
  40. package/src/rate-limit-headers.ts +40 -0
  41. package/src/rate-limit-installed.ts +79 -0
  42. package/src/rate-limit-peek.ts +32 -0
  43. package/src/rate-limit-postgres.ts +30 -21
  44. package/src/rate-limit-stage.ts +132 -0
  45. package/src/rate-limit.ts +40 -15
  46. package/src/request-facts.ts +44 -0
  47. package/src/request.ts +12 -5
  48. package/src/response.ts +10 -2
  49. package/src/route-cache.ts +1 -1
  50. package/src/router.ts +36 -8
  51. package/src/server.ts +101 -30
  52. package/src/set-cookie.ts +60 -0
  53. package/src/stages.ts +50 -54
  54. package/src/validate.ts +2 -2
@@ -41,33 +41,48 @@ export function signInRedirect(input: {
41
41
  /**
42
42
  * The other half of the round trip: where to send someone once they HAVE signed in.
43
43
  *
44
+ * `raw` is the value a query or form parser already decoded ONCE — `props.query.next`, an action's
45
+ * `next` input, `url.searchParams.get('next')` — so it is screened as it arrived and never
46
+ * decoded again. A second decode changed the destination: `signInRedirect` carried
47
+ * `/search?q=a%26b`, the parser handed back exactly that, and decoding it again landed on
48
+ * `?q=a&b`, a different query; `?label=100%25done` threw and fell back. Normalising (below) removes
49
+ * dot segments only; it never decodes, so `%26` is still `%26`.
50
+ *
44
51
  * Everything except a same-origin path is refused and `fallback` is used instead. `?next=`
45
52
  * arrives from the URL bar, so it is attacker-controlled by definition — an unchecked value here
46
53
  * is an open redirect on a page whose entire job is to hold a session, which is the exact shape
47
54
  * phishing wants: a real domain, a real login, a hop to somewhere else.
48
55
  *
49
56
  * Refused: an absolute URL (`https://evil.test/x`), a scheme-relative one (`//evil.test`), a
50
- * backslash the browser normalises to a slash (`/\evil.test`), a value carrying a TAB, CR or LF,
51
- * and anything not starting `/`.
57
+ * backslash the browser normalises to a slash (`/\evil.test`), a value carrying any control
58
+ * character, and anything not starting `/`. A percent-encoded `%2F%2F`, `%5C` or `%09` is NOT
59
+ * refused, because it is not decoded: a browser resolves a `Location` without decoding it, so
60
+ * each is an ordinary path segment on this origin — and the final parse below proves it.
52
61
  *
53
62
  * The control characters are not cosmetic. A browser DELETES tab, CR and LF from a `Location`
54
- * before it parses one, so `/%09/evil.test` decodes to `/\t/evil.test` — which starts with a
55
- * single slash, passes a prefix check, and is then parsed as `//evil.test`. The URL parser
56
- * strips them the same way, which is why the last word here is the parse: whatever a client
57
- * would actually resolve has to still be a path on this origin.
63
+ * before it parses one, so `/\t/evil.test` — which starts with a single slash and passes a prefix
64
+ * check — is parsed as `//evil.test`. NUL, ESC and DEL are not stripped, they are refused by
65
+ * `Headers`, which is a 500 on the sign-in page. The URL parser strips the first three the same way
66
+ * a browser does, which is why the last word here is the parse: whatever a client would actually
67
+ * resolve has to still be a path on this origin.
68
+ *
69
+ * The answer is the RESOLVED path, query and fragment — dot segments removed, never the raw
70
+ * string: a value checked by its resolution and returned unresolved let `/.//evil.test` pass as
71
+ * same-origin and reach the client router as `//evil.test`. A resolved pathname starting `//` is
72
+ * refused outright.
73
+ *
74
+ * Before the parse, a code point above U+007F is percent-encoded as UTF-8. The parser hands `/日本`
75
+ * over decoded, `Headers` refuses anything above U+00FF, and a Latin-1 byte in a `Location` is read
76
+ * as UTF-8 by the browser — so the raw form is not a destination any client can follow. A lone
77
+ * surrogate has no UTF-8 encoding and falls back.
58
78
  */
59
79
  export function nextAfterSignIn(raw: string | null | undefined, fallback: string): string {
60
80
  if (raw === null || raw === undefined || raw === '') return fallback;
61
- // `?next=%` is a bare `URIError`, and this runs while the pipeline is already rendering a 401.
62
- let value: string;
63
- try {
64
- value = decodeURIComponent(raw);
65
- } catch {
66
- return fallback;
67
- }
68
- if (!value.startsWith('/')) return fallback;
69
- if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
70
- if (/[\t\r\n]/.test(value)) return fallback;
81
+ if (!raw.startsWith('/')) return fallback;
82
+ if (raw.startsWith('//') || raw.startsWith('/\\')) return fallback;
83
+ if (hasControlCharacter(raw)) return fallback;
84
+ const value = encodeNonAscii(raw);
85
+ if (value === undefined) return fallback;
71
86
  // An origin no relative path could reach, so any value that resolves off it left this origin.
72
87
  const base = 'http://x.invalid';
73
88
  let resolved: URL;
@@ -77,5 +92,29 @@ export function nextAfterSignIn(raw: string | null | undefined, fallback: string
77
92
  return fallback;
78
93
  }
79
94
  if (resolved.origin !== base) return fallback;
80
- return value;
95
+ // Dot segments can hide a `//`: `/.//evil.test` resolves on this origin with the pathname
96
+ // `//evil.test`, which is another host the moment anything re-reads it without the base.
97
+ if (resolved.pathname.startsWith('//')) return fallback;
98
+ // The CHECKED string is the returned one. Returning `value` after checking its resolution let
99
+ // the two differ, and the router re-resolved the difference off-site.
100
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
101
+ }
102
+
103
+ /** C0 controls and DEL — the bytes a browser strips from a `Location` or a header refuses. */
104
+ function hasControlCharacter(value: string): boolean {
105
+ for (let index = 0; index < value.length; index += 1) {
106
+ const code = value.charCodeAt(index);
107
+ if (code < 0x20 || code === 0x7f) return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ /** Every run outside printable ASCII as UTF-8 percent-encoding, or `undefined` for a lone surrogate. */
113
+ function encodeNonAscii(value: string): string | undefined {
114
+ try {
115
+ // Controls are already refused, so everything this class matches is above U+007E.
116
+ return value.replace(/[^ -~]+/g, (run) => encodeURI(run));
117
+ } catch {
118
+ return undefined;
119
+ }
81
120
  }
@@ -13,8 +13,12 @@ import { bearerMountInvalid, routeNotFound } from './errors';
13
13
  import type { RateLimitDecision, RateLimitStore } from './rate-limit';
14
14
  import { memoryRateLimitStore, toBucket } from './rate-limit';
15
15
  import { rateLimited } from './rate-limit-errors';
16
+ import { rateLimitHeaders } from './rate-limit-headers';
16
17
  import type { UltimateRequest } from './request';
18
+ import type { RequestFacts } from './request-facts';
19
+ import { requestFacts } from './request-facts';
17
20
  import type { Route, RouteMeta } from './router';
21
+ import { HTTP_METHODS } from './router';
18
22
 
19
23
  /**
20
24
  * What a token resolves to. Structurally `@ultimat3/mcp`'s `ResolvedToken`, so an app hands the
@@ -26,8 +30,16 @@ export interface BearerCaller {
26
30
  readonly scopes: ReadonlySet<string>;
27
31
  }
28
32
 
29
- /** `null` for a malformed, unknown, revoked or expired token — all one indistinguishable 401. */
30
- export type BearerResolver = (token: string) => Promise<BearerCaller | null> | BearerCaller | null;
33
+ /**
34
+ * `null` for a malformed, unknown, revoked or expired token — all one indistinguishable 401.
35
+ * `facts` is how the request arrived (`RequestFacts`: the pipeline's address, user agent, `Origin`,
36
+ * path) — the same shape `@ultimat3/mcp` hands its resolver, so one resolver serves both. A
37
+ * one-argument resolver is still one.
38
+ */
39
+ export type BearerResolver = (
40
+ token: string,
41
+ facts: RequestFacts,
42
+ ) => Promise<BearerCaller | null> | BearerCaller | null;
31
43
 
32
44
  export interface BearerMountRateLimit {
33
45
  readonly limit: number;
@@ -86,6 +98,9 @@ const tokenKey = (token: string): string =>
86
98
  * The mounted routes. Refuses at construction (`X_BEARER_MOUNT_INVALID`) a prefix that is not a
87
99
  * plain path or would shadow `/api` / `/_x`, a scope naming a primitive no route carries, and two
88
100
  * primitives that would land on one mounted path.
101
+ *
102
+ * The last routes returned are the prefix's catch-all (`<prefix>/*rest`, every method), so no
103
+ * path under the prefix answers differently for being served: see `opaquePrefix`.
89
104
  */
90
105
  export function bearerMount(input: BearerMountInput): readonly Route[] {
91
106
  const { prefix } = input;
@@ -134,20 +149,38 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
134
149
  // concurrent requests could share.
135
150
  const callers = new WeakMap<RequestContext, { caller: BearerCaller; key: string }>();
136
151
 
137
- const authenticate: NonNullable<RouteMeta['authenticate']> = async (request, ctx) => {
138
- const token = bearerTokenOf(request.header('authorization'));
139
- if (token === null) {
140
- ctx.headers.set('www-authenticate', 'Bearer');
141
- return null;
142
- }
143
- const caller = await input.resolveToken(token);
144
- if (caller === null) {
145
- ctx.headers.set('www-authenticate', 'Bearer error="invalid_token"');
146
- return null;
147
- }
148
- callers.set(ctx, { caller, key: tokenKey(token) });
149
- return caller.actor;
150
- };
152
+ // One authenticator per mounted route, closed over ITS scope, so the cut is decided in the `auth`
153
+ // stage — before `body` and `authz`. Decided in the handler, an out-of-scope token was answered
154
+ // 422 with the schema's issues or 403 with the policy's reason, both confirming the primitive.
155
+ // `null` is the catch-all's: a scope no token holds, so a valid token is told 404 exactly where
156
+ // an out-of-scope one is, and a missing or bad one gets the same challenge as on a served path.
157
+ const authenticatorOf =
158
+ (scope: string | null): NonNullable<RouteMeta['authenticate']> =>
159
+ async (request, ctx) => {
160
+ const token = bearerTokenOf(request.header('authorization'));
161
+ if (token === null) {
162
+ ctx.headers.set('www-authenticate', 'Bearer');
163
+ return null;
164
+ }
165
+ // `ctx.ip` is the pipeline's trusted-hop answer — the facts never read a forwarding header.
166
+ const facts = requestFacts({ headers: request.raw.headers, url: ctx.url, address: ctx.ip });
167
+ const caller = await input.resolveToken(token, facts);
168
+ if (caller === null) {
169
+ ctx.headers.set('www-authenticate', 'Bearer error="invalid_token"');
170
+ return null;
171
+ }
172
+ // Hidden, never forbidden: a 403 would confirm the primitive exists to a token that was
173
+ // not issued for it — the enumeration MCP's catalog refuses the same way.
174
+ // And never free: the refusal spends the token's allowance as a served call does (which the
175
+ // handler charges, once, so the two paths never both spend) — else a token walks the cut
176
+ // unmetered, and `RateLimit-*` on one 404 but not another tells hidden from unknown.
177
+ if (scope === null || !caller.scopes.has(scope)) {
178
+ await spend(tokenKey(token), ctx);
179
+ throw routeNotFound(ctx.method, ctx.url.pathname);
180
+ }
181
+ callers.set(ctx, { caller, key: tokenKey(token) });
182
+ return caller.actor;
183
+ };
151
184
 
152
185
  const spend = async (key: string, ctx: RequestContext): Promise<void> => {
153
186
  if (bucket === undefined) return;
@@ -157,12 +190,9 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
157
190
  1,
158
191
  clock.now().getTime(),
159
192
  );
160
- ctx.headers.set('ratelimit-limit', String(decision.limit));
161
- ctx.headers.set('ratelimit-remaining', String(decision.remaining));
162
- ctx.headers.set(
163
- 'ratelimit-reset',
164
- String(Math.max(0, Math.ceil((decision.resetAtMs - clock.now().getTime()) / 1000))),
165
- );
193
+ for (const [name, value] of Object.entries(rateLimitHeaders(decision, clock.now().getTime()))) {
194
+ ctx.headers.set(name, value);
195
+ }
166
196
  // `rateLimited` carries the seconds in `meta`, which the error-map stage turns into
167
197
  // `Retry-After` — the same 429 every other limit in the framework answers.
168
198
  if (!decision.allowed) throw rateLimited(`${prefix}|token`, decision.retryAfterSeconds);
@@ -182,11 +212,11 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
182
212
  mounted.push({
183
213
  method: route.method,
184
214
  path,
185
- meta: { ...route.meta, auth: 'required', authenticate },
215
+ meta: { ...route.meta, auth: 'required', authenticate: authenticatorOf(scope) },
186
216
  handler: async (request: UltimateRequest, ctx: RequestContext) => {
187
217
  const resolved = callers.get(ctx);
188
- // Hidden, never forbidden: a 403 would confirm the primitive exists to a token that
189
- // was not issued for it — the enumeration MCP's catalog refuses the same way.
218
+ // The authenticator already answered an out-of-scope token; this is the fail-closed
219
+ // floor for a handler driven without the `auth` stage.
190
220
  if (resolved === undefined || !resolved.caller.scopes.has(scope)) {
191
221
  throw routeNotFound(ctx.method, ctx.url.pathname);
192
222
  }
@@ -196,5 +226,30 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
196
226
  });
197
227
  }
198
228
  }
199
- return mounted;
229
+ return [...mounted, ...opaquePrefix(prefix, authenticatorOf(null))];
200
230
  }
231
+
232
+ /** Every method a client can send. HEAD is GET's (`router.ts`'s `routeFor`), never its own route. */
233
+ const CATCH_ALL_METHODS = HTTP_METHODS.filter((method) => method !== 'HEAD');
234
+
235
+ /**
236
+ * `<prefix>/*rest` for every method, behind the mount's own authenticator. Without it a request
237
+ * with no token answered 401 on a served path and 404 — or 405 naming the served methods — on
238
+ * every other, so an anonymous walk of the prefix listed the cut. 401 with the `Bearer` challenge
239
+ * is the uniform answer because it is the TRUE one for a real client anywhere under the prefix:
240
+ * nothing there is reachable without a token. A static or param route still outranks a wildcard,
241
+ * so the mounted routes, and any route the app serves under the prefix itself, keep answering.
242
+ */
243
+ const opaquePrefix = (
244
+ prefix: string,
245
+ authenticate: NonNullable<RouteMeta['authenticate']>,
246
+ ): readonly Route[] =>
247
+ CATCH_ALL_METHODS.map((method) => ({
248
+ method,
249
+ path: `${prefix}/*rest`,
250
+ meta: { name: `bearer-mount:${prefix}`, auth: 'required', authenticate },
251
+ // Reached only if the authenticator did not run: fail closed, with the miss it stands for.
252
+ handler: (_request: UltimateRequest, ctx: RequestContext) => {
253
+ throw routeNotFound(ctx.method, ctx.url.pathname);
254
+ },
255
+ }));
@@ -16,14 +16,22 @@ export const PRIVATE_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
16
16
  // for every signed-in user.
17
17
  const OFFERS_SHARED = /(?:^|,)\s*(?:public\b|s-maxage=)/i;
18
18
  const IMMUTABLE = /(?:^|,)\s*immutable\b/i;
19
+ // RFC 9111 §3: a shared cache may store a response to a request without `Authorization` — which
20
+ // is every cookie session — as soon as it carries explicit freshness or a revalidation rule. So
21
+ // `max-age=3600` alone is an offer too, unless the same header withholds it.
22
+ const STATES_FRESHNESS = /(?:^|,)\s*(?:max-age=|must-revalidate\b|proxy-revalidate\b)/i;
23
+ const WITHHOLDS = /(?:^|,)\s*(?:private\b|no-store\b)/i;
19
24
 
20
25
  /**
21
26
  * Whether a `cache-control` a HANDLER wrote offers the response to a shared cache. A render mode
22
27
  * states the MODE's intent — `ssr` offers an ungated page to a CDN for 30 seconds — and the actor
23
28
  * is the half it cannot see, so the stage reviews the declaration rather than deferring to it.
24
29
  */
25
- export const offersSharedCache = (declared: string): boolean =>
26
- OFFERS_SHARED.test(declared) && !IMMUTABLE.test(declared);
30
+ export const offersSharedCache = (declared: string): boolean => {
31
+ if (IMMUTABLE.test(declared)) return false;
32
+ if (OFFERS_SHARED.test(declared)) return true;
33
+ return STATES_FRESHNESS.test(declared) && !WITHHOLDS.test(declared);
34
+ };
27
35
 
28
36
  /**
29
37
  * Authenticated responses are never shared-cacheable; that default is not overridable.
package/src/config.ts CHANGED
@@ -3,10 +3,20 @@
3
3
  // `app.config.ts`, which this file claimed for four majors while `AppConfig` has never carried an
4
4
  // `http` key: an app declares its half through `configureHttp()` (`app-config.ts`) and the boot
5
5
  // lays its own facts over it before calling this.
6
- import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
6
+ import {
7
+ DEFAULT_ENVIRONMENT,
8
+ DEFAULT_HEALTH_DETAIL_PEERS,
9
+ tryResolveEnvironment,
10
+ } from '@ultimat3/core';
7
11
  import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
8
12
  import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
9
- import { httpCountInvalid, trustProxyUnset } from './errors';
13
+ import {
14
+ buildIdHeaderDeleted,
15
+ drainTimeoutDeleted,
16
+ httpCountInvalid,
17
+ trustProxyUnset,
18
+ } from './errors';
19
+ import { assertHealthDetailPeers } from './health-disclosure';
10
20
  import {
11
21
  DEFAULT_LOCALE_CONFIG,
12
22
  DEFAULT_TZ_CONFIG,
@@ -23,7 +33,6 @@ export interface HttpConfig {
23
33
  readonly basePath: string;
24
34
  /** Build id this process serves; `null` disables skew detection (dev). */
25
35
  readonly buildId: string | null;
26
- readonly buildIdHeader: string;
27
36
  readonly dev: boolean;
28
37
  /**
29
38
  * Where a browser that failed `auth: 'required'` is sent, or `null` to answer it with the
@@ -45,6 +54,20 @@ export interface HttpConfig {
45
54
  * `0` when nothing is trusted.
46
55
  */
47
56
  readonly trustedProxyHops: number;
57
+ /**
58
+ * Read Envoy's `x-forwarded-client-cert` into `ctx.peer`. Its OWN declaration, `false` until an
59
+ * app makes it: `trustProxy` says the proxy appends to `x-forwarded-for`, which is no promise
60
+ * that it strips or overwrites a certificate header the client sent — and this one names an
61
+ * identity. Read at `trustedProxyHops`, so it does nothing without `trustProxy`.
62
+ */
63
+ readonly trustClientCertHeader: boolean;
64
+ /**
65
+ * Who `/healthz` and `/readyz` tell more than the verdict: each entry an address class
66
+ * (`'loopback'`, `'private'`, …) or one exact address. Everyone else gets `state`, `ready` and
67
+ * `role` — the endpoints answer outside the pipeline, so the build id, the in-flight count and
68
+ * the readiness check names were otherwise any stranger's to read. `[]` tells nobody.
69
+ */
70
+ readonly healthDetailPeers: readonly string[];
48
71
  readonly bodyLimitBytes: number;
49
72
  /**
50
73
  * How long one request may run before it is aborted and answered `X_TIMEOUT` (504). `0`
@@ -59,18 +82,6 @@ export interface HttpConfig {
59
82
  * retries multiply the load.
60
83
  */
61
84
  readonly maxInflight: number;
62
- /**
63
- * How long SIGTERM waits for in-flight requests before hard-stopping, or `null` when this app
64
- * has not said and core's own deadline stands.
65
- *
66
- * `null` and not a 15s default, because the two are different claims and only one of them may
67
- * reach `configureLifecycle`. `createServer` applied the resolved number unconditionally, so an
68
- * app that had already written `configureLifecycle({ deadlineMs: 600_000 })` — the edit
69
- * `X_SHUTDOWN_TIMEOUT`'s own `fix:` line prints — had it silently reverted by the next line of
70
- * boot, in every process that serves web. Declaring this key IS declaring the drain budget for
71
- * the whole process; leaving it out is declining to.
72
- */
73
- readonly drainTimeoutMs: number | null;
74
85
  readonly locale: LocaleConfig;
75
86
  readonly tz: TimeZoneConfig;
76
87
  readonly cors: CorsConfig;
@@ -84,15 +95,15 @@ export interface HttpConfigInput {
84
95
  readonly hostname?: string;
85
96
  readonly basePath?: string;
86
97
  readonly buildId?: string | null;
87
- readonly buildIdHeader?: string;
88
98
  readonly dev?: boolean;
89
99
  readonly signInPath?: string | null;
90
100
  readonly trustProxy?: boolean;
91
101
  readonly trustedProxyHops?: number;
102
+ readonly trustClientCertHeader?: boolean;
103
+ readonly healthDetailPeers?: readonly string[];
92
104
  readonly bodyLimitBytes?: number;
93
105
  readonly requestTimeoutMs?: number;
94
106
  readonly maxInflight?: number;
95
- readonly drainTimeoutMs?: number;
96
107
  readonly locale?: Partial<LocaleConfig>;
97
108
  readonly tz?: Partial<TimeZoneConfig>;
98
109
  readonly cors?: Partial<CorsConfig>;
@@ -170,6 +181,13 @@ const assertFiniteCount = (
170
181
 
171
182
  const MAX_PORT = 65_535;
172
183
 
184
+ /**
185
+ * The longest delay a timer holds: a signed 32-bit count of milliseconds, about 24.8 days. Past
186
+ * it `setTimeout` arms ~1 ms, so a "longer" request budget timed every request out at once.
187
+ * `requestTimeoutMs` is screened against it and `deadline.ts` reads the inbound header by it.
188
+ */
189
+ export const MAX_TIMER_MS = 2_147_483_647;
190
+
173
191
  /**
174
192
  * Nobody has 64 proxies in front of one process; a bigger number is a typo, not a topology.
175
193
  *
@@ -211,7 +229,22 @@ const resolveTrustedProxyHops = (trustProxy: boolean, declared: number | undefin
211
229
  );
212
230
  };
213
231
 
232
+ /**
233
+ * Keys this config once carried and no longer reads. A typed caller is stopped by
234
+ * `HttpConfigInput` itself; this is for the one that is not — plain JS, a spread of a parsed
235
+ * object, a cast — so a deleted knob is an instruction rather than a number ignored in silence.
236
+ */
237
+ export const refuseDeletedHttpKeys = (input: object): void => {
238
+ // Core's rule for a removed key (`core/src/config-removed.ts`): `undefined` is a layer not
239
+ // saying, so a spread carrying the key unset passes on both surfaces; any other value is written.
240
+ const written = (key: string): boolean =>
241
+ Object.hasOwn(input, key) && (input as Record<string, unknown>)[key] !== undefined;
242
+ if (written('drainTimeoutMs')) throw drainTimeoutDeleted();
243
+ if (written('buildIdHeader')) throw buildIdHeaderDeleted();
244
+ };
245
+
214
246
  export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
247
+ refuseDeletedHttpKeys(input);
215
248
  // `ULTIMATE_ENV` is the framework's one environment key and `NODE_ENV` is only its fallback, so
216
249
  // reading `NODE_ENV` alone made a deployment that declared production the documented way serve
217
250
  // the dev overlay and a report-only CSP. Non-throwing and `?? DEFAULT_ENVIRONMENT`, the same
@@ -241,14 +274,21 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
241
274
  'a whole port number from 0 to 65535, where 0 asks the OS for a free one',
242
275
  'port: 3000',
243
276
  ),
244
- hostname: input.hostname ?? env('HOSTNAME') ?? '0.0.0.0',
277
+ // Never `HOSTNAME`: Docker sets it to the container id, which is not an address to bind. The
278
+ // boot passes what `HOST` says (`@ultimat3/cli`'s `hostnameFromEnv`); an embedder passes its own.
279
+ hostname: input.hostname ?? '0.0.0.0',
245
280
  basePath: input.basePath ?? '/',
246
- buildId: input.buildId ?? env('BUILD_ID') ?? null,
247
- buildIdHeader: input.buildIdHeader ?? 'x-ultimate-build',
281
+ // `undefined` falls back to the environment; an explicit `null` is the declaration that
282
+ // switches skew detection off, and `??` would have read it as unset.
283
+ buildId: input.buildId === undefined ? (env('BUILD_ID') ?? null) : input.buildId,
248
284
  dev,
249
285
  signInPath: input.signInPath ?? null,
250
286
  trustProxy,
251
287
  trustedProxyHops,
288
+ trustClientCertHeader: input.trustClientCertHeader === true,
289
+ healthDetailPeers: assertHealthDetailPeers(
290
+ input.healthDetailPeers === undefined ? DEFAULT_HEALTH_DETAIL_PEERS : input.healthDetailPeers,
291
+ ),
252
292
  bodyLimitBytes: assertFiniteCount(
253
293
  'bodyLimitBytes',
254
294
  input.bodyLimitBytes ?? 1_048_576,
@@ -261,8 +301,8 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
261
301
  requestTimeoutMs: assertFiniteCount(
262
302
  'requestTimeoutMs',
263
303
  input.requestTimeoutMs ?? 30_000,
264
- Number.MAX_SAFE_INTEGER,
265
- 'a whole number of milliseconds, where 0 means no deadline',
304
+ MAX_TIMER_MS,
305
+ `a whole number of milliseconds up to ${MAX_TIMER_MS} (the longest a timer holds), where 0 means no deadline`,
266
306
  'requestTimeoutMs: 30_000',
267
307
  ),
268
308
  maxInflight: assertFiniteCount(
@@ -272,16 +312,6 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
272
312
  'a whole number of requests, where 0 means never shed',
273
313
  'maxInflight: 1_000',
274
314
  ),
275
- drainTimeoutMs:
276
- input.drainTimeoutMs === undefined || input.drainTimeoutMs === null
277
- ? null
278
- : assertFiniteCount(
279
- 'drainTimeoutMs',
280
- input.drainTimeoutMs,
281
- Number.MAX_SAFE_INTEGER,
282
- 'a whole number of milliseconds, or null for the lifecycle default',
283
- 'drainTimeoutMs: 15_000',
284
- ),
285
315
  locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale },
286
316
  tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
287
317
  cors,
package/src/context.ts CHANGED
@@ -6,22 +6,22 @@ import {
6
6
  anonymousActor,
7
7
  type Clock,
8
8
  type Ctx,
9
- createContext,
9
+ ctxOf,
10
10
  isAnonymous,
11
11
  type Logger,
12
12
  traceId as newTraceId,
13
13
  type Role,
14
+ readCookie,
14
15
  type ServiceBag,
15
16
  systemClock,
16
17
  useContext,
17
- uuid,
18
+ uuidV7,
18
19
  } from '@ultimat3/core';
19
20
  import { localeConfig } from '@ultimat3/i18n';
20
21
  import { timeConfig } from '@ultimat3/time';
21
22
  import type { HttpConfig } from './config';
22
23
  import { noRequest } from './errors';
23
24
  import type { AuthzDecision } from './hooks';
24
- import { readCookie } from './locale';
25
25
  import type { PeerIdentity } from './peer-identity';
26
26
  import type { RateLimitDecision } from './rate-limit';
27
27
  import { bindRequestServices } from './request-services';
@@ -36,8 +36,8 @@ import type { Route, RouteParams } from './router';
36
36
  * `CtxServices` with `declare module` to declare `ctx.posts`, and every service it declared then
37
37
  * became a required member of every context literal in the framework — this file failed to
38
38
  * compile inside `examples/dummy` with `TS2739: missing posts, orgs` while the framework's own
39
- * gate, which augments nothing, stayed green. `createRequestContext` now spreads
40
- * `createContext()`'s result, so the members only an app's boot can supply arrive with it and the
39
+ * gate, which augments nothing, stayed green. `requestContext` now spreads
40
+ * `ctxOf()`'s result, so the members only an app's boot can supply arrive with it and the
41
41
  * literal below is checked in full.
42
42
  *
43
43
  * The `extends` is therefore back to doing what it was always claimed to do: a member core adds
@@ -118,7 +118,7 @@ export interface RequestContext extends Ctx {
118
118
  */
119
119
  pathLocale: string | undefined;
120
120
  tz: string;
121
- /** What the CLIENT says it is running, from `config.buildIdHeader`. `assertBuild()` reads it. */
121
+ /** What the CLIENT says it is running, from core's `BUILD_ID_HEADER`. `assertBuild()` reads it. */
122
122
  clientBuildId: string | null;
123
123
  input: unknown;
124
124
  authz: AuthzDecision | undefined;
@@ -166,15 +166,15 @@ export interface RequestContextInit {
166
166
  readonly services?: ServiceBag;
167
167
  }
168
168
 
169
- export const createRequestContext = (init: RequestContextInit): RequestContext => {
169
+ export const requestContext = (init: RequestContextInit): RequestContext => {
170
170
  const clock = init.clock ?? systemClock;
171
- const requestId = init.requestId ?? uuid(clock);
171
+ const requestId = init.requestId ?? uuidV7(clock);
172
172
  // core's `traceId()`, never `uuid()`: a dashed UUIDv7 is not a 32-hex W3C trace id, and a
173
173
  // collector rejects the span that carries one — while the log lines beside it, which quote the
174
174
  // same field, look fine. Two ids for one request that cannot be joined.
175
175
  const traceId = init.traceId ?? newTraceId();
176
176
  // COMPOSED from core's constructor rather than built beside it, and that is what deletes the
177
- // last cast in this file. `createContext` returns a `Ctx` that already carries the app's
177
+ // last cast in this file. `ctxOf` returns a `Ctx` that already carries the app's
178
178
  // `CtxServices` augmentation, so spreading it hands this literal the members only the app's boot
179
179
  // could supply — and the return below is checked in full, with nothing asserted anywhere.
180
180
  //
@@ -186,8 +186,8 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
186
186
  // `ctx.services.posts` beside it was populated. Composing makes that class of drift unwritable.
187
187
  //
188
188
  // `defineService` factories now install on this surface too, for the same reason: they are
189
- // `createContext`'s and this is `createContext`.
190
- const base = createContext({
189
+ // `ctxOf`'s and this is `ctxOf`.
190
+ const base = ctxOf({
191
191
  requestId,
192
192
  traceId,
193
193
  role: init.role,
@@ -3,7 +3,7 @@
3
3
  // frozen the span's context: the caller's trace was discarded, the root span carried a fresh
4
4
  // UUIDv7 no collector accepts as a trace id, and the log lines quoted a third value.
5
5
 
6
- import { traceId as newTraceId, parseTraceparent, type SpanContext, uuid } from '@ultimat3/core';
6
+ import { traceId as newTraceId, parseTraceparent, type SpanContext, uuidV7 } from '@ultimat3/core';
7
7
  import type { HttpConfig } from './config';
8
8
 
9
9
  /**
@@ -33,7 +33,7 @@ export interface InboundCorrelation {
33
33
  */
34
34
  export const readCorrelation = (headers: Headers, config: HttpConfig): InboundCorrelation => {
35
35
  const inboundId = config.trustProxy ? headers.get('x-request-id') : null;
36
- const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuid();
36
+ const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuidV7();
37
37
  // Deliberately NOT gated on `trustProxy`, unlike the id above, and the asymmetry is the rule:
38
38
  // `x-request-id` is ECHOED back as this response's identity, so an untrusted caller choosing it
39
39
  // poisons log correlation for everyone — while `traceparent` is W3C context continuation, which
package/src/cors.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // CORS with a locked default: same-origin only. Cross-origin access is a decision the app makes
2
2
  // once, in `configureHttp({ cors })`, never something a route can quietly opt into.
3
3
 
4
+ import { BUILD_ID_HEADER } from '@ultimat3/core';
4
5
  import { corsConfigInvalid } from './errors';
5
6
 
6
7
  export interface CorsConfig {
@@ -16,8 +17,8 @@ export interface CorsConfig {
16
17
  export const DEFAULT_CORS: CorsConfig = {
17
18
  origins: [],
18
19
  methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
19
- allowHeaders: ['content-type', 'authorization', 'x-ultimate-build', 'x-request-id'],
20
- exposeHeaders: ['x-request-id', 'x-ultimate-build', 'retry-after'],
20
+ allowHeaders: ['content-type', 'authorization', BUILD_ID_HEADER, 'x-request-id'],
21
+ exposeHeaders: ['x-request-id', BUILD_ID_HEADER, 'retry-after'],
21
22
  credentials: true,
22
23
  maxAgeSeconds: 600,
23
24
  };
package/src/csrf.ts CHANGED
@@ -1,5 +1,5 @@
1
- // Whether an unsafe request carrying an AMBIENT credential came from somewhere allowed to make
2
- // it. CORS does not answer this: `application/x-www-form-urlencoded` is a CORS-simple content
1
+ // Whether an unsafe request a BROWSER sent — with the session cookie, or to obtain one — came
2
+ // from somewhere allowed to make it. CORS does not answer this: `application/x-www-form-urlencoded` is a CORS-simple content
3
3
  // type, so `<form method="post">` on evil.test is SENT and EXECUTED with the session cookie
4
4
  // attached — `cors.origins: []` only stops the attacker reading the reply, long after the refund
5
5
  // went through. `setRedirect` exists so those form posts work without JS, which makes them a
@@ -34,7 +34,11 @@ export interface CsrfCheckInput {
34
34
  readonly secFetchSite: string | null;
35
35
  /** A bearer token is not ambient: a cross-site page cannot make the browser attach one. */
36
36
  readonly hasAuthorizationHeader: boolean;
37
- /** Anonymous callers have no credential to ride, so nothing to forge. */
37
+ /**
38
+ * No session resolved. NOT an exemption: a sign-in form is forgeable too (login CSRF — the
39
+ * visitor ends up in the attacker's account), so an anonymous write that carries browser
40
+ * evidence is judged exactly as a credentialed one. See `checkCsrf` for the one thing it buys.
41
+ */
38
42
  readonly anonymous: boolean;
39
43
  readonly cors: CorsConfig;
40
44
  readonly config: CsrfConfig;
@@ -50,12 +54,17 @@ export type CsrfVerdict =
50
54
  * `Origin` second, so an app that lists a sibling origin in `cors.origins` keeps working. A
51
55
  * request with neither — a non-browser client with a cookie, or a browser too old to send
52
56
  * either — is refused: "we could not tell" is the case this exists for.
57
+ *
58
+ * An ANONYMOUS request carrying neither is the one case let through: there is no ambient
59
+ * credential to ride and no browser evidence to judge, which is an inbound webhook or a
60
+ * server-to-server call, never a page a hostile site can drive — every browser that can submit a
61
+ * cross-site form sends `Origin` on it. Either header present, and it is judged like any other.
53
62
  */
54
63
  export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
55
64
  if (input.config.mode === 'off') return { ok: true };
56
65
  if (SAFE_METHODS.has(input.method)) return { ok: true };
57
- if (input.anonymous) return { ok: true };
58
66
  if (input.hasAuthorizationHeader) return { ok: true };
67
+ if (input.anonymous && input.origin === null && input.secFetchSite === null) return { ok: true };
59
68
 
60
69
  // The rule itself is core's, shared with the sync node's upgrade: one answer to "did this
61
70
  // come from this app?" on every surface an ambient credential reaches.
@@ -79,8 +88,8 @@ export const selfOrigin = (url: URL, https: boolean): string =>
79
88
  `${https ? 'https' : 'http'}://${url.host}`;
80
89
 
81
90
  /**
82
- * A write that arrived with the browser's ambient credential and could not be shown to come from
83
- * this app. Never a 401: the caller IS signed in, which is precisely the problem.
91
+ * A write a browser sent — with its ambient credential, or anonymously to obtain one — that could
92
+ * not be shown to come from this app. Never a 401: who is calling was never the question.
84
93
  *
85
94
  * From a LOOPBACK `ip` the caller is `curl` against `x dev`: it sends neither
86
95
  * header, and the remedies below name a token or a config change dev does not need. The header a
@@ -95,11 +104,11 @@ export const csrfBlocked = (
95
104
  ip !== null && classifyAddress(ip) === 'loopback'
96
105
  ? new HttpError({
97
106
  code: 'X_CSRF_BLOCKED',
98
- cause: `${pathname} refused a credentialed write: ${reason}`,
107
+ cause: `${pathname} refused a write it could not show came from this app: ${reason}`,
99
108
  fix: "curl -H 'sec-fetch-site: same-origin' -X POST http://localhost:3000/the-path-above # a local client proves same-origin the way a browser does; the session cookie still decides who is calling",
100
109
  })
101
110
  : new HttpError({
102
111
  code: 'X_CSRF_BLOCKED',
103
- cause: `${pathname} refused a credentialed write: ${reason}`,
112
+ cause: `${pathname} refused a write it could not show came from this app: ${reason}`,
104
113
  fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
105
114
  });