@zhin.js/host-http 1.1.17 → 1.2.1
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 +12 -1
- package/lib/console-plugin-rpc.js +16 -66
- 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 -18
- 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 +11 -6
- package/lib/index.js +10 -6
- 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 +17 -77
- 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 -24
- 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 +56 -14
- 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/lib/token-registry.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { CONSOLE_PERMISSION_LIST, pluginPermissionLevelResolver, } from '@zhin.js/console-protocol';
|
|
1
2
|
import { timingSafeEqualString } from './timing-safe-equal.js';
|
|
3
|
+
import { isLoopbackAddress } from './protocol-auth.js';
|
|
4
|
+
import { globalPermissions, } from './access-control.js';
|
|
2
5
|
export const publicConsoleAccess = Object.freeze({
|
|
3
6
|
unrestricted: false,
|
|
4
7
|
permissions: Object.freeze([]),
|
|
@@ -9,154 +12,158 @@ export const unrestrictedConsoleAccess = Object.freeze({
|
|
|
9
12
|
permissions: Object.freeze([]),
|
|
10
13
|
roles: Object.freeze([]),
|
|
11
14
|
});
|
|
15
|
+
const openBootstrap = Object.freeze({ kind: 'bootstrap', open: true });
|
|
16
|
+
const tokenBootstrap = Object.freeze({ kind: 'bootstrap', open: false });
|
|
17
|
+
/**
|
|
18
|
+
* Bearer authentication for the Host (ADR 0049).
|
|
19
|
+
* - Before setup: no credential configured → open; `http.token` → bootstrap (setup only).
|
|
20
|
+
* - After setup (an enabled global owner exists): only user sessions and API tokens from the
|
|
21
|
+
* {@link ConsoleAccessProvider}; `http.token` is rejected everywhere.
|
|
22
|
+
*/
|
|
12
23
|
export class TokenRegistry {
|
|
13
|
-
#
|
|
24
|
+
#primaryToken;
|
|
25
|
+
#provider;
|
|
26
|
+
#pluginPermissions;
|
|
14
27
|
constructor(config = {}) {
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
this.#primaryToken = config.primaryToken || undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Installs the user/session backend; returns an idempotent uninstaller. */
|
|
31
|
+
useAccessProvider(provider) {
|
|
32
|
+
if (this.#provider && this.#provider !== provider)
|
|
33
|
+
throw new Error('Console access provider is already installed');
|
|
34
|
+
this.#provider = provider;
|
|
35
|
+
return () => {
|
|
36
|
+
if (this.#provider === provider)
|
|
37
|
+
this.#provider = undefined;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
get accessProvider() {
|
|
41
|
+
return this.#provider;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Installs the plugin permission catalog source (the runtime composition root reads it from
|
|
45
|
+
* the current generation); returns an idempotent uninstaller.
|
|
46
|
+
*/
|
|
47
|
+
usePluginPermissions(source) {
|
|
48
|
+
if (this.#pluginPermissions && this.#pluginPermissions !== source)
|
|
49
|
+
throw new Error('Plugin permission catalog is already installed');
|
|
50
|
+
this.#pluginPermissions = source;
|
|
51
|
+
return () => {
|
|
52
|
+
if (this.#pluginPermissions === source)
|
|
53
|
+
this.#pluginPermissions = undefined;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Plugin permissions the loaded plugins use or declare, with their levels (empty when none). */
|
|
57
|
+
pluginPermissions() {
|
|
58
|
+
try {
|
|
59
|
+
return this.#pluginPermissions?.() ?? [];
|
|
17
60
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
continue;
|
|
21
|
-
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
22
|
-
throw new Error('HTTP token principalId is invalid');
|
|
23
|
-
}
|
|
24
|
-
const candidate = Object.freeze({
|
|
25
|
-
scope: scope === 'full' ? 'full' : 'demo',
|
|
26
|
-
...(principalId === undefined ? {} : { principalId }),
|
|
27
|
-
permissions: accessList(permissions, 'permissions'),
|
|
28
|
-
roles: accessList(roles, 'roles'),
|
|
29
|
-
dynamic: false,
|
|
30
|
-
});
|
|
31
|
-
const existing = this.#entries.get(token);
|
|
32
|
-
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
|
|
33
|
-
|| existing.permissions.join('\n') !== candidate.permissions.join('\n')
|
|
34
|
-
|| existing.roles.join('\n') !== candidate.roles.join('\n'))) {
|
|
35
|
-
throw new Error('HTTP token has conflicting authority bindings');
|
|
36
|
-
}
|
|
37
|
-
this.#entries.set(token, candidate);
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
38
63
|
}
|
|
39
64
|
}
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
/** Declared-or-inferred level lookup over {@link pluginPermissions}. */
|
|
66
|
+
pluginPermissionLevels() {
|
|
67
|
+
return pluginPermissionLevelResolver(this.pluginPermissions());
|
|
68
|
+
}
|
|
69
|
+
/** An enabled global owner exists. */
|
|
70
|
+
initialized() {
|
|
71
|
+
return this.#provider?.initialized() === true;
|
|
72
|
+
}
|
|
73
|
+
/** Whether `/api` requires a credential (a bootstrap token is configured, or setup is done). */
|
|
74
|
+
hasAnyToken() {
|
|
75
|
+
return this.#primaryToken !== undefined || this.initialized();
|
|
76
|
+
}
|
|
77
|
+
/** Whether `token` is the bootstrap token and still accepted (setup not done). */
|
|
78
|
+
isBootstrapToken(token) {
|
|
79
|
+
return !this.initialized() && this.#primaryToken !== undefined && !!token
|
|
80
|
+
&& timingSafeEqualString(this.#primaryToken, token);
|
|
81
|
+
}
|
|
82
|
+
/** Resolves a bearer to a principal; null when it is not (or no longer) valid. */
|
|
83
|
+
async authenticate(token, meta = {}) {
|
|
84
|
+
// An open Host (no bootstrap token, no owner) holds every permission, so it is only open to
|
|
85
|
+
// loopback peers that did not come through a proxy, and to callers that explicitly mark
|
|
86
|
+
// themselves in-process (tests, embedding). An unknown peer address is not local.
|
|
87
|
+
if (!this.hasAnyToken())
|
|
88
|
+
return isOpenHostPeer(meta) ? openBootstrap : null;
|
|
89
|
+
if (this.isBootstrapToken(token))
|
|
90
|
+
return tokenBootstrap;
|
|
91
|
+
if (!token || !this.#provider)
|
|
42
92
|
return null;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
continue;
|
|
46
|
-
if (this.#expired(known, binding))
|
|
47
|
-
return null;
|
|
48
|
-
return binding.scope;
|
|
93
|
+
try {
|
|
94
|
+
return await this.#provider.authenticate(token, meta);
|
|
49
95
|
}
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
resolvePrincipal(token) {
|
|
53
|
-
if (!token)
|
|
96
|
+
catch {
|
|
54
97
|
return null;
|
|
55
|
-
for (const [known, binding] of this.#entries) {
|
|
56
|
-
if (timingSafeEqualString(known, token)) {
|
|
57
|
-
if (this.#expired(known, binding))
|
|
58
|
-
return null;
|
|
59
|
-
return binding.principalId
|
|
60
|
-
? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
|
|
61
|
-
: null;
|
|
62
|
-
}
|
|
63
98
|
}
|
|
64
|
-
return null;
|
|
65
99
|
}
|
|
66
100
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
101
|
+
* Bearer check for non-Console protocol surfaces (MCP, A2A): the bootstrap token before
|
|
102
|
+
* setup, or a user credential holding `permission` globally. An open Host yields false so
|
|
103
|
+
* the surface keeps its own unauthenticated (loopback) policy.
|
|
69
104
|
*/
|
|
70
|
-
|
|
71
|
-
if (!this.hasAnyToken())
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
return publicConsoleAccess;
|
|
75
|
-
for (const [known, binding] of this.#entries) {
|
|
76
|
-
if (!timingSafeEqualString(known, token))
|
|
77
|
-
continue;
|
|
78
|
-
if (this.#expired(known, binding))
|
|
79
|
-
return publicConsoleAccess;
|
|
80
|
-
if (binding.scope === 'full')
|
|
81
|
-
return unrestrictedConsoleAccess;
|
|
82
|
-
return Object.freeze({ unrestricted: false, permissions: binding.permissions, roles: binding.roles });
|
|
83
|
-
}
|
|
84
|
-
return publicConsoleAccess;
|
|
105
|
+
async authorizeBearer(token, permission, meta = {}) {
|
|
106
|
+
if (!token || !this.hasAnyToken())
|
|
107
|
+
return false;
|
|
108
|
+
return this.authorizePrincipal(await this.authenticate(token, meta), permission);
|
|
85
109
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
110
|
+
/**
|
|
111
|
+
* {@link authorizeBearer} for a principal already resolved from the request's bearer (by this
|
|
112
|
+
* registry, or by the gateway that forwarded the request): the bootstrap token before setup,
|
|
113
|
+
* or a user credential holding `permission` globally. An open Host yields false.
|
|
114
|
+
*/
|
|
115
|
+
authorizePrincipal(principal, permission) {
|
|
116
|
+
if (!principal)
|
|
117
|
+
return false;
|
|
118
|
+
if (principal.kind === 'bootstrap')
|
|
119
|
+
return !principal.open;
|
|
120
|
+
return principal.grants.some(grant => grant.scope.kind === 'global' && grant.permissions.includes(permission));
|
|
92
121
|
}
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Synchronous explicit-credential check: `full` only for the still-valid bootstrap token.
|
|
124
|
+
* User credentials need {@link authenticate} (or the request's bound principal).
|
|
125
|
+
*/
|
|
126
|
+
resolve(token) {
|
|
127
|
+
// An open Host has no explicit credential: routes that demand one keep refusing it.
|
|
128
|
+
return this.isBootstrapToken(token) ? 'full' : null;
|
|
129
|
+
}
|
|
130
|
+
/** Console access for a resolved principal. */
|
|
131
|
+
accessFor(principal) {
|
|
132
|
+
if (!principal)
|
|
133
|
+
return publicConsoleAccess;
|
|
134
|
+
if (principal.kind === 'bootstrap')
|
|
135
|
+
return principal.open ? unrestrictedConsoleAccess : publicConsoleAccess;
|
|
136
|
+
// A global owner sees every page, including plugin pages whose permissions no plugin reports.
|
|
137
|
+
// An owner API token narrowed below the full catalog is not unrestricted.
|
|
138
|
+
if (principal.grants.some(grant => grant.role === 'owner' && grant.scope.kind === 'global'
|
|
139
|
+
&& CONSOLE_PERMISSION_LIST.every(permission => grant.permissions.includes(permission))))
|
|
140
|
+
return unrestrictedConsoleAccess;
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
unrestricted: false,
|
|
143
|
+
permissions: globalPermissions(principal, this.pluginPermissions()),
|
|
144
|
+
roles: Object.freeze([...principal.roles].sort()),
|
|
112
145
|
});
|
|
113
|
-
const existing = this.#entries.get(config.token);
|
|
114
|
-
if (existing)
|
|
115
|
-
throw new Error('Dynamic HTTP token conflicts with an existing credential');
|
|
116
|
-
this.#entries.set(config.token, candidate);
|
|
117
|
-
let active = true;
|
|
118
|
-
return () => {
|
|
119
|
-
if (!active)
|
|
120
|
-
return;
|
|
121
|
-
active = false;
|
|
122
|
-
if (this.#entries.get(config.token) === candidate)
|
|
123
|
-
this.#entries.delete(config.token);
|
|
124
|
-
};
|
|
125
146
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
147
|
+
/** Synchronous legacy access: only an open Host is unrestricted. */
|
|
148
|
+
resolveAccess(_token) {
|
|
149
|
+
return this.hasAnyToken() ? publicConsoleAccess : unrestrictedConsoleAccess;
|
|
129
150
|
}
|
|
130
151
|
primaryTokenPrefixForLog() {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
const first = this.#entries.keys().next().value;
|
|
136
|
-
return first ? first.slice(0, 6) : '';
|
|
137
|
-
}
|
|
138
|
-
#expired(token, binding) {
|
|
139
|
-
if (binding.expiresAt === undefined || binding.expiresAt > Date.now())
|
|
140
|
-
return false;
|
|
141
|
-
if (binding.dynamic)
|
|
142
|
-
this.#entries.delete(token);
|
|
143
|
-
return true;
|
|
152
|
+
if (this.initialized())
|
|
153
|
+
return '(users)';
|
|
154
|
+
return this.#primaryToken ? `${this.#primaryToken.slice(0, 6)}…` : '';
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
|
-
function accessList(value, label) {
|
|
147
|
-
if (value === undefined)
|
|
148
|
-
return Object.freeze([]);
|
|
149
|
-
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0 && item.trim() === item)) {
|
|
150
|
-
throw new Error(`HTTP token ${label} must be non-empty trimmed strings`);
|
|
151
|
-
}
|
|
152
|
-
return Object.freeze([...new Set(value)].sort());
|
|
153
|
-
}
|
|
154
|
-
/** WebSocket upgrade paths allowed for demo scope. */
|
|
155
|
-
export function isDemoWebSocketPath(pathname) {
|
|
156
|
-
return pathname === '/sandbox';
|
|
157
|
-
}
|
|
158
157
|
export function extractBearerToken(authorization, queryToken) {
|
|
159
158
|
if (authorization?.startsWith('Bearer '))
|
|
160
159
|
return authorization.slice(7).trim();
|
|
161
160
|
return queryToken?.trim() ?? '';
|
|
162
161
|
}
|
|
162
|
+
/** Who may use an open Host: no proxy hop, and a loopback peer or an explicit in-process caller. */
|
|
163
|
+
function isOpenHostPeer(meta) {
|
|
164
|
+
if (meta.forwarded)
|
|
165
|
+
return false;
|
|
166
|
+
if (meta.ip === undefined)
|
|
167
|
+
return meta.inProcess === true;
|
|
168
|
+
return isLoopbackAddress(meta.ip);
|
|
169
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `true` trusts every hop; a number trusts that many hops next to the server; a list trusts
|
|
3
|
+
* peers matching an IP or CIDR (`10.0.0.0/8`, `::1`, `fd00::/8`) and the aliases `loopback`,
|
|
4
|
+
* `private` (RFC 1918 / ULA) and `linklocal`.
|
|
5
|
+
*/
|
|
6
|
+
export type TrustProxySetting = boolean | number | readonly string[];
|
|
7
|
+
/** Decides whether a hop is a trusted proxy; `index` 0 is the TCP peer. */
|
|
8
|
+
export type TrustProxyPredicate = (address: string, index: number) => boolean;
|
|
9
|
+
export declare class TrustProxyConfigError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
/** Validates a raw config value; `undefined` / `false` → undefined (feature off). */
|
|
13
|
+
export declare function parseTrustProxy(value: unknown): TrustProxySetting | undefined;
|
|
14
|
+
/** Predicate for a setting; undefined means no hop is trusted. */
|
|
15
|
+
export declare function compileTrustProxy(setting: TrustProxySetting | undefined): TrustProxyPredicate | undefined;
|
|
16
|
+
/** True when the request carries any proxy forwarding header. */
|
|
17
|
+
export declare function hasForwardingHeaders(headers: Headers | Readonly<Record<string, unknown>>): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Client address for one request. Returns the peer unchanged unless the peer is trusted; then
|
|
20
|
+
* walks the forwarded chain right to left and returns the first untrusted hop (or the leftmost).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveClientAddress(peer: string | undefined, headers: Headers | Readonly<Record<string, unknown>>, trust: TrustProxyPredicate | undefined): string | undefined;
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
const ALIASES = Object.freeze({
|
|
9
|
+
loopback: ['127.0.0.0/8', '::1/128'],
|
|
10
|
+
private: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'],
|
|
11
|
+
linklocal: ['169.254.0.0/16', 'fe80::/10'],
|
|
12
|
+
});
|
|
13
|
+
export class TrustProxyConfigError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(`http.trustProxy: ${message}`);
|
|
16
|
+
this.name = 'TrustProxyConfigError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Validates a raw config value; `undefined` / `false` → undefined (feature off). */
|
|
20
|
+
export function parseTrustProxy(value) {
|
|
21
|
+
if (value === undefined || value === null || value === false)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
// Environment placeholders expand to strings: "true", "2", "10.0.0.0/8, ::1".
|
|
25
|
+
const text = value.trim().toLowerCase();
|
|
26
|
+
if (text === '' || text === 'false')
|
|
27
|
+
return undefined;
|
|
28
|
+
if (text === 'true')
|
|
29
|
+
return true;
|
|
30
|
+
if (/^\d+$/u.test(text))
|
|
31
|
+
return parseTrustProxy(Number(text));
|
|
32
|
+
}
|
|
33
|
+
if (value === true)
|
|
34
|
+
return true;
|
|
35
|
+
if (typeof value === 'number') {
|
|
36
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 32)
|
|
37
|
+
throw new TrustProxyConfigError('hop count must be an integer 0–32');
|
|
38
|
+
return value === 0 ? undefined : value;
|
|
39
|
+
}
|
|
40
|
+
const list = typeof value === 'string' ? value.split(',') : value;
|
|
41
|
+
if (!Array.isArray(list))
|
|
42
|
+
throw new TrustProxyConfigError('must be true, a hop count, or a list of proxy IPs / CIDRs');
|
|
43
|
+
const entries = list.map(entry => String(entry).trim()).filter(Boolean);
|
|
44
|
+
for (const entry of entries)
|
|
45
|
+
compileEntry(entry);
|
|
46
|
+
return entries.length === 0 ? undefined : Object.freeze(entries);
|
|
47
|
+
}
|
|
48
|
+
/** Predicate for a setting; undefined means no hop is trusted. */
|
|
49
|
+
export function compileTrustProxy(setting) {
|
|
50
|
+
if (setting === undefined || setting === false)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (setting === true)
|
|
53
|
+
return () => true;
|
|
54
|
+
if (typeof setting === 'number')
|
|
55
|
+
return (_address, index) => index < setting;
|
|
56
|
+
const matchers = setting.flatMap(entry => compileEntry(entry));
|
|
57
|
+
return (address) => {
|
|
58
|
+
const parsed = parseAddress(address);
|
|
59
|
+
return parsed !== undefined && matchers.some(matcher => matcher(parsed));
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** True when the request carries any proxy forwarding header. */
|
|
63
|
+
export function hasForwardingHeaders(headers) {
|
|
64
|
+
const get = (name) => headers instanceof Headers ? headers.get(name) : headers[name];
|
|
65
|
+
return get('x-forwarded-for') != null || get('forwarded') != null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Client address for one request. Returns the peer unchanged unless the peer is trusted; then
|
|
69
|
+
* walks the forwarded chain right to left and returns the first untrusted hop (or the leftmost).
|
|
70
|
+
*/
|
|
71
|
+
export function resolveClientAddress(peer, headers, trust) {
|
|
72
|
+
if (!peer || !trust)
|
|
73
|
+
return peer;
|
|
74
|
+
const chain = forwardedChain(headers);
|
|
75
|
+
if (chain === undefined)
|
|
76
|
+
return peer;
|
|
77
|
+
const hops = [normalizeAddress(peer), ...chain.reverse()];
|
|
78
|
+
let index = 0;
|
|
79
|
+
while (index < hops.length - 1 && trust(hops[index], index))
|
|
80
|
+
index += 1;
|
|
81
|
+
return hops[index];
|
|
82
|
+
}
|
|
83
|
+
/** Forwarded addresses, client first; undefined when absent or malformed (fail closed to the peer). */
|
|
84
|
+
function forwardedChain(headers) {
|
|
85
|
+
const read = (name) => {
|
|
86
|
+
const value = headers instanceof Headers ? headers.get(name) : headers[name];
|
|
87
|
+
if (value == null)
|
|
88
|
+
return undefined;
|
|
89
|
+
return Array.isArray(value) ? value.join(',') : String(value);
|
|
90
|
+
};
|
|
91
|
+
const xff = read('x-forwarded-for');
|
|
92
|
+
const raw = xff !== undefined
|
|
93
|
+
? xff.split(',').map(part => part.trim())
|
|
94
|
+
: read('forwarded')?.split(',').map(part => forwardedFor(part));
|
|
95
|
+
if (!raw || raw.length === 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
const chain = [];
|
|
98
|
+
for (const entry of raw) {
|
|
99
|
+
const address = entry === undefined ? undefined : normalizeAddress(stripPort(entry));
|
|
100
|
+
if (!address || isIP(address) === 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
chain.push(address);
|
|
103
|
+
}
|
|
104
|
+
return chain;
|
|
105
|
+
}
|
|
106
|
+
/** `for=` of one RFC 7239 element; `for="[2001:db8::1]:80"`, `for=192.0.2.1`. */
|
|
107
|
+
function forwardedFor(element) {
|
|
108
|
+
for (const pair of element.split(';')) {
|
|
109
|
+
const [key, ...rest] = pair.split('=');
|
|
110
|
+
if (key?.trim().toLowerCase() !== 'for')
|
|
111
|
+
continue;
|
|
112
|
+
return rest.join('=').trim().replace(/^"|"$/gu, '');
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
function stripPort(value) {
|
|
117
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/u.exec(value);
|
|
118
|
+
if (bracketed)
|
|
119
|
+
return bracketed[1];
|
|
120
|
+
const v4 = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/u.exec(value);
|
|
121
|
+
return v4 ? v4[1] : value;
|
|
122
|
+
}
|
|
123
|
+
function normalizeAddress(address) {
|
|
124
|
+
const lower = address.trim().toLowerCase();
|
|
125
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(lower);
|
|
126
|
+
return mapped ? mapped[1] : lower;
|
|
127
|
+
}
|
|
128
|
+
function parseAddress(address) {
|
|
129
|
+
const normalized = normalizeAddress(address);
|
|
130
|
+
const family = isIP(normalized);
|
|
131
|
+
if (family === 4) {
|
|
132
|
+
return { family: 4, value: normalized.split('.').reduce((sum, part) => (sum << 8n) + BigInt(Number(part)), 0n) };
|
|
133
|
+
}
|
|
134
|
+
if (family === 6)
|
|
135
|
+
return { family: 6, value: ipv6ToBigInt(normalized) };
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
function ipv6ToBigInt(address) {
|
|
139
|
+
let text = address.split('%')[0];
|
|
140
|
+
const v4Tail = /(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(text);
|
|
141
|
+
if (v4Tail) {
|
|
142
|
+
const parts = v4Tail[1].split('.').map(Number);
|
|
143
|
+
text = `${text.slice(0, -v4Tail[1].length)}${((parts[0] << 8) | parts[1]).toString(16)}:${((parts[2] << 8) | parts[3]).toString(16)}`;
|
|
144
|
+
}
|
|
145
|
+
const [head, tail] = text.split('::');
|
|
146
|
+
const headParts = head ? head.split(':') : [];
|
|
147
|
+
const tailParts = tail ? tail.split(':') : [];
|
|
148
|
+
const missing = tail === undefined ? 0 : 8 - headParts.length - tailParts.length;
|
|
149
|
+
const groups = [...headParts, ...Array(missing).fill('0'), ...tailParts];
|
|
150
|
+
return groups.reduce((sum, group) => (sum << 16n) + BigInt(parseInt(group || '0', 16)), 0n);
|
|
151
|
+
}
|
|
152
|
+
function compileEntry(entry) {
|
|
153
|
+
const alias = ALIASES[entry.toLowerCase()];
|
|
154
|
+
if (alias)
|
|
155
|
+
return alias.flatMap(compileEntry);
|
|
156
|
+
const [ip, prefixText] = entry.split('/');
|
|
157
|
+
const parsed = parseAddress(ip);
|
|
158
|
+
if (!parsed)
|
|
159
|
+
throw new TrustProxyConfigError(`"${entry}" is not an IP address, CIDR or alias (loopback, private, linklocal)`);
|
|
160
|
+
const bits = parsed.family === 4 ? 32 : 128;
|
|
161
|
+
const prefix = prefixText === undefined ? bits : Number(prefixText);
|
|
162
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits || (prefixText !== undefined && !/^\d+$/u.test(prefixText))) {
|
|
163
|
+
throw new TrustProxyConfigError(`"${entry}" has an invalid prefix length`);
|
|
164
|
+
}
|
|
165
|
+
const shift = BigInt(bits - prefix);
|
|
166
|
+
const network = parsed.value >> shift;
|
|
167
|
+
return [(address) => address.family === parsed.family && (address.value >> shift) === network];
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
11
|
"import": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./node": {
|
|
14
|
+
"types": "./lib/node/index.d.ts",
|
|
15
|
+
"import": "./lib/node/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./bridge": {
|
|
18
|
+
"types": "./lib/bridge/index.d.ts",
|
|
19
|
+
"import": "./lib/bridge/index.js"
|
|
12
20
|
}
|
|
13
21
|
},
|
|
14
22
|
"files": [
|
|
@@ -17,11 +25,11 @@
|
|
|
17
25
|
],
|
|
18
26
|
"dependencies": {
|
|
19
27
|
"ws": "^8.21.1",
|
|
20
|
-
"@zhin.js/console-protocol": "1.1
|
|
21
|
-
"@zhin.js/host-http-contract": "1.
|
|
22
|
-
"@zhin.js/logger": "1.1.
|
|
23
|
-
"@zhin.js/schedule": "1.1.
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.
|
|
28
|
+
"@zhin.js/console-protocol": "1.2.1",
|
|
29
|
+
"@zhin.js/host-http-contract": "1.2.0",
|
|
30
|
+
"@zhin.js/logger": "1.1.3",
|
|
31
|
+
"@zhin.js/schedule": "1.1.5",
|
|
32
|
+
"@zhin.js/plugin-runtime": "1.2.0"
|
|
25
33
|
},
|
|
26
34
|
"devDependencies": {
|
|
27
35
|
"@types/node": "^26.1.2",
|