@zhin.js/host-http 1.1.17 → 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.
- package/README.md +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +5 -1
- package/lib/console-plugin-rpc.js +5 -40
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -14
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +10 -5
- package/lib/index.js +9 -5
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +5 -51
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -19
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +55 -13
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
package/src/token-registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
26
|
-
*
|
|
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
|
-
|
|
62
|
-
|
|
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 #
|
|
64
|
+
readonly #primaryToken: string | undefined;
|
|
65
|
+
#provider: ConsoleAccessProvider | undefined;
|
|
66
|
+
#pluginPermissions: (() => readonly ConsolePluginPermissionInfo[]) | undefined;
|
|
72
67
|
|
|
73
68
|
constructor(config: TokenRegistryConfig = {}) {
|
|
74
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
if (!this.#expired(token, binding)) return true;
|
|
141
|
-
}
|
|
142
|
-
return false;
|
|
118
|
+
return this.#primaryToken !== undefined || this.initialized();
|
|
143
119
|
}
|
|
144
120
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|