@ultimat3/http 1.2.0 → 3.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 +301 -0
- package/README.md +99 -2
- package/package.json +6 -3
- package/src/auth-redirect.ts +81 -0
- package/src/cache-policy.ts +24 -0
- package/src/config.ts +67 -7
- package/src/context.ts +187 -36
- package/src/correlation.ts +44 -0
- package/src/cors.ts +33 -5
- package/src/csrf.ts +85 -0
- package/src/deadline.ts +79 -0
- package/src/error-map.ts +218 -16
- package/src/errors.ts +334 -5
- package/src/finalize.ts +75 -0
- package/src/forwarded.ts +94 -0
- package/src/hooks.ts +39 -4
- package/src/index.ts +59 -23
- package/src/locale.ts +34 -82
- package/src/overlay-style.ts +43 -0
- package/src/overlay.ts +59 -41
- package/src/peer-identity.ts +107 -0
- package/src/pipeline.ts +148 -301
- package/src/rate-limit-buckets.ts +86 -0
- package/src/rate-limit.ts +197 -9
- package/src/redirect.ts +29 -0
- package/src/request.ts +47 -13
- package/src/response.ts +34 -8
- package/src/router.ts +63 -13
- package/src/security-headers.ts +34 -13
- package/src/server.ts +44 -7
- package/src/stages.ts +381 -0
- package/src/type-pins.ts +48 -0
- package/src/validate.ts +13 -2
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
87
|
+
/** Resolved from the inbound headers before the context exists (`correlation.ts`). */
|
|
34
88
|
requestId: string;
|
|
35
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
102
|
-
export const
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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}`;
|