@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/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The public surface of @ultimat3/http. Explicit, never `export *`: what is not
|
|
2
2
|
// listed here is an implementation detail and may change without a major bump.
|
|
3
3
|
|
|
4
|
+
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
4
5
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
5
6
|
export { defineHttpConfig, stripBasePath } from './config';
|
|
6
7
|
export type { ActorView, RequestContext, RequestContextInit } from './context';
|
|
@@ -10,15 +11,26 @@ export {
|
|
|
10
11
|
createRequestContext,
|
|
11
12
|
elapsedMs,
|
|
12
13
|
useRequestContext,
|
|
14
|
+
useRequestCookie,
|
|
15
|
+
useRequestHeader,
|
|
16
|
+
useRequestHeaders,
|
|
13
17
|
} from './context';
|
|
18
|
+
export type { InboundCorrelation } from './correlation';
|
|
19
|
+
export { readCorrelation } from './correlation';
|
|
14
20
|
export type { CorsConfig } from './cors';
|
|
15
|
-
export { corsHeaders, DEFAULT_CORS, preflight } from './cors';
|
|
21
|
+
export { allowedOrigin, corsHeaders, DEFAULT_CORS, preflight } from './cors';
|
|
22
|
+
export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
23
|
+
export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
|
|
24
|
+
export type { Deadline } from './deadline';
|
|
25
|
+
export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
|
|
16
26
|
export type { ErrorFacts, ProblemDocument } from './error-map';
|
|
17
27
|
export {
|
|
18
28
|
DEFAULT_STATUS,
|
|
19
29
|
ERROR_STATUS,
|
|
20
30
|
factsOf,
|
|
31
|
+
registerErrorStatus,
|
|
21
32
|
renderErrorLines,
|
|
33
|
+
resetErrorStatus,
|
|
22
34
|
statusFor,
|
|
23
35
|
toProblem,
|
|
24
36
|
} from './error-map';
|
|
@@ -26,60 +38,81 @@ export type { HttpErrorCode } from './errors';
|
|
|
26
38
|
export {
|
|
27
39
|
bodyInvalid,
|
|
28
40
|
buildSkew,
|
|
41
|
+
csrfBlocked,
|
|
42
|
+
draining,
|
|
43
|
+
errorStatusInvalid,
|
|
44
|
+
finalizeFailed,
|
|
29
45
|
forbidden,
|
|
30
46
|
HTTP_ERROR_CODES,
|
|
31
47
|
HTTP_ERROR_TITLES,
|
|
32
48
|
HttpError,
|
|
33
49
|
methodNotAllowed,
|
|
50
|
+
noRequest,
|
|
51
|
+
overloaded,
|
|
52
|
+
pathInvalid,
|
|
34
53
|
pipelineNoResponse,
|
|
54
|
+
rateLimitBucketConflict,
|
|
55
|
+
rateLimitBucketUnbound,
|
|
35
56
|
rateLimited,
|
|
57
|
+
rateLimitInvalid,
|
|
58
|
+
rateLimitNotShared,
|
|
59
|
+
rateLimitScopeUnset,
|
|
60
|
+
requestTimedOut,
|
|
36
61
|
routeConflict,
|
|
37
62
|
routeNotFound,
|
|
38
63
|
serverNotStarted,
|
|
64
|
+
trustProxyUnset,
|
|
39
65
|
unauthenticated,
|
|
40
66
|
} from './errors';
|
|
41
|
-
export type {
|
|
42
|
-
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
67
|
+
export type { ForwardedInput, ForwardedSplit } from './forwarded';
|
|
43
68
|
export {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
clientAddress,
|
|
70
|
+
clientUsedHttps,
|
|
71
|
+
FORWARDED_CLIENT_CERT,
|
|
72
|
+
FORWARDED_FOR,
|
|
73
|
+
FORWARDED_PROTO,
|
|
74
|
+
forwardedElement,
|
|
75
|
+
forwardedValue,
|
|
76
|
+
} from './forwarded';
|
|
77
|
+
export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
|
|
78
|
+
export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
|
|
79
|
+
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
80
|
+
export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG, readCookie } from './locale';
|
|
51
81
|
export type { Middleware } from './middleware';
|
|
52
82
|
export { compose } from './middleware';
|
|
53
|
-
export type { OverlayMeta } from './overlay';
|
|
83
|
+
export type { OverlayMeta, OverlayNotice } from './overlay';
|
|
54
84
|
export { overlayResponse, renderOverlay, wantsOverlay } from './overlay';
|
|
55
|
-
export
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Stage,
|
|
60
|
-
StageDoc,
|
|
61
|
-
StageName,
|
|
62
|
-
StagePhase,
|
|
63
|
-
StageRun,
|
|
64
|
-
} from './pipeline';
|
|
85
|
+
export { OVERLAY_STYLE } from './overlay-style';
|
|
86
|
+
export type { PeerIdentity } from './peer-identity';
|
|
87
|
+
export { peerIdentity } from './peer-identity';
|
|
88
|
+
export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
|
|
65
89
|
export { createPipeline, PIPELINE_STAGES } from './pipeline';
|
|
66
90
|
export type {
|
|
67
91
|
Bucket,
|
|
92
|
+
MemoryRateLimitStore,
|
|
68
93
|
RateLimitConfig,
|
|
69
94
|
RateLimitDecision,
|
|
95
|
+
RateLimitDeclaration,
|
|
70
96
|
RateLimiter,
|
|
71
97
|
RateLimitKeyParts,
|
|
98
|
+
RateLimitScope,
|
|
72
99
|
RateLimitStore,
|
|
73
100
|
} from './rate-limit';
|
|
74
101
|
export {
|
|
102
|
+
assertRateLimitScope,
|
|
75
103
|
createRateLimiter,
|
|
104
|
+
DEFAULT_MAX_RATE_LIMIT_KEYS,
|
|
76
105
|
DEFAULT_RATE_LIMIT,
|
|
77
106
|
memoryRateLimitStore,
|
|
78
107
|
rateLimitKey,
|
|
108
|
+
resolveRateLimitConfig,
|
|
109
|
+
toBucket,
|
|
79
110
|
} from './rate-limit';
|
|
111
|
+
export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
112
|
+
export { setRedirect, takeRedirect } from './redirect';
|
|
80
113
|
export type { QueryValues } from './request';
|
|
81
114
|
export { UltimateRequest } from './request';
|
|
82
|
-
export type { CacheHint } from './response';
|
|
115
|
+
export type { CacheHint, RedirectIntent, RedirectStatus } from './response';
|
|
83
116
|
export {
|
|
84
117
|
applyCacheHeaders,
|
|
85
118
|
cacheControl,
|
|
@@ -112,8 +145,11 @@ export {
|
|
|
112
145
|
normalizePath,
|
|
113
146
|
} from './router';
|
|
114
147
|
export type { SecurityConfig } from './security-headers';
|
|
115
|
-
export { buildCsp, DEFAULT_SECURITY, securityHeaders } from './security-headers';
|
|
148
|
+
export { buildCsp, cspHashSource, DEFAULT_SECURITY, securityHeaders } from './security-headers';
|
|
116
149
|
export type { LifecycleState, ServerHandle, ServerOptions } from './server';
|
|
117
150
|
export { createServer } from './server';
|
|
151
|
+
// The stage vocabulary comes from its declaration site, beside the fourteen implementations it
|
|
152
|
+
// names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
|
|
153
|
+
export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
|
|
118
154
|
export type { InferOutput, Schema, ValidationOutcome } from './validate';
|
|
119
155
|
export { formatIssue, validate, validateSync } from './validate';
|
package/src/locale.ts
CHANGED
|
@@ -1,31 +1,52 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// WHERE the request's locale and zone are read from — the header and cookie NAMES, and nothing
|
|
2
|
+
// else. What a locale or a zone IS belongs to `@ultimat3/i18n` and `@ultimat3/time` (tier 1, a
|
|
3
|
+
// legal downward import), which is why this file negotiates nothing: three re-implementations
|
|
4
|
+
// lived here and all three disagreed with their owner about the same request.
|
|
5
|
+
|
|
6
|
+
import { LOCALE_COOKIE } from '@ultimat3/i18n';
|
|
7
|
+
import { TIMEZONE_HEADER } from '@ultimat3/time';
|
|
4
8
|
|
|
5
9
|
export interface LocaleConfig {
|
|
6
|
-
readonly supported: readonly string[];
|
|
7
|
-
readonly default: string;
|
|
8
10
|
/** Cookie the client sets when the user picks a locale explicitly. */
|
|
9
11
|
readonly cookie: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface TimeZoneConfig {
|
|
13
|
-
readonly default: string;
|
|
14
15
|
/** Header the client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
|
|
15
16
|
readonly header: string;
|
|
16
17
|
readonly cookie: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The supported set and the fallback are NOT here: `defineCatalogs()` already declares them, and
|
|
22
|
+
* a second copy in this config meant an app shipping `{ en, fr }` still resolved `ctx.locale` to
|
|
23
|
+
* `'en'` forever. The cookie name is `@ultimat3/i18n`'s own constant for the same reason — a
|
|
24
|
+
* language switcher written against the documented `LOCALE_COOKIE` was read by nothing.
|
|
25
|
+
*/
|
|
19
26
|
export const DEFAULT_LOCALE_CONFIG: LocaleConfig = {
|
|
20
|
-
|
|
21
|
-
default: 'en',
|
|
22
|
-
cookie: 'x-locale',
|
|
27
|
+
cookie: LOCALE_COOKIE,
|
|
23
28
|
};
|
|
24
29
|
|
|
30
|
+
/** The default zone is `configureTime({ defaultZone })`'s, so a process has one answer, not two. */
|
|
25
31
|
export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
header: TIMEZONE_HEADER,
|
|
33
|
+
cookie: 'x_timezone',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The raw value on a malformed escape, never a throw. `Cookie:` is attacker-controlled and
|
|
38
|
+
* `decodeURIComponent('%')` is a bare `URIError` — thrown from the `locale` stage, which runs on
|
|
39
|
+
* every request, so `curl -H 'Cookie: x-locale=%'` answered 500 and paged the on-call. Nothing is
|
|
40
|
+
* loosened: a locale that is not in `supported` still falls back, and a time zone `Intl` cannot
|
|
41
|
+
* format is still refused. `@ultimat3/auth` guards its own cookie reader the same way and for the
|
|
42
|
+
* same reason; the guard is four lines and this package can never import that one (same tier).
|
|
43
|
+
*/
|
|
44
|
+
const decodeCookieValue = (raw: string): string => {
|
|
45
|
+
try {
|
|
46
|
+
return decodeURIComponent(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
29
50
|
};
|
|
30
51
|
|
|
31
52
|
export const readCookie = (header: string | null, name: string): string | null => {
|
|
@@ -34,76 +55,7 @@ export const readCookie = (header: string | null, name: string): string | null =
|
|
|
34
55
|
const index = part.indexOf('=');
|
|
35
56
|
if (index === -1) continue;
|
|
36
57
|
if (part.slice(0, index).trim() !== name) continue;
|
|
37
|
-
return
|
|
58
|
+
return decodeCookieValue(part.slice(index + 1).trim());
|
|
38
59
|
}
|
|
39
60
|
return null;
|
|
40
61
|
};
|
|
41
|
-
|
|
42
|
-
interface Weighted {
|
|
43
|
-
readonly tag: string;
|
|
44
|
-
readonly q: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const parseAcceptLanguage = (header: string): readonly Weighted[] =>
|
|
48
|
-
header
|
|
49
|
-
.split(',')
|
|
50
|
-
.map((entry): Weighted => {
|
|
51
|
-
const [tag = '', ...params] = entry.trim().split(';');
|
|
52
|
-
const qParam = params.find((p) => p.trim().startsWith('q='));
|
|
53
|
-
const q = qParam === undefined ? 1 : Number.parseFloat(qParam.trim().slice(2));
|
|
54
|
-
return { tag: tag.trim().toLowerCase(), q: Number.isFinite(q) ? q : 0 };
|
|
55
|
-
})
|
|
56
|
-
.filter((entry) => entry.tag.length > 0 && entry.q > 0)
|
|
57
|
-
.sort((a, b) => b.q - a.q);
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Exact match wins, then primary-subtag match (`de-CH` -> `de`), then the config
|
|
61
|
-
* default. `*` is treated as "no preference" rather than "any locale" so the
|
|
62
|
-
* default stays predictable for SEO and cache keys.
|
|
63
|
-
*/
|
|
64
|
-
export const negotiateLocale = (
|
|
65
|
-
acceptLanguage: string | null,
|
|
66
|
-
config: LocaleConfig = DEFAULT_LOCALE_CONFIG,
|
|
67
|
-
explicit?: string | null,
|
|
68
|
-
): string => {
|
|
69
|
-
const supported = config.supported.map((locale) => locale.toLowerCase());
|
|
70
|
-
const pick = (candidate: string): string | undefined => {
|
|
71
|
-
const wanted = candidate.toLowerCase();
|
|
72
|
-
const exact = supported.indexOf(wanted);
|
|
73
|
-
if (exact !== -1) return config.supported[exact];
|
|
74
|
-
const primary = wanted.split('-')[0] ?? wanted;
|
|
75
|
-
const loose = supported.findIndex((locale) => (locale.split('-')[0] ?? locale) === primary);
|
|
76
|
-
return loose === -1 ? undefined : config.supported[loose];
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
if (explicit !== undefined && explicit !== null) {
|
|
80
|
-
const chosen = pick(explicit);
|
|
81
|
-
if (chosen !== undefined) return chosen;
|
|
82
|
-
}
|
|
83
|
-
if (acceptLanguage !== null) {
|
|
84
|
-
for (const entry of parseAcceptLanguage(acceptLanguage)) {
|
|
85
|
-
if (entry.tag === '*') break;
|
|
86
|
-
const chosen = pick(entry.tag);
|
|
87
|
-
if (chosen !== undefined) return chosen;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return config.default;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/** A time zone is only accepted if `Intl` can actually format with it. */
|
|
94
|
-
export const isValidTimeZone = (candidate: string): boolean => {
|
|
95
|
-
try {
|
|
96
|
-
new Intl.DateTimeFormat('en', { timeZone: candidate }).format(0);
|
|
97
|
-
return true;
|
|
98
|
-
} catch {
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
export const resolveTimeZone = (
|
|
104
|
-
candidate: string | null,
|
|
105
|
-
config: TimeZoneConfig = DEFAULT_TZ_CONFIG,
|
|
106
|
-
): string => {
|
|
107
|
-
if (candidate !== null && candidate.length > 0 && isValidTimeZone(candidate)) return candidate;
|
|
108
|
-
return config.default;
|
|
109
|
-
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The dev overlay's stylesheet, split from the document that carries it so `security-headers.ts`
|
|
2
|
+
// can hash it into the CSP without importing the renderer — and so the hash is computed from the
|
|
3
|
+
// one copy of the text, never from a constant that drifted away from what the `<style>` holds.
|
|
4
|
+
|
|
5
|
+
// Token definitions live here and nowhere else; every rule below uses var().
|
|
6
|
+
export const OVERLAY_STYLE = `
|
|
7
|
+
:root {
|
|
8
|
+
--x-bg: #fbfbfd; --x-surface: #ffffff; --x-border: #e3e3ea;
|
|
9
|
+
--x-text: #1b1b1f; --x-muted: #6b6b76; --x-danger: #b3261e; --x-accent: #2f4fd8;
|
|
10
|
+
--x-code-bg: #f3f3f7;
|
|
11
|
+
}
|
|
12
|
+
@media (prefers-color-scheme: dark) {
|
|
13
|
+
:root {
|
|
14
|
+
--x-bg: #111115; --x-surface: #1a1a20; --x-border: #2e2e38;
|
|
15
|
+
--x-text: #ececf1; --x-muted: #9b9baa; --x-danger: #ff8a80; --x-accent: #9db2ff;
|
|
16
|
+
--x-code-bg: #22222b;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body {
|
|
21
|
+
margin: 0; padding: 2rem; background: var(--x-bg); color: var(--x-text);
|
|
22
|
+
font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
23
|
+
}
|
|
24
|
+
main { max-width: 60rem; margin: 0 auto; }
|
|
25
|
+
.card {
|
|
26
|
+
background: var(--x-surface); border: 1px solid var(--x-border);
|
|
27
|
+
border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
|
|
28
|
+
}
|
|
29
|
+
h1 { font-size: 1.1rem; margin: 0 0 .25rem; color: var(--x-danger); }
|
|
30
|
+
h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--x-muted);
|
|
31
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
32
|
+
dl { display: grid; grid-template-columns: 5rem 1fr; gap: .35rem 1rem; margin: 0; }
|
|
33
|
+
dt { color: var(--x-muted); }
|
|
34
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
35
|
+
pre { background: var(--x-code-bg); border-radius: 6px; padding: .75rem;
|
|
36
|
+
margin: 0; overflow-x: auto; }
|
|
37
|
+
a { color: var(--x-accent); }
|
|
38
|
+
.title { color: var(--x-muted); font-weight: normal; }
|
|
39
|
+
/* A notice's term is an X_ code, not a five-character label: at the shared 5rem the codes this
|
|
40
|
+
card exists to show would sit on top of their own causes. */
|
|
41
|
+
.notices dl { grid-template-columns: 14rem 1fr; }
|
|
42
|
+
.notices dt { overflow-wrap: anywhere; }
|
|
43
|
+
`;
|
package/src/overlay.ts
CHANGED
|
@@ -1,59 +1,77 @@
|
|
|
1
1
|
// The dev error overlay. It renders the SAME facts object the terminal prints and
|
|
2
2
|
// `--json` emits, so a code/cause/fix string can never differ between the three
|
|
3
|
-
// surfaces. Labels here ("cause", "fix") are protocol strings from the
|
|
4
|
-
// contract, not UI copy, so they are not routed through the i18n catalog.
|
|
3
|
+
// surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
|
|
4
|
+
// error contract, not UI copy, so they are not routed through the i18n catalog.
|
|
5
5
|
import { factsOf, renderErrorLines, toProblem } from './error-map';
|
|
6
|
+
import { OVERLAY_STYLE } from './overlay-style';
|
|
6
7
|
import { html } from './response';
|
|
7
8
|
|
|
9
|
+
// `'` is escaped even though every attribute below is double-quoted: the escape set is what the
|
|
10
|
+
// next author reads as the guarantee, and a single-quoted attribute written later would inherit a
|
|
11
|
+
// hole nothing here would have flagged.
|
|
8
12
|
const escapeHtml = (value: string): string =>
|
|
9
13
|
value
|
|
10
14
|
.replaceAll('&', '&')
|
|
11
15
|
.replaceAll('<', '<')
|
|
12
16
|
.replaceAll('>', '>')
|
|
13
|
-
.replaceAll('"', '"')
|
|
17
|
+
.replaceAll('"', '"')
|
|
18
|
+
.replaceAll("'", ''');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An `href` is a SCHEME decision, not an escaping one: `javascript:alert(1)` survives every entity
|
|
22
|
+
* replacement intact and becomes a link the agent debugging this page clicks. A `docs` value comes
|
|
23
|
+
* from whichever package raised the error, so anything that is not plainly http(s) renders as text.
|
|
24
|
+
*/
|
|
25
|
+
const docsLink = (value: string): string => {
|
|
26
|
+
const protocol = URL.parse(value)?.protocol;
|
|
27
|
+
const safe = protocol === 'https:' || protocol === 'http:';
|
|
28
|
+
return safe ? `<a href="${escapeHtml(value)}">${escapeHtml(value)}</a>` : escapeHtml(value);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A non-fatal finding rendered next to the error: the same code/cause/fix contract, from a
|
|
33
|
+
* diagnostic that did not stop the request. Declared structurally — the packages that produce
|
|
34
|
+
* one (`@ultimat3/entity`'s N+1 codes, reported by `x dev`) are this tier or above and can
|
|
35
|
+
* never be imported here, exactly as `AuthzDecision` is declared and never imported.
|
|
36
|
+
*/
|
|
37
|
+
export interface OverlayNotice {
|
|
38
|
+
readonly code: string;
|
|
39
|
+
readonly cause: string;
|
|
40
|
+
readonly fix: string;
|
|
41
|
+
readonly docs?: string;
|
|
42
|
+
}
|
|
14
43
|
|
|
15
44
|
export interface OverlayMeta {
|
|
16
45
|
readonly requestId?: string;
|
|
17
46
|
readonly method?: string;
|
|
18
47
|
readonly path?: string;
|
|
19
48
|
readonly buildId?: string | null;
|
|
49
|
+
readonly notices?: readonly OverlayNotice[];
|
|
20
50
|
}
|
|
21
51
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
h1 { font-size: 1.1rem; margin: 0 0 .25rem; color: var(--x-danger); }
|
|
47
|
-
h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--x-muted);
|
|
48
|
-
text-transform: uppercase; letter-spacing: .08em; }
|
|
49
|
-
dl { display: grid; grid-template-columns: 5rem 1fr; gap: .35rem 1rem; margin: 0; }
|
|
50
|
-
dt { color: var(--x-muted); }
|
|
51
|
-
dd { margin: 0; overflow-wrap: anywhere; }
|
|
52
|
-
pre { background: var(--x-code-bg); border-radius: 6px; padding: .75rem;
|
|
53
|
-
margin: 0; overflow-x: auto; }
|
|
54
|
-
a { color: var(--x-accent); }
|
|
55
|
-
.title { color: var(--x-muted); font-weight: normal; }
|
|
56
|
-
`;
|
|
52
|
+
// One `<dd>` per finding, holding the cause and the runnable fix — the same two lines the
|
|
53
|
+
// terminal prints under a code, so a notice cannot say something the CLI would not.
|
|
54
|
+
const noticeRow = (notice: OverlayNotice): string =>
|
|
55
|
+
`<dt>${escapeHtml(notice.code)}</dt><dd>${escapeHtml(notice.cause)}<br><code>${escapeHtml(
|
|
56
|
+
notice.fix,
|
|
57
|
+
)}</code>${notice.docs === undefined ? '' : `<br>${docsLink(notice.docs)}`}</dd>`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Nothing at all when there is nothing to report — not an empty card. A request with no findings
|
|
61
|
+
* must render the bytes this file rendered before notices existed, or every host that never
|
|
62
|
+
* supplies one still pays for the feature with a card that says nothing. The trailing indent is
|
|
63
|
+
* part of that: it leaves the card after this one exactly where it already sat.
|
|
64
|
+
*/
|
|
65
|
+
const noticesCard = (notices: readonly OverlayNotice[]): string =>
|
|
66
|
+
notices.length === 0
|
|
67
|
+
? ''
|
|
68
|
+
: `<section class="card notices">
|
|
69
|
+
<h2>notices</h2>
|
|
70
|
+
<dl>
|
|
71
|
+
${notices.map(noticeRow).join('\n ')}
|
|
72
|
+
</dl>
|
|
73
|
+
</section>
|
|
74
|
+
`;
|
|
57
75
|
|
|
58
76
|
export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string => {
|
|
59
77
|
const facts = factsOf(error);
|
|
@@ -62,14 +80,14 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
|
|
|
62
80
|
...(meta.path === undefined ? {} : { instance: meta.path }),
|
|
63
81
|
});
|
|
64
82
|
const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim();
|
|
65
|
-
return `<style>${
|
|
83
|
+
return `<style>${OVERLAY_STYLE}</style>
|
|
66
84
|
<main>
|
|
67
85
|
<section class="card">
|
|
68
86
|
<h1>${escapeHtml(facts.code)} <span class="title">${escapeHtml(facts.title)}</span></h1>
|
|
69
87
|
<dl>
|
|
70
88
|
<dt>cause</dt><dd>${escapeHtml(facts.cause)}</dd>
|
|
71
89
|
<dt>fix</dt><dd><code>${escapeHtml(facts.fix)}</code></dd>
|
|
72
|
-
<dt>docs</dt><dd
|
|
90
|
+
<dt>docs</dt><dd>${docsLink(facts.docs)}</dd>
|
|
73
91
|
${where === '' ? '' : `<dt>route</dt><dd>${escapeHtml(where)}</dd>`}
|
|
74
92
|
${meta.requestId === undefined ? '' : `<dt>request</dt><dd>${escapeHtml(meta.requestId)}</dd>`}
|
|
75
93
|
${
|
|
@@ -79,7 +97,7 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
|
|
|
79
97
|
}
|
|
80
98
|
</dl>
|
|
81
99
|
</section>
|
|
82
|
-
<section class="card">
|
|
100
|
+
${noticesCard(meta.notices ?? [])}<section class="card">
|
|
83
101
|
<h2>terminal</h2>
|
|
84
102
|
<pre>${escapeHtml(renderErrorLines(error))}</pre>
|
|
85
103
|
</section>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Who the MESH says is calling. TLS termination is the mesh's job (axiom 7) so the framework's
|
|
2
|
+
// entire contribution is reading a header it has been TOLD to trust: Envoy's
|
|
3
|
+
// `x-forwarded-client-cert`, at the same hop index `x-forwarded-for` is read at. Untrusted, this
|
|
4
|
+
// header is worse than nothing — it authenticates — so an untrusted read answers `null` and
|
|
5
|
+
// there is no second proxy-trust path to get it wrong in.
|
|
6
|
+
|
|
7
|
+
import { FORWARDED_CLIENT_CERT, type ForwardedInput, forwardedElement } from './forwarded';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The peer's certificate facts, declared structurally rather than imported: `@ultimat3/auth` owns
|
|
11
|
+
* `ServiceIdentity` and is this tier, so it can never be imported here — the same reason
|
|
12
|
+
* `AuthzDecision` and `OverlayNotice` are declared in this package. An app maps this onto a
|
|
13
|
+
* `ServiceIdentity` inside its `configureAuthenticator()`, which is the ONE funnel:
|
|
14
|
+
* `verifyWorkloadToken(...)` -> `actorFromService(identity)` -> `ctx.actor`. Never a second
|
|
15
|
+
* authz system that reads a certificate and decides on its own.
|
|
16
|
+
*/
|
|
17
|
+
export interface PeerIdentity {
|
|
18
|
+
/**
|
|
19
|
+
* The SPIFFE ID if the peer presented one, else the subject DN, else the first URI SAN. What an
|
|
20
|
+
* app keys a service on — and always a value a trusted proxy wrote, never the caller.
|
|
21
|
+
*/
|
|
22
|
+
readonly id: string;
|
|
23
|
+
/** `spiffe://…` from the URI SANs, when there is one. */
|
|
24
|
+
readonly spiffeId: string | null;
|
|
25
|
+
/** The certificate subject DN, e.g. `CN=checkout,OU=payments`. */
|
|
26
|
+
readonly subject: string | null;
|
|
27
|
+
readonly uriSans: readonly string[];
|
|
28
|
+
readonly dnsSans: readonly string[];
|
|
29
|
+
/** The proxy that terminated the TLS connection, as it named itself. */
|
|
30
|
+
readonly by: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Splits on a separator that is not inside a double-quoted value. `Subject="CN=a,OU=b"`. */
|
|
34
|
+
const splitUnquoted = (value: string, separator: string): readonly string[] => {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
let current = '';
|
|
37
|
+
let quoted = false;
|
|
38
|
+
let escaped = false;
|
|
39
|
+
for (const char of value) {
|
|
40
|
+
if (escaped) {
|
|
41
|
+
current += char;
|
|
42
|
+
escaped = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (char === '\\') {
|
|
46
|
+
escaped = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === '"') {
|
|
50
|
+
quoted = !quoted;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === separator && !quoted) {
|
|
54
|
+
out.push(current.trim());
|
|
55
|
+
current = '';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
current += char;
|
|
59
|
+
}
|
|
60
|
+
out.push(current.trim());
|
|
61
|
+
return out.filter((entry) => entry.length > 0);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** `Key=Value` pairs, keys lowercased. `URI` and `DNS` may repeat, so values collect. */
|
|
65
|
+
const pairsOf = (element: string): ReadonlyMap<string, readonly string[]> => {
|
|
66
|
+
const out = new Map<string, string[]>();
|
|
67
|
+
for (const pair of splitUnquoted(element, ';')) {
|
|
68
|
+
const eq = pair.indexOf('=');
|
|
69
|
+
if (eq <= 0) continue;
|
|
70
|
+
const key = pair.slice(0, eq).trim().toLowerCase();
|
|
71
|
+
const value = pair.slice(eq + 1).trim();
|
|
72
|
+
if (value.length === 0) continue;
|
|
73
|
+
const existing = out.get(key);
|
|
74
|
+
if (existing === undefined) out.set(key, [value]);
|
|
75
|
+
else existing.push(value);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The peer identity a TRUSTED proxy asserted, or `null`. `null` is the answer for an untrusted
|
|
82
|
+
* deployment, a missing header and a chain shorter than `trustedProxyHops` alike: a certificate
|
|
83
|
+
* identity read from a hop nobody vouched for is a confident name for an attacker's claim.
|
|
84
|
+
*/
|
|
85
|
+
export const peerIdentity = (input: ForwardedInput): PeerIdentity | null => {
|
|
86
|
+
const element = forwardedElement(
|
|
87
|
+
input.headers.get(FORWARDED_CLIENT_CERT),
|
|
88
|
+
input.config.trustedProxyHops,
|
|
89
|
+
splitUnquoted,
|
|
90
|
+
);
|
|
91
|
+
if (element === undefined) return null;
|
|
92
|
+
const pairs = pairsOf(element);
|
|
93
|
+
const uriSans = pairs.get('uri') ?? [];
|
|
94
|
+
const dnsSans = pairs.get('dns') ?? [];
|
|
95
|
+
const subject = pairs.get('subject')?.[0] ?? null;
|
|
96
|
+
const spiffeId = uriSans.find((uri) => uri.startsWith('spiffe://')) ?? null;
|
|
97
|
+
const id = spiffeId ?? subject ?? uriSans[0] ?? null;
|
|
98
|
+
if (id === null) return null;
|
|
99
|
+
return {
|
|
100
|
+
id,
|
|
101
|
+
spiffeId,
|
|
102
|
+
subject,
|
|
103
|
+
uriSans,
|
|
104
|
+
dnsSans,
|
|
105
|
+
by: pairs.get('by')?.[0] ?? null,
|
|
106
|
+
};
|
|
107
|
+
};
|