@lensmcp/cluster 1.9.0 → 1.11.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.
@@ -1 +1 @@
1
- {"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
@@ -39,6 +39,29 @@ function verifyDevJwt(req) {
39
39
  const [h, p, s] = auth.slice(7).split('.');
40
40
  if (!h || !p || !s)
41
41
  return undefined;
42
+ // An IdP that signs ASYMMETRIC (RS256 + JWKS — e.g. tetros's auth host) does NOT use the HS256 dev
43
+ // shared-secret this dev edge assumed. The PROD gateway verifies RS256 via jwks-verify; in dev we
44
+ // DECODE + exp-check the claims (no signature check) — the gateway is the only local ingress, the token
45
+ // came from the trusted local IdP, and a dev IdP's ephemeral per-pod keys make in-cluster JWKS
46
+ // verification unreliable. (The HS256 path below stays for services using the shared-secret convention.)
47
+ let alg;
48
+ try {
49
+ alg = JSON.parse(Buffer.from(h, 'base64url').toString())['alg'];
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ if (typeof alg === 'string' && alg !== 'HS256') {
55
+ try {
56
+ const rsClaims = JSON.parse(Buffer.from(p, 'base64url').toString());
57
+ if (typeof rsClaims['exp'] === 'number' && Date.now() / 1000 > rsClaims['exp'])
58
+ return undefined;
59
+ return rsClaims;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
42
65
  const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
43
66
  const a = Buffer.from(s);
44
67
  const b = Buffer.from(expected);
@@ -694,7 +717,12 @@ async function startGateway(options, context) {
694
717
  }
695
718
  req.__lensmcpPod = path.basename(sock, '.sock');
696
719
  traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
697
- proxy.web(req, res, { target: { socketPath: sock }, autoRewrite: true, agent: httpAgent }, (err) => {
720
+ // NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's setRedirectHostRewrite
721
+ // does url.parse(options.target) on ANY 3xx response — which throws ("url must be a string, received
722
+ // Object") and CRASHES the gateway the instant a pod returns a redirect (e.g. the auth IdP's
723
+ // /authorize → login). A socket target has no host to rewrite anyway, and pods emit ABSOLUTE
724
+ // external redirect URLs, so no rewrite is needed.
725
+ proxy.web(req, res, { target: { socketPath: sock }, agent: httpAgent }, (err) => {
698
726
  route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
699
727
  route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
700
728
  try {
@@ -707,7 +735,9 @@ async function startGateway(options, context) {
707
735
  });
708
736
  return;
709
737
  }
710
- proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
738
+ // autoRewrite ONLY for a string (external URL) target a non-string target crashes
739
+ // setRedirectHostRewrite's url.parse(options.target) on a 3xx (see the socketPath case above).
740
+ proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
711
741
  console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
712
742
  res.statusCode = 502;
713
743
  res.end(`Upstream ${route.project} unavailable.`);
@@ -785,6 +815,26 @@ async function startGateway(options, context) {
785
815
  ...(route.host ? { routeHost: route.host } : {}),
786
816
  ...(route.internal ? { internal: true } : {}),
787
817
  });
818
+ // CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries
819
+ // no token/credentials by spec, so the JWT (or x-api-key) enforcement below would 401 it and the
820
+ // browser's CORS check fails with "No Access-Control-Allow-Origin". The matching real request
821
+ // (GET/POST with the token) still goes through auth normally; the service supplies CORS on it.
822
+ if (req.method === 'OPTIONS') {
823
+ cors(req, res);
824
+ res.writeHead(204, {
825
+ 'access-control-allow-methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
826
+ 'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
827
+ });
828
+ res.end();
829
+ return;
830
+ }
831
+ // CORS on EVERY routed response — including the 401/403/5xx the gateway generates ITSELF below (edge
832
+ // auth, authz, proxy errors). Without this, a gateway-generated error short-circuits before the upstream
833
+ // service (which carries CORS on success), so the browser sees the error WITHOUT an
834
+ // Access-Control-Allow-Origin and reports a CORS failure that MASKS the real status (e.g. a 401 the app
835
+ // should handle by re-authing). On the success path the upstream's CORS header overwrites this one
836
+ // (http-proxy setHeader), so there's no duplicate. The gateway is the edge CORS authority.
837
+ cors(req, res);
788
838
  if (route.internal) {
789
839
  // service-to-service: validate the CALLER's api key at the boundary
790
840
  const presented = String(req.headers['x-api-key'] ?? '');
@@ -1 +1 @@
1
- {"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"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"}
@@ -415,6 +415,17 @@ async function startProdGateway(opts) {
415
415
  return reply;
416
416
  }
417
417
  step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
418
+ // CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries no
419
+ // token by spec, so the auth below would 401 it and the browser's CORS check fails. The real request
420
+ // still authenticates. (handleNoRoute covers UNROUTED preflights; this covers routed ones.)
421
+ if (req.method === 'OPTIONS') {
422
+ setCors(reply, req);
423
+ reply.code(204)
424
+ .header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
425
+ .header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
426
+ .send();
427
+ return reply;
428
+ }
418
429
  // OTel SERVER span (opt-in): continues the incoming traceparent. Stored now so
419
430
  // onResponse ends it on ANY exit path (auth reject / 503 / forward). Context is
420
431
  // injected into req.headers just before forwarding (below) so downstream parents to it.
@@ -757,6 +768,19 @@ async function startProdGateway(opts) {
757
768
  reply.type('application/json').send(JSON.stringify(opts.metrics.snapshot()));
758
769
  });
759
770
  app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
771
+ // Edge CORS authority: stamp Access-Control-Allow-Origin on EVERY response that lacks it — including the
772
+ // 401/403/5xx the gateway generates ITSELF (edge auth/authz/proxy errors) which never reach an upstream.
773
+ // Without it the browser sees the error WITHOUT CORS and reports a CORS failure that MASKS the real
774
+ // status (e.g. a 401 the app should handle by re-authing). Add ONLY if absent so a proxied success keeps
775
+ // the upstream's header (no duplicate). No credentials (Bearer APIs; matches setCors).
776
+ app.addHook('onSend', (request, reply, payload, done) => {
777
+ const origin = request.headers.origin;
778
+ if (origin && !reply.getHeader('access-control-allow-origin')) {
779
+ reply.header('access-control-allow-origin', String(origin));
780
+ reply.header('vary', 'Origin');
781
+ }
782
+ done(null, payload);
783
+ });
760
784
  app.addHook('onResponse', (request, reply, done) => {
761
785
  const st = reqState.get(request);
762
786
  if (st) {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * SSRF guard for OUTBOUND fetches of a request- or config-supplied URL.
3
+ *
4
+ * The prod gateway today never fetches an attacker-controlled URL — upstreams
5
+ * are pinned in the manifest, the JWKS URL is operator env, and reply-from /
6
+ * undici do not chase redirects. This module exists so that the MOMENT a sink
7
+ * IS added (a remote-MCP / OpenAPI schema import, a webhook callback, a
8
+ * dynamically-discovered upstream — anything that fetches a URL it did not
9
+ * author) there is a ready, tested guard to wrap it with, instead of a naive
10
+ * `if (host === '127.0.0.1')` string check that the classic bypasses defeat.
11
+ *
12
+ * It defends the three canonical bypasses:
13
+ * 1. DNS name that RESOLVES to an internal IP (`127.0.0.1.nip.io`, rebinding)
14
+ * → we resolve the host and check the RESOLVED addresses, not the string.
15
+ * 2. Redirect chase (`/redirect` 3xx → loopback) → `safeFetch` re-validates
16
+ * EVERY hop with `redirect:'manual'`; it never blindly follows a Location.
17
+ * 3. IPv4-mapped IPv6 loopback (`[::ffff:127.0.0.1]`, `[::ffff:7f00:1]`) →
18
+ * `net.BlockList` normalizes mapped forms against the IPv4 rules.
19
+ *
20
+ * Residual TOCTOU: between the resolve-and-check and the socket connect, a
21
+ * rebinding resolver could flip the record. Close it by pinning the connection
22
+ * to the validated address(es) with `pinnedLookup()` (pass to
23
+ * `new http.Agent({ lookup })` or an undici `connect:{ lookup }` dispatcher).
24
+ *
25
+ * Defaults are operator-overridable but FAIL CLOSED: anything unparseable, any
26
+ * non-http(s) scheme, embedded credentials, an empty host, or a host that
27
+ * resolves to no records is rejected.
28
+ */
29
+ import { BlockList } from 'node:net';
30
+ import type { LookupAddress } from 'node:dns';
31
+ /** Thrown for every blocked URL — carries a machine-readable `reason` and, when
32
+ * the block was an address, the offending `address` (the resolved IP, not the
33
+ * hostname, so logs show what the name actually pointed at). */
34
+ export declare class SsrfBlockedError extends Error {
35
+ readonly reason: string;
36
+ readonly url?: string;
37
+ readonly address?: string;
38
+ constructor(reason: string, url?: string, address?: string, cause?: unknown);
39
+ }
40
+ /** True if `ip` (a literal address) falls in a blocked range. Mapped IPv6 is
41
+ * normalized by BlockList; an unparseable string is treated as blocked. */
42
+ export declare function isBlockedAddress(ip: string, blockList?: BlockList): boolean;
43
+ export interface FetchableUrlOptions {
44
+ /** Allowed URL schemes. Default `['http:', 'https:']`. */
45
+ allowedProtocols?: string[];
46
+ /** DNS resolver (injectable for tests). Default `dns.lookup(host, {all:true})`. */
47
+ resolve?: (host: string) => Promise<string[]>;
48
+ /** Override the blocked-range set. Default = the internal/non-routable set. */
49
+ blockList?: BlockList;
50
+ /** Escape hatch: skip all internal-range checks (e.g. a trusted-loopback dev
51
+ * sink). Off by default — opt in explicitly, never from request input. */
52
+ allowPrivate?: boolean;
53
+ }
54
+ export interface FetchableUrl {
55
+ /** The parsed, validated URL. */
56
+ url: URL;
57
+ /** The host (brackets stripped for IPv6 literals). */
58
+ hostname: string;
59
+ /** Validated addresses: the literal IP, or every resolved DNS record. Feed to
60
+ * `pinnedLookup()` to pin the connection and close the rebinding TOCTOU. */
61
+ addresses: string[];
62
+ }
63
+ /**
64
+ * Validate that `rawUrl` is safe to fetch. Rejects (throws `SsrfBlockedError`)
65
+ * a malformed URL, a disallowed scheme, embedded credentials, a missing host,
66
+ * a literal internal IP, or a host that resolves to ANY internal address.
67
+ * Returns the parsed URL and the validated address set on success.
68
+ */
69
+ export declare function assertFetchableUrl(rawUrl: string | URL, opts?: FetchableUrlOptions): Promise<FetchableUrl>;
70
+ export interface SafeFetchOptions extends FetchableUrlOptions {
71
+ /** Max redirect hops before giving up. Default 5. */
72
+ maxRedirects?: number;
73
+ /** Injectable fetch (tests). Default global `fetch`. */
74
+ fetchImpl?: typeof fetch;
75
+ }
76
+ /**
77
+ * A `fetch` that validates the target — and re-validates every redirect hop —
78
+ * with the SSRF guard. Redirects are handled manually (`redirect:'manual'`) so
79
+ * a 3xx Location to an internal host is checked BEFORE it is followed, never
80
+ * chased blindly. Returns the final non-redirect `Response`.
81
+ *
82
+ * Note: each hop re-issues with the original `init` (method + body preserved —
83
+ * the security-relevant worst case for 307/308). A streamed request body cannot
84
+ * be replayed across hops.
85
+ */
86
+ export declare function safeFetch(rawUrl: string | URL, init?: RequestInit, opts?: SafeFetchOptions): Promise<Response>;
87
+ type LookupCallback = (err: NodeJS.ErrnoException | null, address?: string | LookupAddress[], family?: number) => void;
88
+ /**
89
+ * A `dns.lookup`-compatible function that ONLY ever returns the pre-validated
90
+ * `addresses`. Pass it to `new http.Agent({ lookup })` or an undici
91
+ * `connect:{ lookup }` dispatcher so the socket connects to an address the
92
+ * guard already cleared — closing the DNS-rebinding TOCTOU between check and
93
+ * connect. Use the `addresses` from `assertFetchableUrl()`.
94
+ */
95
+ export declare function pinnedLookup(addresses: readonly string[]): (hostname: string, options: unknown, callback?: LookupCallback) => void;
96
+ export {};
97
+ //# sourceMappingURL=ssrf-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssrf-guard.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/ssrf-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAQ,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE9C;;iEAEiE;AACjE,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;gBACd,MAAM,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAU5E;AA6BD;4EAC4E;AAC5E,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,GAAE,SAA8B,GAAG,OAAO,CAI/F;AAQD,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,mFAAmF;IACnF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC9C,+EAA+E;IAC/E,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;+EAC2E;IAC3E,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,GAAG,EAAE,GAAG,CAAC;IACT,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;IACjB;iFAC6E;IAC7E,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,GAAG,GAAG,EACpB,IAAI,GAAE,mBAAwB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAgDvB;AAED,MAAM,WAAW,gBAAiB,SAAQ,mBAAmB;IAC3D,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,GAAG,GAAG,EACpB,IAAI,GAAE,WAAgB,EACtB,IAAI,GAAE,gBAAqB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAgBnB;AAED,KAAK,cAAc,GAAG,CACpB,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EACjC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa,EAAE,EAClC,MAAM,CAAC,EAAE,MAAM,KACZ,IAAI,CAAC;AAEV;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,cAAc,KAAK,IAAI,CAezE"}
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SsrfBlockedError = void 0;
4
+ exports.isBlockedAddress = isBlockedAddress;
5
+ exports.assertFetchableUrl = assertFetchableUrl;
6
+ exports.safeFetch = safeFetch;
7
+ exports.pinnedLookup = pinnedLookup;
8
+ /**
9
+ * SSRF guard for OUTBOUND fetches of a request- or config-supplied URL.
10
+ *
11
+ * The prod gateway today never fetches an attacker-controlled URL — upstreams
12
+ * are pinned in the manifest, the JWKS URL is operator env, and reply-from /
13
+ * undici do not chase redirects. This module exists so that the MOMENT a sink
14
+ * IS added (a remote-MCP / OpenAPI schema import, a webhook callback, a
15
+ * dynamically-discovered upstream — anything that fetches a URL it did not
16
+ * author) there is a ready, tested guard to wrap it with, instead of a naive
17
+ * `if (host === '127.0.0.1')` string check that the classic bypasses defeat.
18
+ *
19
+ * It defends the three canonical bypasses:
20
+ * 1. DNS name that RESOLVES to an internal IP (`127.0.0.1.nip.io`, rebinding)
21
+ * → we resolve the host and check the RESOLVED addresses, not the string.
22
+ * 2. Redirect chase (`/redirect` 3xx → loopback) → `safeFetch` re-validates
23
+ * EVERY hop with `redirect:'manual'`; it never blindly follows a Location.
24
+ * 3. IPv4-mapped IPv6 loopback (`[::ffff:127.0.0.1]`, `[::ffff:7f00:1]`) →
25
+ * `net.BlockList` normalizes mapped forms against the IPv4 rules.
26
+ *
27
+ * Residual TOCTOU: between the resolve-and-check and the socket connect, a
28
+ * rebinding resolver could flip the record. Close it by pinning the connection
29
+ * to the validated address(es) with `pinnedLookup()` (pass to
30
+ * `new http.Agent({ lookup })` or an undici `connect:{ lookup }` dispatcher).
31
+ *
32
+ * Defaults are operator-overridable but FAIL CLOSED: anything unparseable, any
33
+ * non-http(s) scheme, embedded credentials, an empty host, or a host that
34
+ * resolves to no records is rejected.
35
+ */
36
+ const node_net_1 = require("node:net");
37
+ const promises_1 = require("node:dns/promises");
38
+ /** Thrown for every blocked URL — carries a machine-readable `reason` and, when
39
+ * the block was an address, the offending `address` (the resolved IP, not the
40
+ * hostname, so logs show what the name actually pointed at). */
41
+ class SsrfBlockedError extends Error {
42
+ constructor(reason, url, address, cause) {
43
+ super(`SSRF guard blocked ${url ?? 'request'}: ${reason}${address ? ` (${address})` : ''}`, cause !== undefined ? { cause } : undefined);
44
+ this.name = 'SsrfBlockedError';
45
+ this.reason = reason;
46
+ this.url = url;
47
+ this.address = address;
48
+ }
49
+ }
50
+ exports.SsrfBlockedError = SsrfBlockedError;
51
+ /** The internal / non-routable ranges an outbound fetch must never reach.
52
+ * Mapped IPv4-in-IPv6 (`::ffff:a.b.c.d`) is matched by the IPv4 rules — verified
53
+ * in the spec — so we do NOT blanket-block `::ffff:0:0/96` (mapped *public*
54
+ * IPv4 is legitimate). */
55
+ function buildDefaultBlockList() {
56
+ const b = new node_net_1.BlockList();
57
+ // ── IPv4 ──
58
+ b.addSubnet('0.0.0.0', 8, 'ipv4'); // "this host" / 0.0.0.0
59
+ b.addSubnet('10.0.0.0', 8, 'ipv4'); // RFC1918 private
60
+ b.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC6598 CGNAT
61
+ b.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback
62
+ b.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local incl. cloud metadata 169.254.169.254
63
+ b.addSubnet('172.16.0.0', 12, 'ipv4'); // RFC1918 private (172.16–172.31)
64
+ b.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments
65
+ b.addSubnet('192.168.0.0', 16, 'ipv4'); // RFC1918 private
66
+ b.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking
67
+ b.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved + 255.255.255.255 broadcast
68
+ // ── IPv6 ──
69
+ b.addAddress('::', 'ipv6'); // unspecified
70
+ b.addAddress('::1', 'ipv6'); // loopback
71
+ b.addSubnet('fc00::', 7, 'ipv6'); // unique-local (ULA)
72
+ b.addSubnet('fe80::', 10, 'ipv6'); // link-local
73
+ return b;
74
+ }
75
+ const DEFAULT_BLOCK_LIST = buildDefaultBlockList();
76
+ /** True if `ip` (a literal address) falls in a blocked range. Mapped IPv6 is
77
+ * normalized by BlockList; an unparseable string is treated as blocked. */
78
+ function isBlockedAddress(ip, blockList = DEFAULT_BLOCK_LIST) {
79
+ const fam = (0, node_net_1.isIP)(ip);
80
+ if (fam === 0)
81
+ return true; // not an IP → fail closed
82
+ return blockList.check(ip, fam === 6 ? 'ipv6' : 'ipv4');
83
+ }
84
+ const stripBrackets = (host) => host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
85
+ const defaultResolve = async (host) => (await (0, promises_1.lookup)(host, { all: true, verbatim: true })).map((r) => r.address);
86
+ /**
87
+ * Validate that `rawUrl` is safe to fetch. Rejects (throws `SsrfBlockedError`)
88
+ * a malformed URL, a disallowed scheme, embedded credentials, a missing host,
89
+ * a literal internal IP, or a host that resolves to ANY internal address.
90
+ * Returns the parsed URL and the validated address set on success.
91
+ */
92
+ async function assertFetchableUrl(rawUrl, opts = {}) {
93
+ const protocols = opts.allowedProtocols ?? ['http:', 'https:'];
94
+ let url;
95
+ try {
96
+ url = new URL(rawUrl);
97
+ }
98
+ catch {
99
+ throw new SsrfBlockedError('malformed-url', String(rawUrl));
100
+ }
101
+ if (!protocols.includes(url.protocol)) {
102
+ throw new SsrfBlockedError(`scheme-not-allowed:${url.protocol}`, url.href);
103
+ }
104
+ if (url.username !== '' || url.password !== '') {
105
+ throw new SsrfBlockedError('embedded-credentials', url.href);
106
+ }
107
+ const hostname = stripBrackets(url.hostname);
108
+ if (hostname === '')
109
+ throw new SsrfBlockedError('missing-host', url.href);
110
+ if (opts.allowPrivate) {
111
+ return { url, hostname, addresses: (0, node_net_1.isIP)(hostname) ? [hostname] : [] };
112
+ }
113
+ const blockList = opts.blockList ?? DEFAULT_BLOCK_LIST;
114
+ // Literal IP (covers IPv4, IPv6, and IPv4-mapped IPv6 like [::ffff:127.0.0.1]).
115
+ if ((0, node_net_1.isIP)(hostname) !== 0) {
116
+ if (isBlockedAddress(hostname, blockList)) {
117
+ throw new SsrfBlockedError('blocked-address', url.href, hostname);
118
+ }
119
+ return { url, hostname, addresses: [hostname] };
120
+ }
121
+ // DNS name → resolve and check EVERY record (defeats nip.io / rebinding).
122
+ const resolve = opts.resolve ?? defaultResolve;
123
+ let addresses;
124
+ try {
125
+ addresses = await resolve(hostname);
126
+ }
127
+ catch (e) {
128
+ throw new SsrfBlockedError('dns-resolution-failed', url.href, undefined, e);
129
+ }
130
+ if (addresses.length === 0)
131
+ throw new SsrfBlockedError('dns-no-records', url.href);
132
+ for (const addr of addresses) {
133
+ if (isBlockedAddress(addr, blockList)) {
134
+ throw new SsrfBlockedError('blocked-resolved-address', url.href, addr);
135
+ }
136
+ }
137
+ return { url, hostname, addresses };
138
+ }
139
+ /**
140
+ * A `fetch` that validates the target — and re-validates every redirect hop —
141
+ * with the SSRF guard. Redirects are handled manually (`redirect:'manual'`) so
142
+ * a 3xx Location to an internal host is checked BEFORE it is followed, never
143
+ * chased blindly. Returns the final non-redirect `Response`.
144
+ *
145
+ * Note: each hop re-issues with the original `init` (method + body preserved —
146
+ * the security-relevant worst case for 307/308). A streamed request body cannot
147
+ * be replayed across hops.
148
+ */
149
+ async function safeFetch(rawUrl, init = {}, opts = {}) {
150
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
151
+ const maxRedirects = opts.maxRedirects ?? 5;
152
+ let current = rawUrl;
153
+ for (let hop = 0;; hop++) {
154
+ const { url } = await assertFetchableUrl(current, opts); // re-validate THIS hop
155
+ const res = await doFetch(url, { ...init, redirect: 'manual' });
156
+ const location = res.headers.get('location');
157
+ if (res.status >= 300 && res.status < 400 && location) {
158
+ if (hop >= maxRedirects)
159
+ throw new SsrfBlockedError('too-many-redirects', url.href);
160
+ current = new URL(location, url).href; // resolve relative; loop re-validates next hop
161
+ continue;
162
+ }
163
+ return res;
164
+ }
165
+ }
166
+ /**
167
+ * A `dns.lookup`-compatible function that ONLY ever returns the pre-validated
168
+ * `addresses`. Pass it to `new http.Agent({ lookup })` or an undici
169
+ * `connect:{ lookup }` dispatcher so the socket connects to an address the
170
+ * guard already cleared — closing the DNS-rebinding TOCTOU between check and
171
+ * connect. Use the `addresses` from `assertFetchableUrl()`.
172
+ */
173
+ function pinnedLookup(addresses) {
174
+ const recs = addresses.map((address) => ({
175
+ address,
176
+ family: (0, node_net_1.isIP)(address) === 6 ? 6 : 4,
177
+ }));
178
+ return (hostname, options, callback) => {
179
+ const cb = (typeof options === 'function' ? options : callback);
180
+ const all = typeof options === 'object' && options !== null && options.all === true;
181
+ if (recs.length === 0) {
182
+ cb(new Error(`SSRF guard: no pinned address for ${hostname}`));
183
+ return;
184
+ }
185
+ if (all)
186
+ cb(null, recs);
187
+ else
188
+ cb(null, recs[0].address, recs[0].family);
189
+ };
190
+ }
package/gateway.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@lensmcp/cluster/gateway` — the PRODUCTION gateway's public, extendable surface.
3
+ *
4
+ * This is the **deployable edge** (`startProdGateway` — Fastify + undici reverse
5
+ * proxy, manifest-driven, the thing `Dockerfile.prod-gateway` runs). A consumer
6
+ * (e.g. tetros' `server/apps/gateway`) imports from here to run or extend it with
7
+ * its own providers/middleware and ship a specialized image.
8
+ *
9
+ * ⚠️ This is NOT the DEV cluster gateway. The dev gateway is the
10
+ * `@lensmcp/cluster:gateway` nx EXECUTOR (`gateway.lib.ts` → `startGateway`) — a
11
+ * local-only HTTPS front door on :443 that fronts unix-socket pods for
12
+ * development. The two share a routing core (`manifest.ts`) but are separate
13
+ * lifecycles: dev = an nx target you run locally; prod = a containerized service
14
+ * you deploy. Kept on a separate subpath so the build-time webpack-config exports
15
+ * in the main `@lensmcp/cluster` entry never pull in fastify/undici.
16
+ */
17
+ export { startProdGateway, type ProdGatewayOptions, type ProdGatewayHandle, type ServiceStatus, } from './executors/gateway/prod-gateway.lib';
18
+ export { staticManifestProvider, endpointsPodProvider } from './executors/gateway/providers-prod';
19
+ export { createRegistryProviders, redisRegistrySource, type RegistrySource, type RedisLike, DEFAULT_REGISTRY_PREFIX, } from './executors/gateway/registry-source';
20
+ export { type RouteManifest, type RouteMount, type AuthMode, type AuthPolicy, type ManifestProvider, type PodProvider, type Upstream, type Route, manifestFromClusterDecl, } from './executors/gateway/manifest';
21
+ //# sourceMappingURL=gateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EACL,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,aAAa,GACnB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;AAGlG,OAAO,EACL,uBAAuB,EACvB,mBAAmB,EACnB,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,uBAAuB,GACxB,MAAM,qCAAqC,CAAC;AAI7C,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,KAAK,EACV,uBAAuB,GACxB,MAAM,8BAA8B,CAAC"}
package/gateway.js ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /**
3
+ * `@lensmcp/cluster/gateway` — the PRODUCTION gateway's public, extendable surface.
4
+ *
5
+ * This is the **deployable edge** (`startProdGateway` — Fastify + undici reverse
6
+ * proxy, manifest-driven, the thing `Dockerfile.prod-gateway` runs). A consumer
7
+ * (e.g. tetros' `server/apps/gateway`) imports from here to run or extend it with
8
+ * its own providers/middleware and ship a specialized image.
9
+ *
10
+ * ⚠️ This is NOT the DEV cluster gateway. The dev gateway is the
11
+ * `@lensmcp/cluster:gateway` nx EXECUTOR (`gateway.lib.ts` → `startGateway`) — a
12
+ * local-only HTTPS front door on :443 that fronts unix-socket pods for
13
+ * development. The two share a routing core (`manifest.ts`) but are separate
14
+ * lifecycles: dev = an nx target you run locally; prod = a containerized service
15
+ * you deploy. Kept on a separate subpath so the build-time webpack-config exports
16
+ * in the main `@lensmcp/cluster` entry never pull in fastify/undici.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.manifestFromClusterDecl = exports.DEFAULT_REGISTRY_PREFIX = exports.redisRegistrySource = exports.createRegistryProviders = exports.endpointsPodProvider = exports.staticManifestProvider = exports.startProdGateway = void 0;
20
+ // The prod gateway engine + its option/handle contract.
21
+ var prod_gateway_lib_1 = require("./executors/gateway/prod-gateway.lib");
22
+ Object.defineProperty(exports, "startProdGateway", { enumerable: true, get: function () { return prod_gateway_lib_1.startProdGateway; } });
23
+ // Static (in-memory) providers — build a gateway from explicit manifests/endpoints.
24
+ var providers_prod_1 = require("./executors/gateway/providers-prod");
25
+ Object.defineProperty(exports, "staticManifestProvider", { enumerable: true, get: function () { return providers_prod_1.staticManifestProvider; } });
26
+ Object.defineProperty(exports, "endpointsPodProvider", { enumerable: true, get: function () { return providers_prod_1.endpointsPodProvider; } });
27
+ // Redis-backed registry providers — the prod path (manifests/rollout read live from Redis).
28
+ var registry_source_1 = require("./executors/gateway/registry-source");
29
+ Object.defineProperty(exports, "createRegistryProviders", { enumerable: true, get: function () { return registry_source_1.createRegistryProviders; } });
30
+ Object.defineProperty(exports, "redisRegistrySource", { enumerable: true, get: function () { return registry_source_1.redisRegistrySource; } });
31
+ Object.defineProperty(exports, "DEFAULT_REGISTRY_PREFIX", { enumerable: true, get: function () { return registry_source_1.DEFAULT_REGISTRY_PREFIX; } });
32
+ // Manifest/route types + the cluster-decl → manifest converter (derive prod
33
+ // manifests from the SAME project.json `cluster` declarations dev uses).
34
+ var manifest_1 = require("./executors/gateway/manifest");
35
+ Object.defineProperty(exports, "manifestFromClusterDecl", { enumerable: true, get: function () { return manifest_1.manifestFromClusterDecl; } });
package/main.devserver.js CHANGED
@@ -600,7 +600,10 @@ else {
600
600
  }
601
601
  if (route?.target) {
602
602
  applyGatewayMiddleware(req);
603
- proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true }, (err) => {
603
+ // autoRewrite ONLY for a string (external URL) target: a route may target a child socketPath
604
+ // OBJECT, and http-proxy's setRedirectHostRewrite does url.parse(options.target) on ANY 3xx —
605
+ // which throws on an object and crashes the gateway (e.g. proxying the auth IdP's /authorize → login).
606
+ proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true }, (err) => {
604
607
  console.error(`[parent] proxy error to ${route.target}:`, err);
605
608
  res.statusCode = 502;
606
609
  res.setHeader('content-type', 'text/plain; charset=utf-8');
@@ -630,7 +633,12 @@ else {
630
633
  }
631
634
  proxy.web(req, res, {
632
635
  target: { socketPath: targetChild.socketPath },
633
- autoRewrite: true,
636
+ // NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's
637
+ // setRedirectHostRewrite does url.parse(options.target) on ANY 3xx response — which throws
638
+ // ("url must be a string, received Object") and CRASHES the gateway the moment a child
639
+ // returns a redirect (e.g. the auth IdP's /authorize → login). Host-rewrite is meaningless
640
+ // for a socket target anyway (no host to match), and our services emit ABSOLUTE external
641
+ // redirect URLs, so no rewrite is needed. (autoRewrite stays on the string-target route above.)
634
642
  headers: {
635
643
  'x-proxy-child-id': String(targetChild.id) // for logging/debugging
636
644
  }
@@ -722,7 +730,8 @@ else {
722
730
  }
723
731
  proxy.web(req, res, {
724
732
  target: { socketPath: targetChild.socketPath },
725
- autoRewrite: true,
733
+ // NO autoRewrite — a socketPath target crashes http-proxy's setRedirectHostRewrite on a 3xx
734
+ // (url.parse of the target OBJECT). See the public-port handler above.
726
735
  }, (err) => {
727
736
  targetChild.healthy = false;
728
737
  targetChild.lastError = String(err?.stack || err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@lensmcp/node-instrumentation": "1.9.0",
69
- "@lensmcp/nx-plugin": "1.9.0",
68
+ "@lensmcp/node-instrumentation": "1.11.0",
69
+ "@lensmcp/nx-plugin": "1.11.0",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",