@zhin.js/host-http 1.1.16 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -14
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +10 -5
  37. package/lib/index.js +9 -5
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +5 -51
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -19
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +55 -13
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
@@ -1,29 +1,27 @@
1
+ import {
2
+ CONSOLE_PERMISSION_LIST,
3
+ pluginPermissionLevelResolver,
4
+ type ConsolePluginPermissionInfo,
5
+ type ConsolePluginPermissionLevelResolver,
6
+ } from '@zhin.js/console-protocol';
1
7
  import { timingSafeEqualString } from './timing-safe-equal.js';
8
+ import { isLoopbackAddress } from './protocol-auth.js';
9
+ import {
10
+ globalPermissions,
11
+ type ConsoleAccessProvider,
12
+ type ConsolePrincipal,
13
+ type ConsoleRequestMeta,
14
+ } from './access-control.js';
2
15
 
3
- /** Demo Host token scope (ADR 0016 subset for Plugin Runtime Host). */
4
- export type AuthScope = 'full' | 'demo';
5
-
6
- export type ScopedTokenConfig = {
7
- readonly token: string;
8
- /**
9
- * `full` is the operator. `user` is an alias of `demo`: an ordinary Console user who sees and
10
- * calls only what its `permissions` / `roles` allow. Stored and reported as `demo`.
11
- */
12
- readonly scope: AuthScope | 'user';
13
- /** Root-configured subject binding; never accepted from an HTTP payload. */
14
- readonly principalId?: string;
15
- /**
16
- * Console permissions held by this token (matched against Page `requiredPermissions`).
17
- * Ignored for `full` scope, which already holds every Console permission.
18
- */
19
- readonly permissions?: readonly string[];
20
- /** Console roles held by this token (matched against Page `requiredRoles`). */
21
- readonly roles?: readonly string[];
22
- };
16
+ /**
17
+ * Scope a Host handler runs with. Every admitted request is `full`: authorization happens
18
+ * before dispatch against the request's `ConsolePrincipal` (ADR 0049).
19
+ */
20
+ export type AuthScope = 'full';
23
21
 
24
22
  /**
25
- * What a Console caller may see. `unrestricted` is the operator (full scope, or a Host with no
26
- * tokens configured); otherwise only the listed permissions and roles count.
23
+ * What a Console caller may see. `unrestricted` is an open Host before setup; otherwise only
24
+ * the listed permissions and roles count.
27
25
  */
28
26
  export interface ConsoleAccess {
29
27
  readonly unrestricted: boolean;
@@ -49,160 +47,152 @@ export interface AuthenticatedTokenPrincipal {
49
47
  }
50
48
 
51
49
  export type TokenRegistryConfig = {
50
+ /** `http.token`: the bootstrap credential, valid only until the first owner exists. */
52
51
  readonly primaryToken?: string;
53
- readonly scopedTokens?: readonly ScopedTokenConfig[];
54
- };
55
-
56
- export type DynamicTokenConfig = ScopedTokenConfig & {
57
- /** Unix epoch milliseconds; omitted credentials remain valid until explicitly revoked. */
58
- readonly expiresAt?: number;
59
52
  };
60
53
 
61
- type TokenBinding = Readonly<{
62
- scope: AuthScope;
63
- principalId?: string;
64
- permissions: readonly string[];
65
- roles: readonly string[];
66
- expiresAt?: number;
67
- dynamic: boolean;
68
- }>;
54
+ const openBootstrap: ConsolePrincipal = Object.freeze({ kind: 'bootstrap', open: true });
55
+ const tokenBootstrap: ConsolePrincipal = Object.freeze({ kind: 'bootstrap', open: false });
69
56
 
57
+ /**
58
+ * Bearer authentication for the Host (ADR 0049).
59
+ * - Before setup: no credential configured → open; `http.token` → bootstrap (setup only).
60
+ * - After setup (an enabled global owner exists): only user sessions and API tokens from the
61
+ * {@link ConsoleAccessProvider}; `http.token` is rejected everywhere.
62
+ */
70
63
  export class TokenRegistry {
71
- readonly #entries = new Map<string, TokenBinding>();
64
+ readonly #primaryToken: string | undefined;
65
+ #provider: ConsoleAccessProvider | undefined;
66
+ #pluginPermissions: (() => readonly ConsolePluginPermissionInfo[]) | undefined;
72
67
 
73
68
  constructor(config: TokenRegistryConfig = {}) {
74
- if (config.primaryToken) {
75
- this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', permissions: [], roles: [], dynamic: false }));
76
- }
77
- for (const { token, scope, principalId, permissions, roles } of config.scopedTokens ?? []) {
78
- if (!token) continue;
79
- if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
80
- throw new Error('HTTP token principalId is invalid');
81
- }
82
- const candidate: TokenBinding = Object.freeze({
83
- scope: scope === 'full' ? 'full' : 'demo',
84
- ...(principalId === undefined ? {} : { principalId }),
85
- permissions: accessList(permissions, 'permissions'),
86
- roles: accessList(roles, 'roles'),
87
- dynamic: false,
88
- });
89
- const existing = this.#entries.get(token);
90
- if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
91
- || existing.permissions.join('\n') !== candidate.permissions.join('\n')
92
- || existing.roles.join('\n') !== candidate.roles.join('\n'))) {
93
- throw new Error('HTTP token has conflicting authority bindings');
94
- }
95
- this.#entries.set(token, candidate);
96
- }
69
+ this.#primaryToken = config.primaryToken || undefined;
97
70
  }
98
71
 
99
- resolve(token: string): AuthScope | null {
100
- if (!token) return null;
101
- for (const [known, binding] of this.#entries) {
102
- if (!timingSafeEqualString(known, token)) continue;
103
- if (this.#expired(known, binding)) return null;
104
- return binding.scope;
105
- }
106
- return null;
107
- }
108
-
109
- resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null {
110
- if (!token) return null;
111
- for (const [known, binding] of this.#entries) {
112
- if (timingSafeEqualString(known, token)) {
113
- if (this.#expired(known, binding)) return null;
114
- return binding.principalId
115
- ? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
116
- : null;
117
- }
118
- }
119
- return null;
72
+ /** Installs the user/session backend; returns an idempotent uninstaller. */
73
+ useAccessProvider(provider: ConsoleAccessProvider): () => void {
74
+ if (this.#provider && this.#provider !== provider) throw new Error('Console access provider is already installed');
75
+ this.#provider = provider;
76
+ return () => {
77
+ if (this.#provider === provider) this.#provider = undefined;
78
+ };
79
+ }
80
+
81
+ get accessProvider(): ConsoleAccessProvider | undefined {
82
+ return this.#provider;
120
83
  }
121
84
 
122
85
  /**
123
- * Console access for a bearer token. With no tokens configured the Host is local and open,
124
- * so every caller is the operator. An unknown or missing token gets public access only.
86
+ * Installs the plugin permission catalog source (the runtime composition root reads it from
87
+ * the current generation); returns an idempotent uninstaller.
125
88
  */
126
- resolveAccess(token: string): ConsoleAccess {
127
- if (!this.hasAnyToken()) return unrestrictedConsoleAccess;
128
- if (!token) return publicConsoleAccess;
129
- for (const [known, binding] of this.#entries) {
130
- if (!timingSafeEqualString(known, token)) continue;
131
- if (this.#expired(known, binding)) return publicConsoleAccess;
132
- if (binding.scope === 'full') return unrestrictedConsoleAccess;
133
- return Object.freeze({ unrestricted: false, permissions: binding.permissions, roles: binding.roles });
89
+ usePluginPermissions(source: () => readonly ConsolePluginPermissionInfo[]): () => void {
90
+ if (this.#pluginPermissions && this.#pluginPermissions !== source) throw new Error('Plugin permission catalog is already installed');
91
+ this.#pluginPermissions = source;
92
+ return () => {
93
+ if (this.#pluginPermissions === source) this.#pluginPermissions = undefined;
94
+ };
95
+ }
96
+
97
+ /** Plugin permissions the loaded plugins use or declare, with their levels (empty when none). */
98
+ pluginPermissions(): readonly ConsolePluginPermissionInfo[] {
99
+ try {
100
+ return this.#pluginPermissions?.() ?? [];
101
+ } catch {
102
+ return [];
134
103
  }
135
- return publicConsoleAccess;
136
104
  }
137
105
 
106
+ /** Declared-or-inferred level lookup over {@link pluginPermissions}. */
107
+ pluginPermissionLevels(): ConsolePluginPermissionLevelResolver {
108
+ return pluginPermissionLevelResolver(this.pluginPermissions());
109
+ }
110
+
111
+ /** An enabled global owner exists. */
112
+ initialized(): boolean {
113
+ return this.#provider?.initialized() === true;
114
+ }
115
+
116
+ /** Whether `/api` requires a credential (a bootstrap token is configured, or setup is done). */
138
117
  hasAnyToken(): boolean {
139
- for (const [token, binding] of this.#entries) {
140
- if (!this.#expired(token, binding)) return true;
141
- }
142
- return false;
118
+ return this.#primaryToken !== undefined || this.initialized();
143
119
  }
144
120
 
145
- /** Registers a runtime-issued credential and returns an idempotent revoker. */
146
- register(config: DynamicTokenConfig): () => void {
147
- if (!config.token) throw new Error('Dynamic HTTP token is required');
148
- if (config.principalId !== undefined
149
- && (!config.principalId.trim() || config.principalId !== config.principalId.trim())) {
150
- throw new Error('Dynamic HTTP token principalId is invalid');
151
- }
152
- if (config.expiresAt !== undefined
153
- && (!Number.isSafeInteger(config.expiresAt) || config.expiresAt <= Date.now())) {
154
- throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
121
+ /** Whether `token` is the bootstrap token and still accepted (setup not done). */
122
+ isBootstrapToken(token: string): boolean {
123
+ return !this.initialized() && this.#primaryToken !== undefined && !!token
124
+ && timingSafeEqualString(this.#primaryToken, token);
125
+ }
126
+
127
+ /** Resolves a bearer to a principal; null when it is not (or no longer) valid. */
128
+ async authenticate(token: string, meta: ConsoleRequestMeta = {}): Promise<ConsolePrincipal | null> {
129
+ // An open Host (no bootstrap token, no owner) holds every permission, so it is only open to
130
+ // loopback peers that did not come through a proxy, and to callers that explicitly mark
131
+ // themselves in-process (tests, embedding). An unknown peer address is not local.
132
+ if (!this.hasAnyToken()) return isOpenHostPeer(meta) ? openBootstrap : null;
133
+ if (this.isBootstrapToken(token)) return tokenBootstrap;
134
+ if (!token || !this.#provider) return null;
135
+ try {
136
+ return await this.#provider.authenticate(token, meta);
137
+ } catch {
138
+ return null;
155
139
  }
156
- const candidate: TokenBinding = Object.freeze({
157
- scope: config.scope === 'full' ? 'full' : 'demo',
158
- ...(config.principalId === undefined ? {} : { principalId: config.principalId }),
159
- permissions: accessList(config.permissions, 'permissions'),
160
- roles: accessList(config.roles, 'roles'),
161
- ...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
162
- dynamic: true,
163
- });
164
- const existing = this.#entries.get(config.token);
165
- if (existing) throw new Error('Dynamic HTTP token conflicts with an existing credential');
166
- this.#entries.set(config.token, candidate);
167
- let active = true;
168
- return () => {
169
- if (!active) return;
170
- active = false;
171
- if (this.#entries.get(config.token) === candidate) this.#entries.delete(config.token);
172
- };
173
140
  }
174
141
 
175
- revoke(token: string): boolean {
176
- const binding = this.#entries.get(token);
177
- return binding?.dynamic === true && this.#entries.delete(token);
142
+ /**
143
+ * Bearer check for non-Console protocol surfaces (MCP, A2A): the bootstrap token before
144
+ * setup, or a user credential holding `permission` globally. An open Host yields false so
145
+ * the surface keeps its own unauthenticated (loopback) policy.
146
+ */
147
+ async authorizeBearer(token: string, permission: string, meta: ConsoleRequestMeta = {}): Promise<boolean> {
148
+ if (!token || !this.hasAnyToken()) return false;
149
+ return this.authorizePrincipal(await this.authenticate(token, meta), permission);
178
150
  }
179
151
 
180
- primaryTokenPrefixForLog(): string {
181
- for (const [tok, binding] of this.#entries) {
182
- if (binding.scope === 'full') return tok.slice(0, 6);
183
- }
184
- const first = this.#entries.keys().next().value;
185
- return first ? first.slice(0, 6) : '';
152
+ /**
153
+ * {@link authorizeBearer} for a principal already resolved from the request's bearer (by this
154
+ * registry, or by the gateway that forwarded the request): the bootstrap token before setup,
155
+ * or a user credential holding `permission` globally. An open Host yields false.
156
+ */
157
+ authorizePrincipal(principal: ConsolePrincipal | null | undefined, permission: string): boolean {
158
+ if (!principal) return false;
159
+ if (principal.kind === 'bootstrap') return !principal.open;
160
+ return principal.grants.some(grant => grant.scope.kind === 'global' && grant.permissions.includes(permission));
186
161
  }
187
162
 
188
- #expired(token: string, binding: TokenBinding): boolean {
189
- if (binding.expiresAt === undefined || binding.expiresAt > Date.now()) return false;
190
- if (binding.dynamic) this.#entries.delete(token);
191
- return true;
163
+ /**
164
+ * Synchronous explicit-credential check: `full` only for the still-valid bootstrap token.
165
+ * User credentials need {@link authenticate} (or the request's bound principal).
166
+ */
167
+ resolve(token: string): AuthScope | null {
168
+ // An open Host has no explicit credential: routes that demand one keep refusing it.
169
+ return this.isBootstrapToken(token) ? 'full' : null;
192
170
  }
193
- }
194
171
 
195
- function accessList(value: readonly string[] | undefined, label: string): readonly string[] {
196
- if (value === undefined) return Object.freeze([]);
197
- if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0 && item.trim() === item)) {
198
- throw new Error(`HTTP token ${label} must be non-empty trimmed strings`);
172
+ /** Console access for a resolved principal. */
173
+ accessFor(principal: ConsolePrincipal | null | undefined): ConsoleAccess {
174
+ if (!principal) return publicConsoleAccess;
175
+ if (principal.kind === 'bootstrap') return principal.open ? unrestrictedConsoleAccess : publicConsoleAccess;
176
+ // A global owner sees every page, including plugin pages whose permissions no plugin reports.
177
+ // An owner API token narrowed below the full catalog is not unrestricted.
178
+ if (principal.grants.some(grant => grant.role === 'owner' && grant.scope.kind === 'global'
179
+ && CONSOLE_PERMISSION_LIST.every(permission => grant.permissions.includes(permission)))) return unrestrictedConsoleAccess;
180
+ return Object.freeze({
181
+ unrestricted: false,
182
+ permissions: globalPermissions(principal, this.pluginPermissions()),
183
+ roles: Object.freeze([...principal.roles].sort()),
184
+ });
199
185
  }
200
- return Object.freeze([...new Set(value)].sort());
201
- }
202
186
 
203
- /** WebSocket upgrade paths allowed for demo scope. */
204
- export function isDemoWebSocketPath(pathname: string): boolean {
205
- return pathname === '/sandbox';
187
+ /** Synchronous legacy access: only an open Host is unrestricted. */
188
+ resolveAccess(_token: string): ConsoleAccess {
189
+ return this.hasAnyToken() ? publicConsoleAccess : unrestrictedConsoleAccess;
190
+ }
191
+
192
+ primaryTokenPrefixForLog(): string {
193
+ if (this.initialized()) return '(users)';
194
+ return this.#primaryToken ? `${this.#primaryToken.slice(0, 6)}…` : '';
195
+ }
206
196
  }
207
197
 
208
198
  export function extractBearerToken(
@@ -212,3 +202,10 @@ export function extractBearerToken(
212
202
  if (authorization?.startsWith('Bearer ')) return authorization.slice(7).trim();
213
203
  return queryToken?.trim() ?? '';
214
204
  }
205
+
206
+ /** Who may use an open Host: no proxy hop, and a loopback peer or an explicit in-process caller. */
207
+ function isOpenHostPeer(meta: ConsoleRequestMeta): boolean {
208
+ if (meta.forwarded) return false;
209
+ if (meta.ip === undefined) return meta.inProcess === true;
210
+ return isLoopbackAddress(meta.ip);
211
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Client address behind reverse proxies (`http.trustProxy`). Without it every request is
3
+ * attributed to its TCP peer and forwarding headers are ignored, so a client can never spoof its
4
+ * address. With it, `X-Forwarded-For` (or RFC 7239 `Forwarded`) is walked from the peer towards
5
+ * the client while each hop is trusted.
6
+ */
7
+ import { isIP } from 'node:net';
8
+
9
+ /**
10
+ * `true` trusts every hop; a number trusts that many hops next to the server; a list trusts
11
+ * peers matching an IP or CIDR (`10.0.0.0/8`, `::1`, `fd00::/8`) and the aliases `loopback`,
12
+ * `private` (RFC 1918 / ULA) and `linklocal`.
13
+ */
14
+ export type TrustProxySetting = boolean | number | readonly string[];
15
+
16
+ /** Decides whether a hop is a trusted proxy; `index` 0 is the TCP peer. */
17
+ export type TrustProxyPredicate = (address: string, index: number) => boolean;
18
+
19
+ const ALIASES: Readonly<Record<string, readonly string[]>> = Object.freeze({
20
+ loopback: ['127.0.0.0/8', '::1/128'],
21
+ private: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'],
22
+ linklocal: ['169.254.0.0/16', 'fe80::/10'],
23
+ });
24
+
25
+ export class TrustProxyConfigError extends Error {
26
+ constructor(message: string) {
27
+ super(`http.trustProxy: ${message}`);
28
+ this.name = 'TrustProxyConfigError';
29
+ }
30
+ }
31
+
32
+ /** Validates a raw config value; `undefined` / `false` → undefined (feature off). */
33
+ export function parseTrustProxy(value: unknown): TrustProxySetting | undefined {
34
+ if (value === undefined || value === null || value === false) return undefined;
35
+ if (typeof value === 'string') {
36
+ // Environment placeholders expand to strings: "true", "2", "10.0.0.0/8, ::1".
37
+ const text = value.trim().toLowerCase();
38
+ if (text === '' || text === 'false') return undefined;
39
+ if (text === 'true') return true;
40
+ if (/^\d+$/u.test(text)) return parseTrustProxy(Number(text));
41
+ }
42
+ if (value === true) return true;
43
+ if (typeof value === 'number') {
44
+ if (!Number.isSafeInteger(value) || value < 0 || value > 32) throw new TrustProxyConfigError('hop count must be an integer 0–32');
45
+ return value === 0 ? undefined : value;
46
+ }
47
+ const list = typeof value === 'string' ? value.split(',') : value;
48
+ if (!Array.isArray(list)) throw new TrustProxyConfigError('must be true, a hop count, or a list of proxy IPs / CIDRs');
49
+ const entries = list.map(entry => String(entry).trim()).filter(Boolean);
50
+ for (const entry of entries) compileEntry(entry);
51
+ return entries.length === 0 ? undefined : Object.freeze(entries);
52
+ }
53
+
54
+ /** Predicate for a setting; undefined means no hop is trusted. */
55
+ export function compileTrustProxy(setting: TrustProxySetting | undefined): TrustProxyPredicate | undefined {
56
+ if (setting === undefined || setting === false) return undefined;
57
+ if (setting === true) return () => true;
58
+ if (typeof setting === 'number') return (_address, index) => index < setting;
59
+ const matchers = setting.flatMap(entry => compileEntry(entry));
60
+ return (address) => {
61
+ const parsed = parseAddress(address);
62
+ return parsed !== undefined && matchers.some(matcher => matcher(parsed));
63
+ };
64
+ }
65
+
66
+ /** True when the request carries any proxy forwarding header. */
67
+ export function hasForwardingHeaders(headers: Headers | Readonly<Record<string, unknown>>): boolean {
68
+ const get = (name: string): unknown => headers instanceof Headers ? headers.get(name) : headers[name];
69
+ return get('x-forwarded-for') != null || get('forwarded') != null;
70
+ }
71
+
72
+ /**
73
+ * Client address for one request. Returns the peer unchanged unless the peer is trusted; then
74
+ * walks the forwarded chain right to left and returns the first untrusted hop (or the leftmost).
75
+ */
76
+ export function resolveClientAddress(
77
+ peer: string | undefined,
78
+ headers: Headers | Readonly<Record<string, unknown>>,
79
+ trust: TrustProxyPredicate | undefined,
80
+ ): string | undefined {
81
+ if (!peer || !trust) return peer;
82
+ const chain = forwardedChain(headers);
83
+ if (chain === undefined) return peer;
84
+ const hops = [normalizeAddress(peer), ...chain.reverse()];
85
+ let index = 0;
86
+ while (index < hops.length - 1 && trust(hops[index]!, index)) index += 1;
87
+ return hops[index];
88
+ }
89
+
90
+ /** Forwarded addresses, client first; undefined when absent or malformed (fail closed to the peer). */
91
+ function forwardedChain(headers: Headers | Readonly<Record<string, unknown>>): string[] | undefined {
92
+ const read = (name: string): string | undefined => {
93
+ const value = headers instanceof Headers ? headers.get(name) : headers[name];
94
+ if (value == null) return undefined;
95
+ return Array.isArray(value) ? value.join(',') : String(value);
96
+ };
97
+ const xff = read('x-forwarded-for');
98
+ const raw = xff !== undefined
99
+ ? xff.split(',').map(part => part.trim())
100
+ : read('forwarded')?.split(',').map(part => forwardedFor(part));
101
+ if (!raw || raw.length === 0) return undefined;
102
+ const chain: string[] = [];
103
+ for (const entry of raw) {
104
+ const address = entry === undefined ? undefined : normalizeAddress(stripPort(entry));
105
+ if (!address || isIP(address) === 0) return undefined;
106
+ chain.push(address);
107
+ }
108
+ return chain;
109
+ }
110
+
111
+ /** `for=` of one RFC 7239 element; `for="[2001:db8::1]:80"`, `for=192.0.2.1`. */
112
+ function forwardedFor(element: string): string | undefined {
113
+ for (const pair of element.split(';')) {
114
+ const [key, ...rest] = pair.split('=');
115
+ if (key?.trim().toLowerCase() !== 'for') continue;
116
+ return rest.join('=').trim().replace(/^"|"$/gu, '');
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function stripPort(value: string): string {
122
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/u.exec(value);
123
+ if (bracketed) return bracketed[1]!;
124
+ const v4 = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/u.exec(value);
125
+ return v4 ? v4[1]! : value;
126
+ }
127
+
128
+ function normalizeAddress(address: string): string {
129
+ const lower = address.trim().toLowerCase();
130
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(lower);
131
+ return mapped ? mapped[1]! : lower;
132
+ }
133
+
134
+ interface ParsedAddress {
135
+ readonly family: 4 | 6;
136
+ readonly value: bigint;
137
+ }
138
+
139
+ function parseAddress(address: string): ParsedAddress | undefined {
140
+ const normalized = normalizeAddress(address);
141
+ const family = isIP(normalized);
142
+ if (family === 4) {
143
+ return { family: 4, value: normalized.split('.').reduce((sum, part) => (sum << 8n) + BigInt(Number(part)), 0n) };
144
+ }
145
+ if (family === 6) return { family: 6, value: ipv6ToBigInt(normalized) };
146
+ return undefined;
147
+ }
148
+
149
+ function ipv6ToBigInt(address: string): bigint {
150
+ let text = address.split('%')[0]!;
151
+ const v4Tail = /(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(text);
152
+ if (v4Tail) {
153
+ const parts = v4Tail[1]!.split('.').map(Number);
154
+ text = `${text.slice(0, -v4Tail[1]!.length)}${((parts[0]! << 8) | parts[1]!).toString(16)}:${((parts[2]! << 8) | parts[3]!).toString(16)}`;
155
+ }
156
+ const [head, tail] = text.split('::') as [string, string | undefined];
157
+ const headParts = head ? head.split(':') : [];
158
+ const tailParts = tail ? tail.split(':') : [];
159
+ const missing = tail === undefined ? 0 : 8 - headParts.length - tailParts.length;
160
+ const groups = [...headParts, ...Array<string>(missing).fill('0'), ...tailParts];
161
+ return groups.reduce((sum, group) => (sum << 16n) + BigInt(parseInt(group || '0', 16)), 0n);
162
+ }
163
+
164
+ function compileEntry(entry: string): Array<(address: ParsedAddress) => boolean> {
165
+ const alias = ALIASES[entry.toLowerCase()];
166
+ if (alias) return alias.flatMap(compileEntry);
167
+ const [ip, prefixText] = entry.split('/') as [string, string | undefined];
168
+ const parsed = parseAddress(ip);
169
+ if (!parsed) throw new TrustProxyConfigError(`"${entry}" is not an IP address, CIDR or alias (loopback, private, linklocal)`);
170
+ const bits = parsed.family === 4 ? 32 : 128;
171
+ const prefix = prefixText === undefined ? bits : Number(prefixText);
172
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits || (prefixText !== undefined && !/^\d+$/u.test(prefixText))) {
173
+ throw new TrustProxyConfigError(`"${entry}" has an invalid prefix length`);
174
+ }
175
+ const shift = BigInt(bits - prefix);
176
+ const network = parsed.value >> shift;
177
+ return [(address) => address.family === parsed.family && (address.value >> shift) === network];
178
+ }