@ultimat3/http 1.2.0 → 2.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/src/config.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  // The HTTP slice of `app.config.ts`. One resolver, so a value is either a locked
2
2
  // default or an explicit override — never "whatever the first caller passed".
3
- import { type CorsConfig, DEFAULT_CORS } from './cors';
3
+ import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
4
+ import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
5
+ import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
6
+ import { trustProxyUnset } from './errors';
4
7
  import {
5
8
  DEFAULT_LOCALE_CONFIG,
6
9
  DEFAULT_TZ_CONFIG,
7
10
  type LocaleConfig,
8
11
  type TimeZoneConfig,
9
12
  } from './locale';
10
- import { DEFAULT_RATE_LIMIT, type RateLimitConfig } from './rate-limit';
13
+ import { type RateLimitConfig, resolveRateLimitConfig } from './rate-limit';
11
14
  import { DEFAULT_SECURITY, type SecurityConfig } from './security-headers';
12
15
 
13
16
  export interface HttpConfig {
@@ -19,14 +22,46 @@ export interface HttpConfig {
19
22
  readonly buildId: string | null;
20
23
  readonly buildIdHeader: string;
21
24
  readonly dev: boolean;
22
- /** Read `x-forwarded-for` / `x-forwarded-proto`. Only safe behind our own proxy. */
25
+ /**
26
+ * Where a browser that failed `auth: 'required'` is sent, or `null` to answer it with the
27
+ * problem document. `null` by default: guessing `/signin` sends an app that spells it `/login`
28
+ * to a 404, and a framework may not invent one of its app's routes.
29
+ */
30
+ readonly signInPath: string | null;
31
+ /**
32
+ * Read `x-forwarded-for` / `x-forwarded-proto`, and echo an inbound `x-request-id`. A claim
33
+ * about the DEPLOYMENT, so it is `false` until an app makes it — it used to default `true`,
34
+ * which let any direct caller choose its own request id and poison log correlation. Setting it
35
+ * requires `trustedProxyHops`.
36
+ */
23
37
  readonly trustProxy: boolean;
38
+ /**
39
+ * How many proxies APPEND to `x-forwarded-for` between the client and this process — 1 for a
40
+ * single ingress or ALB, 2 for a CDN in front of one. The header is read at
41
+ * `entries.length - hops`, never at `[0]`: the leftmost value is whatever the client typed.
42
+ * `0` when nothing is trusted.
43
+ */
44
+ readonly trustedProxyHops: number;
24
45
  readonly bodyLimitBytes: number;
46
+ /**
47
+ * How long one request may run before it is aborted and answered `X_TIMEOUT` (504). `0`
48
+ * disables it, which is a deployment saying it would rather hold a connection forever than
49
+ * cut one short. A caller may ask for LESS with `x-request-timeout-ms`, never for more.
50
+ */
51
+ readonly requestTimeoutMs: number;
52
+ /**
53
+ * Requests this process will hold at once before shedding with `X_OVERLOADED` (503) before any
54
+ * work. `0` disables it. The ceiling is not a capacity plan — it is the difference between
55
+ * degrading and collapsing, because past it every request queues behind the same pool and the
56
+ * retries multiply the load.
57
+ */
58
+ readonly maxInflight: number;
25
59
  /** How long SIGTERM waits for in-flight requests before hard-stopping. */
26
60
  readonly drainTimeoutMs: number;
27
61
  readonly locale: LocaleConfig;
28
62
  readonly tz: TimeZoneConfig;
29
63
  readonly cors: CorsConfig;
64
+ readonly csrf: CsrfConfig;
30
65
  readonly security: SecurityConfig;
31
66
  readonly rateLimit: RateLimitConfig;
32
67
  }
@@ -38,12 +73,17 @@ export interface HttpConfigInput {
38
73
  readonly buildId?: string | null;
39
74
  readonly buildIdHeader?: string;
40
75
  readonly dev?: boolean;
76
+ readonly signInPath?: string | null;
41
77
  readonly trustProxy?: boolean;
78
+ readonly trustedProxyHops?: number;
42
79
  readonly bodyLimitBytes?: number;
80
+ readonly requestTimeoutMs?: number;
81
+ readonly maxInflight?: number;
43
82
  readonly drainTimeoutMs?: number;
44
83
  readonly locale?: Partial<LocaleConfig>;
45
84
  readonly tz?: Partial<TimeZoneConfig>;
46
85
  readonly cors?: Partial<CorsConfig>;
86
+ readonly csrf?: Partial<CsrfConfig>;
47
87
  readonly security?: Partial<Omit<SecurityConfig, 'csp'>> & {
48
88
  readonly csp?: Partial<SecurityConfig['csp']>;
49
89
  };
@@ -69,7 +109,20 @@ const env = (name: string): string | undefined => {
69
109
  };
70
110
 
71
111
  export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
72
- const dev = input.dev ?? env('NODE_ENV') !== 'production';
112
+ // `ULTIMATE_ENV` is the framework's one environment key and `NODE_ENV` is only its fallback, so
113
+ // reading `NODE_ENV` alone made a deployment that declared production the documented way serve
114
+ // the dev overlay and a report-only CSP. Non-throwing and `?? DEFAULT_ENVIRONMENT`, the same
115
+ // expression `@ultimat3/policy`'s `traceByDefault` uses: a malformed `ULTIMATE_ENV` is its own
116
+ // error with its own fix and must never be raised for the first time by a config default.
117
+ const dev = input.dev ?? (tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT) !== 'production';
118
+ const cors = { ...DEFAULT_CORS, ...input.cors };
119
+ // The one resolver is the one place a resolved combination can be judged: an override is merged
120
+ // over defaults the author never restated, so `origins: ['*']` alone is what reaches this.
121
+ assertCorsConfig(cors);
122
+ const trustProxy = input.trustProxy ?? false;
123
+ // Refused here, not on the first request: "trust the header" and "know which entry of it" are
124
+ // one declaration, and half of it is a header the caller writes.
125
+ if (trustProxy && input.trustedProxyHops === undefined) throw trustProxyUnset();
73
126
  return {
74
127
  port: input.port ?? Number.parseInt(env('PORT') ?? '3000', 10),
75
128
  hostname: input.hostname ?? env('HOSTNAME') ?? '0.0.0.0',
@@ -77,17 +130,24 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
77
130
  buildId: input.buildId ?? env('BUILD_ID') ?? null,
78
131
  buildIdHeader: input.buildIdHeader ?? 'x-ultimate-build',
79
132
  dev,
80
- trustProxy: input.trustProxy ?? true,
133
+ signInPath: input.signInPath ?? null,
134
+ trustProxy,
135
+ trustedProxyHops: trustProxy ? Math.max(0, Math.floor(input.trustedProxyHops ?? 0)) : 0,
81
136
  bodyLimitBytes: input.bodyLimitBytes ?? 1_048_576,
137
+ // 30s: longer than any request a browser waits out, shorter than the 15s drain budget times
138
+ // two, so a rolling restart cannot be held open by work started just before SIGTERM.
139
+ requestTimeoutMs: input.requestTimeoutMs ?? 30_000,
140
+ maxInflight: input.maxInflight ?? 1_000,
82
141
  drainTimeoutMs: input.drainTimeoutMs ?? 15_000,
83
142
  locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale },
84
143
  tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
85
- cors: { ...DEFAULT_CORS, ...input.cors },
144
+ cors,
145
+ csrf: { ...DEFAULT_CSRF, ...input.csrf },
86
146
  security: {
87
147
  ...DEFAULT_SECURITY,
88
148
  ...input.security,
89
149
  csp: { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp },
90
150
  },
91
- rateLimit: { ...DEFAULT_RATE_LIMIT, ...input.rateLimit },
151
+ rateLimit: resolveRateLimitConfig(input.rateLimit),
92
152
  };
93
153
  };
package/src/context.ts CHANGED
@@ -4,19 +4,41 @@
4
4
  import {
5
5
  type Actor,
6
6
  anonymousActor,
7
+ type Clock,
7
8
  type Ctx,
8
9
  isAnonymous,
10
+ type Logger,
11
+ traceId as newTraceId,
9
12
  type Role,
13
+ logger as rootLogger,
14
+ type ServiceBag,
15
+ systemClock,
10
16
  useContext,
11
17
  uuid,
12
18
  } from '@ultimat3/core';
19
+ import { localeConfig } from '@ultimat3/i18n';
20
+ import { timeConfig } from '@ultimat3/time';
13
21
  import type { HttpConfig } from './config';
22
+ import { noRequest } from './errors';
14
23
  import type { AuthzDecision } from './hooks';
24
+ import { readCookie } from './locale';
25
+ import type { PeerIdentity } from './peer-identity';
15
26
  import type { RateLimitDecision } from './rate-limit';
16
- import type { CacheHint } from './response';
27
+ import type { CacheHint, RedirectIntent } from './response';
17
28
  import type { Route, RouteParams } from './router';
18
29
 
19
- export interface RequestContext {
30
+ /**
31
+ * The per-request context, and — through `asCtx` — core's `Ctx` itself. Every member `Ctx`
32
+ * declares is declared here and SET by `createRequestContext`, because `asCtx` used to be
33
+ * `as unknown as Ctx` over an object missing five of them (`clock`, `now`, `logger`, `signal`,
34
+ * `services`). The assertion type-checked and every reader threw at runtime: `ctx.now()` in
35
+ * `@ultimat3/action`'s audit trail, `useService()`, `throwIfAborted()`. The cast is gone, so
36
+ * a member core adds is a build error in this file until it is set. The `extends` is what makes
37
+ * that true rather than aspirational — and it carries `CtxServices`' index signature, which is
38
+ * what an app augments for `ctx.posts`; `noPropertyAccessFromIndexSignature` keeps `ctx.typo` a
39
+ * build error all the same.
40
+ */
41
+ export interface RequestContext extends Ctx {
20
42
  /** `performance.now()` at accept time; used for the server-timing header. */
21
43
  readonly startedAt: number;
22
44
  readonly url: URL;
@@ -25,14 +47,46 @@ export interface RequestContext {
25
47
  readonly config: HttpConfig;
26
48
  readonly ip: string | null;
27
49
  readonly https: boolean;
50
+ /**
51
+ * What the mesh's proxy asserted about the peer's certificate, or `null` — for an untrusted
52
+ * deployment, a missing header and a chain shorter than declared alike. Never an actor:
53
+ * `hooks.authenticate` is the one place an identity becomes `ctx.actor`, through
54
+ * `verifyWorkloadToken()` -> `actorFromService()` in `@ultimat3/auth`.
55
+ */
56
+ readonly peer: PeerIdentity | null;
28
57
  /** Response headers accumulated by stages before a Response exists. */
29
58
  readonly headers: Headers;
59
+ /**
60
+ * The INBOUND headers, the request's own. Headers and not the `Request`: they are the only
61
+ * part of a request that is already fully read, immutable and safe to hand out. The body is
62
+ * not — `UltimateRequest` size-caps it, parses it by content-type and caches the result, and a
63
+ * raw `Request` on the context would be a second body reader past all three. Set once at
64
+ * construction; a context built outside a request (a job, a test) carries an empty `Headers`.
65
+ */
66
+ readonly requestHeaders: Headers;
67
+
68
+ // --- core's `Ctx`, in full. Set at construction, never by a stage: `asCtx` publishes THIS
69
+ // object through core's ALS, so anything absent here is `undefined` in every handler.
70
+ /**
71
+ * The build of the APP this process serves — core's meaning of the word, and what a job and a
72
+ * request must agree on. NOT what the client claims to be running: that is `clientBuildId`,
73
+ * and the two shared this name until `asCtx` was checked, which published the caller's header
74
+ * to every `ctx.buildId` reader in the framework.
75
+ */
76
+ readonly buildId: string;
77
+ readonly clock: Clock;
78
+ now(): Date;
79
+ /** Request-scoped: a child of the root logger carrying `requestId` and `traceId`. */
80
+ readonly logger: Logger;
81
+ /** Aborted when the caller goes away or the request deadline passes. See `deadline.ts`. */
82
+ readonly signal: AbortSignal;
83
+ readonly services: ServiceBag;
30
84
 
31
85
  // Mutable slots, each filled by exactly one pipeline stage. Kept mutable (and
32
86
  // documented) rather than rebuilt per stage so a stage list stays a flat array.
33
- /** Set by the `request-id` stage; seeded so a crash before it still correlates. */
87
+ /** Resolved from the inbound headers before the context exists (`correlation.ts`). */
34
88
  requestId: string;
35
- /** Set by the `trace` stage from an inbound `traceparent`, if any. */
89
+ /** W3C trace id, continued from an inbound `traceparent` 32 hex, never a UUID. */
36
90
  traceId: string;
37
91
  parentSpanId: string | null;
38
92
  params: RouteParams;
@@ -45,11 +99,20 @@ export interface RequestContext {
45
99
  actor: Actor;
46
100
  locale: string;
47
101
  tz: string;
48
- buildId: string | null;
102
+ /** What the CLIENT says it is running, from `config.buildIdHeader`. `assertBuild()` reads it. */
103
+ clientBuildId: string | null;
49
104
  input: unknown;
50
105
  authz: AuthzDecision | undefined;
51
106
  rateLimit: RateLimitDecision | undefined;
52
107
  cache: CacheHint | undefined;
108
+ /**
109
+ * The one slot app code fills rather than a stage: `setRedirect()` records that this call
110
+ * should answer with a `Location` instead of its return value, and the route projection that
111
+ * generated the handler reads it back with `takeRedirect()`. An action has no way to return a
112
+ * `Response` — its return value is its output schema — so a side channel is the only place
113
+ * "answer 303" can live without a second return protocol.
114
+ */
115
+ redirect: RedirectIntent | undefined;
53
116
  response: Response | undefined;
54
117
  error: unknown;
55
118
  }
@@ -61,45 +124,133 @@ export interface RequestContextInit {
61
124
  readonly config: HttpConfig;
62
125
  readonly requestId?: string;
63
126
  readonly traceId?: string;
127
+ /** The caller's span id, from an inbound `traceparent`. Resolved before this call. */
128
+ readonly parentSpanId?: string | null;
64
129
  readonly ip?: string | null;
65
130
  readonly https?: boolean;
131
+ readonly peer?: PeerIdentity | null;
132
+ /** The inbound headers. Absent means "not an HTTP request" and reads as empty. */
133
+ readonly requestHeaders?: HeadersInit;
134
+ readonly clock?: Clock;
135
+ readonly logger?: Logger;
136
+ /** The deadline/disconnect signal. Absent means a request nothing can cancel. */
137
+ readonly signal?: AbortSignal;
138
+ readonly services?: ServiceBag;
66
139
  }
67
140
 
68
- export const createRequestContext = (init: RequestContextInit): RequestContext => ({
69
- requestId: init.requestId ?? uuid(),
70
- traceId: init.traceId ?? uuid(),
71
- startedAt: performance.now(),
72
- url: init.url,
73
- method: init.method.toUpperCase(),
74
- role: init.role,
75
- config: init.config,
76
- ip: init.ip ?? null,
77
- https: init.https ?? init.url.protocol === 'https:',
78
- headers: new Headers(),
79
- parentSpanId: null,
80
- params: {},
81
- route: undefined,
82
- actor: anonymousActor(),
83
- locale: init.config.locale.default,
84
- tz: init.config.tz.default,
85
- buildId: null,
86
- input: undefined,
87
- authz: undefined,
88
- rateLimit: undefined,
89
- cache: undefined,
90
- response: undefined,
91
- error: undefined,
92
- });
141
+ /**
142
+ * One signal for every context built without one, so "no cancellation here" costs no allocation
143
+ * and `ctx.signal.aborted` is still a read rather than a `TypeError`. The same shape core uses.
144
+ */
145
+ const NEVER_ABORTED: AbortSignal = new AbortController().signal;
146
+
147
+ export const createRequestContext = (init: RequestContextInit): RequestContext => {
148
+ const clock = init.clock ?? systemClock;
149
+ const requestId = init.requestId ?? uuid(clock);
150
+ // core's `traceId()`, never `uuid()`: a dashed UUIDv7 is not a 32-hex W3C trace id, and a
151
+ // collector rejects the span that carries one — while the log lines beside it, which quote the
152
+ // same field, look fine. Two ids for one request that cannot be joined.
153
+ const traceId = init.traceId ?? newTraceId();
154
+ return {
155
+ requestId,
156
+ traceId,
157
+ parentSpanId: init.parentSpanId ?? null,
158
+ startedAt: performance.now(),
159
+ url: init.url,
160
+ method: init.method.toUpperCase(),
161
+ role: init.role,
162
+ config: init.config,
163
+ ip: init.ip ?? null,
164
+ https: init.https ?? init.url.protocol === 'https:',
165
+ peer: init.peer ?? null,
166
+ headers: new Headers(),
167
+ requestHeaders: new Headers(init.requestHeaders),
168
+ // The build this PROCESS serves, resolved the way core resolves it. The client's claim goes
169
+ // to `clientBuildId` below, where only `assertBuild()` reads it.
170
+ buildId: init.config.buildId ?? 'dev',
171
+ clock,
172
+ now: () => clock.now(),
173
+ // A child, so `ctx.logger` carries the ids even where core's ALS injector cannot see the
174
+ // context — a callback that outlived the request scope, a logger passed to a driver.
175
+ logger: (init.logger ?? rootLogger).child({ requestId, traceId }),
176
+ signal: init.signal ?? NEVER_ABORTED,
177
+ // Frozen and explicit. `defineService` factories are NOT installed here: core does not
178
+ // export the installer, so the honest answer for a service nothing passed is
179
+ // `X_SERVICE_MISSING` from `useService()` — which is what it exists to raise — rather than
180
+ // the `TypeError: undefined is not an object` a missing bag produced.
181
+ services: Object.freeze({ ...(init.services ?? {}) }),
182
+ params: {},
183
+ route: undefined,
184
+ actor: anonymousActor(),
185
+ // What the request gets before the `locale` stage runs, and what it keeps if the stage is
186
+ // never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
187
+ locale: localeConfig().fallback,
188
+ tz: timeConfig().defaultZone,
189
+ clientBuildId: null,
190
+ input: undefined,
191
+ authz: undefined,
192
+ rateLimit: undefined,
193
+ cache: undefined,
194
+ redirect: undefined,
195
+ response: undefined,
196
+ error: undefined,
197
+ };
198
+ };
93
199
 
94
200
  /**
95
- * `Ctx` is owned by `@ultimat3/core` and grows service handles by module
96
- * augmentation. This is the single adapter between the HTTP request context and
97
- * core's ALS payload, so a change to `Ctx` touches one line of this package.
201
+ * The single adapter between the HTTP request context and core's ALS payload. It is a WIDENING
202
+ * the compiler checks, not an assertion: `as unknown as Ctx` here shipped a context missing
203
+ * `clock`, `now`, `logger`, `signal` and `services`, so `ctx.now()` threw on every audited
204
+ * action served over HTTP. Never reintroduce a cast — the type error IS the enforcement.
98
205
  */
99
- export const asCtx = (ctx: RequestContext): Ctx => ctx as unknown as Ctx;
206
+ export const asCtx = (ctx: RequestContext): Ctx => ctx;
207
+
208
+ /**
209
+ * The ambient `Ctx`, WIDENED to the request shape and not yet proven to be one. Private for that
210
+ * reason: every export below runs it through `assertInRequest` first. The cast is the inverse of
211
+ * `asCtx` and the only one in this file that cannot be a checked widening — core's `Ctx` genuinely
212
+ * does not carry `requestHeaders`, which is precisely what makes the proof necessary.
213
+ */
214
+ const ambientContext = (): RequestContext => useContext() as unknown as RequestContext;
215
+
216
+ /**
217
+ * The ambient context, proven to be an HTTP request's rather than a job's or a task's.
218
+ * `requestHeaders` is the discriminator because it is the one field only the pipeline sets, and
219
+ * core's `Ctx` is frozen — so without this a write to a request-only slot is a bare
220
+ * `TypeError: object is not extensible`, which is not an instruction. Not exported from the
221
+ * package: callers want a header, a cookie or a redirect, never the proof.
222
+ */
223
+ export const assertInRequest = (member: string, ctx = ambientContext()): RequestContext => {
224
+ if ((ctx.requestHeaders as Headers | undefined) === undefined) throw noRequest(member);
225
+ return ctx;
226
+ };
227
+
228
+ /**
229
+ * Read the ambient request context. Throws outside a request via core's ALS — and, since a job, a
230
+ * task, a scheduler round and a CLI command all supply an ordinary `Ctx`, throws `X_NO_REQUEST`
231
+ * there too rather than handing back an object whose non-optional fields are `undefined`. That is
232
+ * what it used to do, so the first read (`ctx.requestHeaders.get(...)`) was a bare `TypeError`
233
+ * from a public API. `member` names what the caller was after, so the refusal instructs.
234
+ */
235
+ export const useRequestContext = (member = 'the request context'): RequestContext =>
236
+ assertInRequest(member, ambientContext());
237
+
238
+ /**
239
+ * The inbound headers of the request in scope. `use*` because it reads core's ALS, like
240
+ * `useContext` and `useService` — an action handler and a page get a `Ctx`, never the
241
+ * `UltimateRequest`, so an ambient reader is the only seam that reaches them both.
242
+ *
243
+ * Throws rather than answering `null` off a job's context: "there is no request here" and
244
+ * "the caller sent no such header" are different facts, and folding them into one is how a
245
+ * job silently authenticates as nobody.
246
+ */
247
+ export const useRequestHeaders = (): Headers => assertInRequest('request headers').requestHeaders;
248
+
249
+ export const useRequestHeader = (name: string): string | null => useRequestHeaders().get(name);
100
250
 
101
- /** Read the ambient request context. Throws outside a request via core's ALS. */
102
- export const useRequestContext = (): RequestContext => useContext() as unknown as RequestContext;
251
+ /** The one way app code reads a cookie the browser sent — a session cookie included. */
252
+ export const useRequestCookie = (name: string): string | null =>
253
+ readCookie(useRequestHeaders().get('cookie'), name);
103
254
 
104
255
  export const elapsedMs = (ctx: RequestContext): number =>
105
256
  Math.round((performance.now() - ctx.startedAt) * 100) / 100;
@@ -0,0 +1,44 @@
1
+ // The two ids a caller may bring, read from the raw headers BEFORE the context or the root span
2
+ // exists. Both used to be parsed by a stage that ran one frame after `withSpan` had already
3
+ // frozen the span's context: the caller's trace was discarded, the root span carried a fresh
4
+ // UUIDv7 no collector accepts as a trace id, and the log lines quoted a third value.
5
+
6
+ import { traceId as newTraceId, parseTraceparent, type SpanContext, uuid } from '@ultimat3/core';
7
+ import type { HttpConfig } from './config';
8
+
9
+ /**
10
+ * What an inbound `x-request-id` must look like to be echoed. A caller choosing this value
11
+ * chooses a key in the log store, so it is bounded and boring on purpose — and only read at all
12
+ * when `trustProxy` says a proxy in front of us is what writes it.
13
+ */
14
+ const REQUEST_ID = /^[\w.:-]{8,128}$/;
15
+
16
+ export interface InboundCorrelation {
17
+ readonly requestId: string;
18
+ /** W3C trace id: the caller's when it sent one, otherwise a fresh 32-hex id. */
19
+ readonly traceId: string;
20
+ readonly parentSpanId: string | null;
21
+ /**
22
+ * The caller's span, to hand to `withSpan({ parent })`. `undefined` starts a new trace —
23
+ * `startSpan` must not fall back to `currentSpanContext()` for a request, because that reads
24
+ * the context's own `traceId` and produces a root span with no parent and a made-up trace.
25
+ */
26
+ readonly parent: SpanContext | undefined;
27
+ }
28
+
29
+ /**
30
+ * Read once per request, before `runWithContext`. `parseTraceparent` is core's — one regex for
31
+ * the wire format, in the package that also writes it (`traceparent()`), so an outbound header
32
+ * and an inbound one cannot drift.
33
+ */
34
+ export const readCorrelation = (headers: Headers, config: HttpConfig): InboundCorrelation => {
35
+ const inboundId = config.trustProxy ? headers.get('x-request-id') : null;
36
+ const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuid();
37
+ const parent = parseTraceparent(headers.get('traceparent'));
38
+ return {
39
+ requestId,
40
+ traceId: parent?.traceId ?? newTraceId(),
41
+ parentSpanId: parent?.spanId ?? null,
42
+ parent,
43
+ };
44
+ };
package/src/cors.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // CORS with a locked default: same-origin only. Cross-origin access is a decision
2
2
  // the app makes in app.config.ts, never something a route can quietly opt into.
3
3
 
4
+ import { corsConfigInvalid } from './errors';
5
+
4
6
  export interface CorsConfig {
5
7
  /** Exact origins. `'*'` is allowed only when `credentials` is false. */
6
8
  readonly origins: readonly string[];
@@ -20,19 +22,45 @@ export const DEFAULT_CORS: CorsConfig = {
20
22
  maxAgeSeconds: 600,
21
23
  };
22
24
 
23
- const allowedOrigin = (config: CorsConfig, origin: string | null): string | null => {
25
+ /**
26
+ * The one combination the browser refuses — `*` with credentials — refused HERE instead, at the
27
+ * one moment an author can act on it. It used to resolve to "no CORS headers at all": the natural
28
+ * "open it up" edit produced total, silent CORS failure and a console full of unexplained blocks,
29
+ * with `DEFAULT_CORS.credentials` (true) as the half nobody thinks to look at.
30
+ */
31
+ export const assertCorsConfig = (config: CorsConfig): void => {
32
+ if (config.origins.includes('*') && config.credentials) {
33
+ throw corsConfigInvalid(
34
+ "origins includes '*' while credentials is true — no browser accepts that pair",
35
+ );
36
+ }
37
+ };
38
+
39
+ /**
40
+ * The one answer to "may this origin talk to us?". Exported for `csrf.ts`, which asks the same
41
+ * question about a *request* rather than a response — a second list of allowed origins would be
42
+ * a CORS policy and a CSRF policy that quietly disagree.
43
+ */
44
+ export const allowedOrigin = (config: CorsConfig, origin: string | null): string | null => {
24
45
  if (origin === null) return null;
25
46
  if (config.origins.includes('*')) return config.credentials ? null : '*';
26
47
  return config.origins.includes(origin) ? origin : null;
27
48
  };
28
49
 
29
- /** Headers to merge into every response, preflight or not. */
50
+ /**
51
+ * Headers to merge into every response, preflight or not.
52
+ *
53
+ * A refused origin still gets `vary: origin`, which is the header that keeps the answer *out* of a
54
+ * shared cache's un-keyed slot: without it a CDN stores the un-CORS'd body under the URL alone and
55
+ * hands it to an allowed origin next, whose fetch then fails for a reason nothing in that request
56
+ * explains.
57
+ */
30
58
  export const corsHeaders = (config: CorsConfig, origin: string | null): Record<string, string> => {
31
59
  const allow = allowedOrigin(config, origin);
32
- if (allow === null) return {};
60
+ // Caches must not serve one origin's response to another — refusal included.
61
+ if (allow === null) return { vary: 'origin' };
33
62
  const headers: Record<string, string> = {
34
63
  'access-control-allow-origin': allow,
35
- // Caches must not serve one origin's response to another.
36
64
  vary: 'origin',
37
65
  };
38
66
  if (config.credentials) headers['access-control-allow-credentials'] = 'true';
@@ -52,7 +80,7 @@ export const preflight = (request: Request, config: CorsConfig): Response | unde
52
80
  if (requested === null) return undefined;
53
81
  const origin = request.headers.get('origin');
54
82
  const allow = allowedOrigin(config, origin);
55
- if (allow === null) return new Response(null, { status: 403 });
83
+ if (allow === null) return new Response(null, { status: 403, headers: { vary: 'origin' } });
56
84
  const headers = new Headers(corsHeaders(config, origin));
57
85
  headers.set('access-control-allow-methods', config.methods.join(', '));
58
86
  headers.set('access-control-allow-headers', config.allowHeaders.join(', '));
package/src/csrf.ts ADDED
@@ -0,0 +1,85 @@
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
3
+ // type, so `<form method="post">` on evil.test is SENT and EXECUTED with the session cookie
4
+ // attached — `cors.origins: []` only stops the attacker reading the reply, long after the refund
5
+ // went through. `setRedirect` exists so those form posts work without JS, which makes them a
6
+ // first-class surface here rather than a legacy one.
7
+
8
+ import type { CorsConfig } from './cors';
9
+ import { allowedOrigin } from './cors';
10
+
11
+ export type CsrfMode = 'origin' | 'off';
12
+
13
+ export interface CsrfConfig {
14
+ /**
15
+ * `'origin'` — an unsafe method from a credentialed browser must prove same-origin, through
16
+ * `sec-fetch-site` or an `Origin` the app already allows. Costs a client nothing.
17
+ * `'off'` — for an API with no cookie session at all; say so, do not discover it.
18
+ */
19
+ readonly mode: CsrfMode;
20
+ }
21
+
22
+ export const DEFAULT_CSRF: CsrfConfig = { mode: 'origin' };
23
+
24
+ /** Methods with no side effects, per RFC 9110. A CSRF check on these is a check on nothing. */
25
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
26
+
27
+ /** The complete `Sec-Fetch-Site` vocabulary. Anything else was written by a non-browser. */
28
+ const KNOWN_SITES = new Set(['same-origin', 'same-site', 'cross-site', 'none']);
29
+
30
+ export interface CsrfCheckInput {
31
+ readonly method: string;
32
+ /** The origin this app was reached on — scheme from `ctx.https`, host from the request URL. */
33
+ readonly selfOrigin: string;
34
+ readonly origin: string | null;
35
+ readonly secFetchSite: string | null;
36
+ /** A bearer token is not ambient: a cross-site page cannot make the browser attach one. */
37
+ readonly hasAuthorizationHeader: boolean;
38
+ /** Anonymous callers have no credential to ride, so nothing to forge. */
39
+ readonly anonymous: boolean;
40
+ readonly cors: CorsConfig;
41
+ readonly config: CsrfConfig;
42
+ }
43
+
44
+ export type CsrfVerdict =
45
+ | { readonly ok: true }
46
+ /** Why it was refused, in terms the caller can act on. Never echoes a header verbatim. */
47
+ | { readonly ok: false; readonly reason: string };
48
+
49
+ /**
50
+ * `sec-fetch-site` first because it is the browser's own answer and cannot be set by script;
51
+ * `Origin` second, so an app that lists a sibling origin in `cors.origins` keeps working. A
52
+ * request with neither — a non-browser client with a cookie, or a browser too old to send
53
+ * either — is refused: "we could not tell" is the case this exists for.
54
+ */
55
+ export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
56
+ if (input.config.mode === 'off') return { ok: true };
57
+ if (SAFE_METHODS.has(input.method)) return { ok: true };
58
+ if (input.anonymous) return { ok: true };
59
+ if (input.hasAuthorizationHeader) return { ok: true };
60
+
61
+ const site = input.secFetchSite;
62
+ if (site === 'same-origin' || site === 'none') return { ok: true };
63
+ if (input.origin === input.selfOrigin) return { ok: true };
64
+ if (input.origin !== null && allowedOrigin(input.cors, input.origin) !== null) {
65
+ return { ok: true };
66
+ }
67
+ // Only the four values a browser can send are quoted back. Anything else is a client that
68
+ // wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
69
+ // store and the response body — the same defect the error-map stage's log line had.
70
+ if (site !== null) {
71
+ const known = KNOWN_SITES.has(site) ? site : 'a value no browser sends';
72
+ return { ok: false, reason: `the request reported sec-fetch-site: ${known}` };
73
+ }
74
+ return {
75
+ ok: false,
76
+ reason:
77
+ input.origin === null
78
+ ? 'the request carried neither sec-fetch-site nor origin, so it cannot be shown to be same-origin'
79
+ : 'the origin it declares is not this app and is not listed in http.cors.origins',
80
+ };
81
+ };
82
+
83
+ /** The origin a browser compares against — the PUBLIC one, so a TLS-terminating proxy agrees. */
84
+ export const selfOrigin = (url: URL, https: boolean): string =>
85
+ `${https ? 'https' : 'http'}://${url.host}`;