@intentface/latch-net 0.9.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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/allowlist.d.ts +9 -0
- package/dist/allowlist.d.ts.map +1 -0
- package/dist/allowlist.js +19 -0
- package/dist/allowlist.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/pinned-fetch.d.ts +72 -0
- package/dist/pinned-fetch.d.ts.map +1 -0
- package/dist/pinned-fetch.js +91 -0
- package/dist/pinned-fetch.js.map +1 -0
- package/dist/ssrf.d.ts +53 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +173 -0
- package/dist/ssrf.js.map +1 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Intentface
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @intentface/latch-net
|
|
2
|
+
|
|
3
|
+
Network-safety primitives for Latch — SSRF guards and a DNS-pinned egress `fetch`, shared by every host that opens an outbound connection on a model's behalf.
|
|
4
|
+
|
|
5
|
+
The root export has **zero third-party dependencies** (Node built-ins only), so any package can depend on it without pulling weight in. The pinned fetch, which needs `undici`, sits behind the `/pinned` subpath.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **`assertPublicUrl`** — the front-line check. Parses the URL and rejects any host that is, or resolves to, a private, loopback, link-local or otherwise internal address. Returns the parsed `URL` so the caller can keep using it.
|
|
10
|
+
- **`isBlockedHost` / `isBlockedResolved` / `isPrivateV4`** — the predicates behind it. The string-level one is deliberately conservative, covering `.localhost` and `.local` names, IPv6 literals in any spelling, and the integer and hex encodings of an IPv4 address. The resolving one is the rebinding backstop.
|
|
11
|
+
- **`isHostAllowed`** — positive allowlist matching (an exact host, or `.suffix` for subdomains), used to bound which hosts a tenant may reach at all. It complements the blocklist rather than replacing it.
|
|
12
|
+
- **`createPinnedFetch` / `pinnedFetch`** (`@intentface/latch-net/pinned`) — a `fetch` that vets addresses **inside** the lookup the connection itself uses.
|
|
13
|
+
|
|
14
|
+
## Why the pinned fetch exists
|
|
15
|
+
|
|
16
|
+
Checking a hostname and then handing it to `fetch` leaves a gap: `fetch` performs its own second DNS lookup, so a name that resolves public during the check and private when the socket opens defeats the guard. That is DNS rebinding, and it is a time-of-check/time-of-use bug, not a weak blocklist.
|
|
17
|
+
|
|
18
|
+
`createPinnedFetch` closes the gap by validating in the undici connector's `lookup`, so there is no distance between validating and connecting. TLS is untouched: SNI and certificate validation stay against the original hostname, and redirect hops dispatch through the same agent, so every hop is connect-vetted too.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { assertPublicUrl } from "@intentface/latch-net";
|
|
22
|
+
import { createPinnedFetch } from "@intentface/latch-net/pinned";
|
|
23
|
+
|
|
24
|
+
await assertPublicUrl(url); // throws on private / non-public targets
|
|
25
|
+
const fetch = createPinnedFetch();
|
|
26
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Where it fits
|
|
30
|
+
|
|
31
|
+
An optional peer of `@intentface/latch-core`, required by its `web_fetch` harness tool. `latch-mcp` uses the allowlist to bound MCP server hosts, and any host tool that fetches a user-supplied URL should route through it.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Positive host allowlist shared by hosts that gate outbound connections on a
|
|
3
|
+
* per-tenant list (MCP servers, OpenAPI backends). Complements the SSRF
|
|
4
|
+
* blocklist in ./ssrf.ts: the allowlist says which hosts a tenant may reach at
|
|
5
|
+
* all; the blocklist rejects private/internal addresses regardless.
|
|
6
|
+
*/
|
|
7
|
+
/** True if `host` is permitted by the allowlist (exact, or `.suffix` subdomain). */
|
|
8
|
+
export declare function isHostAllowed(host: string, allowed: string[]): boolean;
|
|
9
|
+
//# sourceMappingURL=allowlist.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"allowlist.d.ts","sourceRoot":"","sources":["../src/allowlist.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAStE"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Positive host allowlist shared by hosts that gate outbound connections on a
|
|
3
|
+
* per-tenant list (MCP servers, OpenAPI backends). Complements the SSRF
|
|
4
|
+
* blocklist in ./ssrf.ts: the allowlist says which hosts a tenant may reach at
|
|
5
|
+
* all; the blocklist rejects private/internal addresses regardless.
|
|
6
|
+
*/
|
|
7
|
+
/** True if `host` is permitted by the allowlist (exact, or `.suffix` subdomain). */
|
|
8
|
+
export function isHostAllowed(host, allowed) {
|
|
9
|
+
// Strip one trailing DNS dot: `URL.hostname` preserves it for a FQDN like
|
|
10
|
+
// `api.example.com.`, which must match the same allowlist entries.
|
|
11
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
12
|
+
return allowed.some((entry) => {
|
|
13
|
+
const a = entry.toLowerCase();
|
|
14
|
+
if (a.startsWith("."))
|
|
15
|
+
return h === a.slice(1) || h.endsWith(a);
|
|
16
|
+
return h === a;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=allowlist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"allowlist.js","sourceRoot":"","sources":["../src/allowlist.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,oFAAoF;AACpF,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,OAAiB;IAC3D,0EAA0E;IAC1E,mEAAmE;IACnE,MAAM,CAAC,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAChD,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE;QAC5B,MAAM,CAAC,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QAC9B,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QAChE,OAAO,CAAC,KAAK,CAAC,CAAC;IACjB,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intentface/latch-net
|
|
3
|
+
*
|
|
4
|
+
* Network-safety primitives shared across the Latch stack. This root export
|
|
5
|
+
* has zero third-party dependencies (Node built-ins only), so any host
|
|
6
|
+
* package can depend on it without pulling heavier packages in; the
|
|
7
|
+
* DNS-pinned egress fetch, which pulls in `undici`, lives behind the
|
|
8
|
+
* `@intentface/latch-net/pinned` subpath.
|
|
9
|
+
*/
|
|
10
|
+
export { isPrivateV4, isBlockedHost, isBlockedResolved, assertPublicUrl, } from "./ssrf.js";
|
|
11
|
+
export { isHostAllowed } from "./allowlist.js";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,eAAe,GAChB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intentface/latch-net
|
|
3
|
+
*
|
|
4
|
+
* Network-safety primitives shared across the Latch stack. This root export
|
|
5
|
+
* has zero third-party dependencies (Node built-ins only), so any host
|
|
6
|
+
* package can depend on it without pulling heavier packages in; the
|
|
7
|
+
* DNS-pinned egress fetch, which pulls in `undici`, lives behind the
|
|
8
|
+
* `@intentface/latch-net/pinned` subpath.
|
|
9
|
+
*/
|
|
10
|
+
export { isPrivateV4, isBlockedHost, isBlockedResolved, assertPublicUrl, } from "./ssrf.js";
|
|
11
|
+
export { isHostAllowed } from "./allowlist.js";
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,WAAW,EACX,aAAa,EACb,iBAAiB,EACjB,eAAe,GAChB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { buildConnector } from "undici";
|
|
2
|
+
/**
|
|
3
|
+
* DNS-pinned SSRF-safe fetch.
|
|
4
|
+
*
|
|
5
|
+
* The guards in `ssrf.ts` validate a hostname and then hand it to `fetch`,
|
|
6
|
+
* which performs its OWN second DNS lookup — a name that resolves public
|
|
7
|
+
* during the check and private when the connection opens (DNS rebinding /
|
|
8
|
+
* TOCTOU) defeats them. This module closes that gap by vetting addresses
|
|
9
|
+
* INSIDE the lookup the connection itself uses (undici `Agent` →
|
|
10
|
+
* `buildConnector` → `net`/`tls.connect` `lookup`), so there is no
|
|
11
|
+
* validate-vs-connect distance at all. TLS is untouched: undici sets
|
|
12
|
+
* `servername` from the URL host before the lookup runs, so SNI and
|
|
13
|
+
* certificate validation stay against the original hostname and
|
|
14
|
+
* `rejectUnauthorized` keeps its default.
|
|
15
|
+
*
|
|
16
|
+
* Lives behind the `@intentface/latch-net/pinned` subpath (not the root
|
|
17
|
+
* export) because it pulls in `undici` — the root entry stays third-party-free.
|
|
18
|
+
*
|
|
19
|
+
* Redirect hops dispatch through the same agent, so with `redirect: "follow"`
|
|
20
|
+
* every hop is connect-vetted too (call sites here still use "error"/"manual"
|
|
21
|
+
* for better messages and header hygiene).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Input is `string | URL` only — undici's `fetch` rejects `Request` objects
|
|
25
|
+
* built by another fetch implementation (cross-realm brand check), so the
|
|
26
|
+
* type must not admit them.
|
|
27
|
+
*/
|
|
28
|
+
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
29
|
+
export interface PinnedFetch extends FetchLike {
|
|
30
|
+
/** Close the underlying Agent (keep-alive sockets would hold a test's event loop). */
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
type Addr = {
|
|
34
|
+
address: string;
|
|
35
|
+
family: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Callback-style resolver, injectable for tests. Always invoked with
|
|
39
|
+
* `all: true`; other options (`family`, `hints`) pass through from `net`.
|
|
40
|
+
*/
|
|
41
|
+
export type BaseLookup = (hostname: string, options: {
|
|
42
|
+
all: true;
|
|
43
|
+
family?: number;
|
|
44
|
+
hints?: number;
|
|
45
|
+
}, callback: (err: NodeJS.ErrnoException | null, addresses?: Addr[]) => void) => void;
|
|
46
|
+
export interface PinnedFetchOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Skip the private-address vetting (trusted local backend, tests against
|
|
49
|
+
* 127.0.0.1 servers). The pinning path itself is unchanged.
|
|
50
|
+
*/
|
|
51
|
+
allowPrivate?: boolean;
|
|
52
|
+
/** Test seam; defaults to `node:dns` `lookup`. */
|
|
53
|
+
lookup?: BaseLookup;
|
|
54
|
+
/**
|
|
55
|
+
* Extra socket/TLS options for the connector (tests: a fixture `ca`).
|
|
56
|
+
* `autoSelectFamily` is widened in explicitly: it IS typed in the union's
|
|
57
|
+
* `TcpNetConnectOpts` member, but that member requires `port`, so a partial
|
|
58
|
+
* options object can never select it.
|
|
59
|
+
*/
|
|
60
|
+
connect?: buildConnector.BuildOptions & {
|
|
61
|
+
autoSelectFamily?: boolean;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a fetch whose connections resolve through the vetting lookup above.
|
|
66
|
+
* One shared `Agent` per instance — pooled keep-alive sockets are safe to
|
|
67
|
+
* reuse because they were vetted when they connected.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createPinnedFetch(options?: PinnedFetchOptions): PinnedFetch;
|
|
70
|
+
export declare const pinnedFetch: FetchLike;
|
|
71
|
+
export {};
|
|
72
|
+
//# sourceMappingURL=pinned-fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pinned-fetch.d.ts","sourceRoot":"","sources":["../src/pinned-fetch.ts"],"names":[],"mappings":"AAEA,OAAO,EAAS,cAAc,EAAwB,MAAM,QAAQ,CAAC;AAIrE;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,CACtB,KAAK,EAAE,MAAM,GAAG,GAAG,EACnB,IAAI,CAAC,EAAE,WAAW,KACf,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEvB,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,sFAAsF;IACtF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,KAAK,IAAI,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhD;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG,CACvB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EACvD,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,IAAI,KACtE,IAAI,CAAC;AAEV,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kDAAkD;IAClD,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC,YAAY,GAAG;QAAE,gBAAgB,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CACxE;AA0DD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CA6B/E;AASD,eAAO,MAAM,WAAW,EAAE,SACqB,CAAC"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
|
4
|
+
import { isBlockedHost, isPrivateV4, isPrivateV6, parseIPv6 } from "./ssrf.js";
|
|
5
|
+
function isPrivateAddress({ address, family }) {
|
|
6
|
+
if (family === 4)
|
|
7
|
+
return isPrivateV4(address);
|
|
8
|
+
const bytes = parseIPv6(address);
|
|
9
|
+
return bytes ? isPrivateV6(bytes) : true; // unparseable resolver output → fail closed
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Wrap a resolver so every address it returns is classified before the socket
|
|
13
|
+
* ever sees it: error (fail closed) on lookup failure, an empty answer, or —
|
|
14
|
+
* unless `allowPrivate` — any private/internal address. Node calls the lookup
|
|
15
|
+
* in two shapes depending on `options.all` (`autoSelectFamily` uses the array
|
|
16
|
+
* form); both are honored.
|
|
17
|
+
*/
|
|
18
|
+
function vettingLookup(base, allowPrivate) {
|
|
19
|
+
return (hostname, options, callback) => {
|
|
20
|
+
base(hostname, { ...options, all: true }, (err, addresses) => {
|
|
21
|
+
if (err)
|
|
22
|
+
return callback(err);
|
|
23
|
+
const addrs = addresses ?? [];
|
|
24
|
+
if (addrs.length === 0) {
|
|
25
|
+
return callback(new Error(`lookup returned no addresses: ${hostname}`));
|
|
26
|
+
}
|
|
27
|
+
if (!allowPrivate) {
|
|
28
|
+
const bad = addrs.find(isPrivateAddress);
|
|
29
|
+
if (bad) {
|
|
30
|
+
return callback(new Error(`Blocked private/internal address: ${hostname} -> ${bad.address}`));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (options.all)
|
|
34
|
+
return callback(null, addrs);
|
|
35
|
+
// Scalar shape: honor a requested family even if the base resolver
|
|
36
|
+
// ignored the forwarded option (real dns.lookup honors it; the contract
|
|
37
|
+
// on BaseLookup doesn't require it). No cross-family fallback — handing
|
|
38
|
+
// back the wrong family is worse than failing, and real dns.lookup
|
|
39
|
+
// reports ENOTFOUND for a family with no answers.
|
|
40
|
+
const pick = options.family
|
|
41
|
+
? addrs.find((a) => a.family === options.family)
|
|
42
|
+
: addrs[0];
|
|
43
|
+
if (!pick) {
|
|
44
|
+
return callback(new Error(`lookup returned no addresses for family ${options.family}: ${hostname}`));
|
|
45
|
+
}
|
|
46
|
+
callback(null, pick.address, pick.family);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build a fetch whose connections resolve through the vetting lookup above.
|
|
52
|
+
* One shared `Agent` per instance — pooled keep-alive sockets are safe to
|
|
53
|
+
* reuse because they were vetted when they connected.
|
|
54
|
+
*/
|
|
55
|
+
export function createPinnedFetch(options = {}) {
|
|
56
|
+
const { allowPrivate = false, lookup = dnsLookup } = options;
|
|
57
|
+
const base = buildConnector({
|
|
58
|
+
...options.connect,
|
|
59
|
+
// Passed through to net/tls.connect; spread AFTER options.connect so a
|
|
60
|
+
// caller can never displace the vetting resolver. (Cast for the same
|
|
61
|
+
// BuildOptions-union reason as `autoSelectFamily` above.)
|
|
62
|
+
lookup: vettingLookup(lookup, allowPrivate),
|
|
63
|
+
});
|
|
64
|
+
// net.connect skips `lookup` entirely for IP-literal hosts — vet those here.
|
|
65
|
+
const connector = (opts, cb) => {
|
|
66
|
+
const host = opts.hostname.replace(/^\[|\]$/g, "");
|
|
67
|
+
if (!allowPrivate && isIP(host) !== 0 && isBlockedHost(host)) {
|
|
68
|
+
cb(new Error(`Blocked private/internal address: ${host}`), null);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
base(opts, cb);
|
|
72
|
+
};
|
|
73
|
+
const agent = new Agent({ connect: connector });
|
|
74
|
+
// undici's fetch, not the global: mixing this Agent into Node's bundled
|
|
75
|
+
// fetch is cross-version undici and unsupported. Types differ from lib.dom
|
|
76
|
+
// but the runtime objects are the same implementation Node bundles.
|
|
77
|
+
const fn = (input, init) => undiciFetch(input, {
|
|
78
|
+
...init,
|
|
79
|
+
dispatcher: agent,
|
|
80
|
+
});
|
|
81
|
+
return Object.assign(fn, { close: () => agent.close() });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Shared default instance, constructed lazily (no Agent at import time).
|
|
85
|
+
* Deliberately exposed WITHOUT `close()` — it is shared, so no caller may
|
|
86
|
+
* tear it down under the others; a long-lived server never closes it, and
|
|
87
|
+
* idle keep-alive sockets expire on undici's own timeout.
|
|
88
|
+
*/
|
|
89
|
+
let shared;
|
|
90
|
+
export const pinnedFetch = (input, init) => (shared ??= createPinnedFetch())(input, init);
|
|
91
|
+
//# sourceMappingURL=pinned-fetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pinned-fetch.js","sourceRoot":"","sources":["../src/pinned-fetch.ts"],"names":[],"mappings":"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"}
|
package/dist/ssrf.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guards for any Latch host that opens an outbound connection to a
|
|
3
|
+
* config- or agent-supplied URL (MCP servers, OpenAPI backends, the renderer's
|
|
4
|
+
* fetch service). A URL could point at internal/cloud-metadata addresses, so
|
|
5
|
+
* there are two layers: a string-level blocklist for encoded/literal private
|
|
6
|
+
* hosts, and a DNS-resolution backstop for public names that resolve private.
|
|
7
|
+
*
|
|
8
|
+
* These are best-effort and deliberately conservative (they err toward
|
|
9
|
+
* blocking). They complement, but do not replace, a positive allowlist — see
|
|
10
|
+
* `@intentface/latch-mcp`'s per-tenant `allowedHosts`.
|
|
11
|
+
*
|
|
12
|
+
* These checks validate at CHECK time, not CONNECT time — on their own they
|
|
13
|
+
* do not close DNS rebinding (a name that resolves public here but private
|
|
14
|
+
* when the request opens). For that, route the request through the pinned
|
|
15
|
+
* fetch in `pinned-fetch.ts` (`@intentface/latch-net/pinned`), which vets
|
|
16
|
+
* addresses inside the lookup the connection itself uses; the checks here
|
|
17
|
+
* remain useful as early, well-messaged pre-flight gates.
|
|
18
|
+
*/
|
|
19
|
+
/** True if a dotted-decimal IPv4 (or a short form like 127.1) is private/loopback/link-local. */
|
|
20
|
+
export declare function isPrivateV4(ip: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Parse an IPv6 literal into its 16 bytes — compressed (`::1`), fully expanded
|
|
23
|
+
* (`0:0:0:0:0:0:0:1`), or with an embedded IPv4 tail (`::ffff:127.0.0.1`).
|
|
24
|
+
* Returns null for anything that isn't a valid IPv6 literal. One parser so every
|
|
25
|
+
* spelling (not just `::`-prefixed hex) is classified the same way.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseIPv6(input: string): number[] | null;
|
|
28
|
+
/**
|
|
29
|
+
* True if a 16-byte IPv6 address is unspecified/loopback/ULA/link-local, or an
|
|
30
|
+
* IPv4-mapped/compatible address whose embedded IPv4 is private.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isPrivateV6(b: number[]): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* String-level blocklist: trailing-dot, bracketed IPv6 (loopback/ULA/link-local
|
|
35
|
+
* + IPv4-mapped/compatible, dotted or hex), bare integer / hex encodings of
|
|
36
|
+
* IPv4 (2130706433, 0x7f000001), short dotted forms (127.1). Best-effort and
|
|
37
|
+
* conservative — strictly more blocking. `isBlockedResolved` backs this up to
|
|
38
|
+
* defeat DNS rebinding.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isBlockedHost(host: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* DNS-rebinding backstop: resolve the hostname and reject if ANY resolved
|
|
43
|
+
* address is private/loopback/link-local. The string check above cannot catch
|
|
44
|
+
* `http://public-name/` that resolves to 169.254.169.254 — this does.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isBlockedResolved(hostname: string): Promise<boolean>;
|
|
47
|
+
/**
|
|
48
|
+
* Convenience for callers that want one hard gate: parse `input`, then reject a
|
|
49
|
+
* private/internal literal (`isBlockedHost`) or a name that resolves private
|
|
50
|
+
* (`isBlockedResolved`). Returns the parsed `URL` on success; throws otherwise.
|
|
51
|
+
*/
|
|
52
|
+
export declare function assertPublicUrl(input: string | URL): Promise<URL>;
|
|
53
|
+
//# sourceMappingURL=ssrf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf.d.ts","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AAEH,iGAAiG;AACjG,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAa/C;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAgCxD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAUhD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAanD;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa1E;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAYvE"}
|
package/dist/ssrf.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
/**
|
|
4
|
+
* SSRF guards for any Latch host that opens an outbound connection to a
|
|
5
|
+
* config- or agent-supplied URL (MCP servers, OpenAPI backends, the renderer's
|
|
6
|
+
* fetch service). A URL could point at internal/cloud-metadata addresses, so
|
|
7
|
+
* there are two layers: a string-level blocklist for encoded/literal private
|
|
8
|
+
* hosts, and a DNS-resolution backstop for public names that resolve private.
|
|
9
|
+
*
|
|
10
|
+
* These are best-effort and deliberately conservative (they err toward
|
|
11
|
+
* blocking). They complement, but do not replace, a positive allowlist — see
|
|
12
|
+
* `@intentface/latch-mcp`'s per-tenant `allowedHosts`.
|
|
13
|
+
*
|
|
14
|
+
* These checks validate at CHECK time, not CONNECT time — on their own they
|
|
15
|
+
* do not close DNS rebinding (a name that resolves public here but private
|
|
16
|
+
* when the request opens). For that, route the request through the pinned
|
|
17
|
+
* fetch in `pinned-fetch.ts` (`@intentface/latch-net/pinned`), which vets
|
|
18
|
+
* addresses inside the lookup the connection itself uses; the checks here
|
|
19
|
+
* remain useful as early, well-messaged pre-flight gates.
|
|
20
|
+
*/
|
|
21
|
+
/** True if a dotted-decimal IPv4 (or a short form like 127.1) is private/loopback/link-local. */
|
|
22
|
+
export function isPrivateV4(ip) {
|
|
23
|
+
const parts = ip.split(".");
|
|
24
|
+
if (parts.length < 2 || parts.length > 4 || !parts.every((p) => /^\d{1,3}$/.test(p)))
|
|
25
|
+
return false;
|
|
26
|
+
const a = Number(parts[0]);
|
|
27
|
+
const b = parts.length >= 2 ? Number(parts[1]) : 0;
|
|
28
|
+
if (a === 127 || a === 10 || a === 0)
|
|
29
|
+
return true; // loopback, private, "this host"
|
|
30
|
+
if (a === 192 && b === 168)
|
|
31
|
+
return true;
|
|
32
|
+
if (a === 169 && b === 254)
|
|
33
|
+
return true; // link-local incl. 169.254.169.254 (cloud metadata)
|
|
34
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
35
|
+
return true;
|
|
36
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
37
|
+
return true; // CGNAT 100.64.0.0/10 (carrier / internal)
|
|
38
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
39
|
+
return true; // benchmarking 198.18.0.0/15
|
|
40
|
+
if (a >= 224)
|
|
41
|
+
return true; // multicast 224/4 + reserved/broadcast 240/4 (incl. 255.255.255.255)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse an IPv6 literal into its 16 bytes — compressed (`::1`), fully expanded
|
|
46
|
+
* (`0:0:0:0:0:0:0:1`), or with an embedded IPv4 tail (`::ffff:127.0.0.1`).
|
|
47
|
+
* Returns null for anything that isn't a valid IPv6 literal. One parser so every
|
|
48
|
+
* spelling (not just `::`-prefixed hex) is classified the same way.
|
|
49
|
+
*/
|
|
50
|
+
export function parseIPv6(input) {
|
|
51
|
+
let h = input.toLowerCase().split("%")[0]; // drop any zone id
|
|
52
|
+
// Rewrite an embedded IPv4 tail (::ffff:127.0.0.1) as its two hextets so the
|
|
53
|
+
// rest parses uniformly as hex groups.
|
|
54
|
+
const dotted = h.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
55
|
+
if (dotted) {
|
|
56
|
+
const o = dotted[1].split(".").map(Number);
|
|
57
|
+
if (o.some((x) => x > 255))
|
|
58
|
+
return null;
|
|
59
|
+
const hextets = `${(((o[0] << 8) | o[1]) >>> 0).toString(16)}:${(((o[2] << 8) | o[3]) >>> 0).toString(16)}`;
|
|
60
|
+
h = h.slice(0, h.length - dotted[1].length) + hextets;
|
|
61
|
+
}
|
|
62
|
+
const halves = h.split("::");
|
|
63
|
+
if (halves.length > 2)
|
|
64
|
+
return null;
|
|
65
|
+
const toBytes = (s) => {
|
|
66
|
+
if (s === "")
|
|
67
|
+
return [];
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const g of s.split(":")) {
|
|
70
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
71
|
+
return null;
|
|
72
|
+
const n = Number.parseInt(g, 16);
|
|
73
|
+
out.push((n >> 8) & 255, n & 255);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const head = toBytes(halves[0]);
|
|
78
|
+
const rest = toBytes(halves[1] ?? "");
|
|
79
|
+
if (!head || !rest)
|
|
80
|
+
return null;
|
|
81
|
+
const known = head.length + rest.length;
|
|
82
|
+
if (halves.length === 2) {
|
|
83
|
+
const gap = 16 - known; // "::" expands to this many zero bytes
|
|
84
|
+
return gap < 0 ? null : [...head, ...Array(gap).fill(0), ...rest];
|
|
85
|
+
}
|
|
86
|
+
return known === 16 ? [...head, ...rest] : null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* True if a 16-byte IPv6 address is unspecified/loopback/ULA/link-local, or an
|
|
90
|
+
* IPv4-mapped/compatible address whose embedded IPv4 is private.
|
|
91
|
+
*/
|
|
92
|
+
export function isPrivateV6(b) {
|
|
93
|
+
if (b.every((x) => x === 0))
|
|
94
|
+
return true; // :: unspecified
|
|
95
|
+
if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1)
|
|
96
|
+
return true; // ::1 loopback
|
|
97
|
+
// IPv4-mapped ::ffff:0:0/96 and IPv4-compatible ::/96 → classify embedded v4.
|
|
98
|
+
const mapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
99
|
+
const compat = b.slice(0, 12).every((x) => x === 0);
|
|
100
|
+
if (mapped || compat)
|
|
101
|
+
return isPrivateV4(b.slice(12).join("."));
|
|
102
|
+
if ((b[0] & 0xfe) === 0xfc)
|
|
103
|
+
return true; // ULA fc00::/7
|
|
104
|
+
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80)
|
|
105
|
+
return true; // link-local fe80::/10
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* String-level blocklist: trailing-dot, bracketed IPv6 (loopback/ULA/link-local
|
|
110
|
+
* + IPv4-mapped/compatible, dotted or hex), bare integer / hex encodings of
|
|
111
|
+
* IPv4 (2130706433, 0x7f000001), short dotted forms (127.1). Best-effort and
|
|
112
|
+
* conservative — strictly more blocking. `isBlockedResolved` backs this up to
|
|
113
|
+
* defeat DNS rebinding.
|
|
114
|
+
*/
|
|
115
|
+
export function isBlockedHost(host) {
|
|
116
|
+
let h = host.toLowerCase().replace(/\.$/, "");
|
|
117
|
+
if (h.startsWith("[") && h.endsWith("]"))
|
|
118
|
+
h = h.slice(1, -1); // unwrap IPv6 literal
|
|
119
|
+
// `.localhost` subdomains are reserved to resolve loopback (RFC 6761).
|
|
120
|
+
if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h === "::1" || h === "::")
|
|
121
|
+
return true;
|
|
122
|
+
if (h.includes(":")) {
|
|
123
|
+
// Parse the full IPv6 literal (any spelling) and classify its bytes. A
|
|
124
|
+
// non-parseable colon-host falls through to false (names hit the resolver).
|
|
125
|
+
const bytes = parseIPv6(h);
|
|
126
|
+
return bytes ? isPrivateV6(bytes) : false;
|
|
127
|
+
}
|
|
128
|
+
if (/^\d+$/.test(h) || /^0x[0-9a-f]+$/.test(h))
|
|
129
|
+
return true; // bare int / hex = encoded IPv4
|
|
130
|
+
return isPrivateV4(h);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* DNS-rebinding backstop: resolve the hostname and reject if ANY resolved
|
|
134
|
+
* address is private/loopback/link-local. The string check above cannot catch
|
|
135
|
+
* `http://public-name/` that resolves to 169.254.169.254 — this does.
|
|
136
|
+
*/
|
|
137
|
+
export async function isBlockedResolved(hostname) {
|
|
138
|
+
if (isIP(hostname))
|
|
139
|
+
return false; // literal IPs already handled by isBlockedHost
|
|
140
|
+
try {
|
|
141
|
+
const addrs = await lookup(hostname, { all: true });
|
|
142
|
+
return addrs.some(({ address, family }) => {
|
|
143
|
+
if (family === 4)
|
|
144
|
+
return isPrivateV4(address);
|
|
145
|
+
const bytes = parseIPv6(address); // same classifier as the literal path
|
|
146
|
+
return bytes ? isPrivateV6(bytes) : false;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return true; // fail closed — a lookup error must not open the gate (a
|
|
151
|
+
// rebinding/flaky resolver could otherwise slip a private target through)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Convenience for callers that want one hard gate: parse `input`, then reject a
|
|
156
|
+
* private/internal literal (`isBlockedHost`) or a name that resolves private
|
|
157
|
+
* (`isBlockedResolved`). Returns the parsed `URL` on success; throws otherwise.
|
|
158
|
+
*/
|
|
159
|
+
export async function assertPublicUrl(input) {
|
|
160
|
+
let url;
|
|
161
|
+
try {
|
|
162
|
+
url = typeof input === "string" ? new URL(input) : input;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error(`Invalid URL: ${String(input)}`);
|
|
166
|
+
}
|
|
167
|
+
const host = url.hostname;
|
|
168
|
+
if (isBlockedHost(host) || (await isBlockedResolved(host))) {
|
|
169
|
+
throw new Error(`Blocked private/internal address: ${host}`);
|
|
170
|
+
}
|
|
171
|
+
return url;
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=ssrf.js.map
|
package/dist/ssrf.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../src/ssrf.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@intentface/latch-net",
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Network-safety primitives for Latch — SSRF guards (private-IP blocklist + DNS-rebinding backstop) and a DNS-pinned egress fetch, shared by every host that opens an outbound connection.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"ai-sdk",
|
|
8
|
+
"agents",
|
|
9
|
+
"llm",
|
|
10
|
+
"ssrf",
|
|
11
|
+
"fetch",
|
|
12
|
+
"security"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"module": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./pinned": {
|
|
25
|
+
"types": "./dist/pinned-fetch.d.ts",
|
|
26
|
+
"import": "./dist/pinned-fetch.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.18.1"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"undici": "^7.28.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22",
|
|
45
|
+
"typescript": "^5.7.0",
|
|
46
|
+
"vitest": "^4"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/Intentface/intentface-latch.git",
|
|
51
|
+
"directory": "packages/net"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc -p tsconfig.json",
|
|
55
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
56
|
+
"test": "vitest run"
|
|
57
|
+
}
|
|
58
|
+
}
|