@ultimat3/http 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +75 -31
- package/README.md +89 -26
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +62 -32
- package/src/context.ts +11 -11
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +10 -8
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +62 -0
- package/src/error-map-tier-1.ts +73 -0
- package/src/error-map-tier-2.ts +120 -0
- package/src/error-map-tier-3.ts +100 -0
- package/src/error-map-tier-4.ts +116 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +3 -4
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +46 -96
- package/src/forwarded.ts +36 -10
- package/src/health-disclosure.ts +66 -0
- package/src/hooks.ts +6 -5
- package/src/html-render.ts +3 -17
- package/src/index.ts +36 -27
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +26 -1
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +32 -13
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +30 -21
- package/src/rate-limit-stage.ts +132 -0
- package/src/rate-limit.ts +40 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +12 -5
- package/src/response.ts +10 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +36 -8
- package/src/server.ts +101 -30
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +50 -54
- package/src/validate.ts +2 -2
package/src/auth-redirect.ts
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/bearer-mount.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
189
|
-
//
|
|
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
|
+
}));
|
package/src/cache-policy.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
40
|
-
* `
|
|
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 `
|
|
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
|
|
169
|
+
export const requestContext = (init: RequestContextInit): RequestContext => {
|
|
170
170
|
const clock = init.clock ?? systemClock;
|
|
171
|
-
const requestId = init.requestId ??
|
|
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. `
|
|
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
|
-
// `
|
|
190
|
-
const base =
|
|
189
|
+
// `ctxOf`'s and this is `ctxOf`.
|
|
190
|
+
const base = ctxOf({
|
|
191
191
|
requestId,
|
|
192
192
|
traceId,
|
|
193
193
|
role: init.role,
|
package/src/correlation.ts
CHANGED
|
@@ -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,
|
|
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 :
|
|
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',
|
|
20
|
-
exposeHeaders: ['x-request-id',
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
83
|
-
* this app. Never a 401:
|
|
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
|
|
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
|
|
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
|
});
|