Skip to content

Commit 6416ba1

Browse files
refactor(local-mcp): share the private-IPv4 range table
isPrivateHostname (core) and web-fetch's isBlockedHost (harness) each hand-rolled the same RFC1918/link-local/CGNAT/loopback IPv4 octet-range table. core can't import harness or node:net, so lifted the pure octet-range predicate into @posthog/shared and had both callers use it instead of maintaining two copies of SSRF-adjacent range logic. isPrivateHostname now also treats 0.0.0.0/8 and 198.18.0.0/15 as private, picking up isBlockedHost's slightly wider range set — consistent with its own "err toward private" design.
1 parent a6d45ea commit 6416ba1

5 files changed

Lines changed: 57 additions & 22 deletions

File tree

packages/core/src/local-mcp/localMcpImport.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
CloudMcpServerImport,
33
LocalMcpServerDescriptor,
44
} from "@posthog/shared";
5+
import { isPrivateIpv4Octets } from "@posthog/shared";
56
import { inject, injectable } from "inversify";
67
import { LOCAL_MCP_WORKSPACE_CLIENT } from "./identifiers";
78

@@ -51,14 +52,6 @@ function parseIpv4(host: string): [number, number, number, number] | null {
5152
return octets as [number, number, number, number];
5253
}
5354

54-
function isPrivateIpv4([a, b]: [number, number, number, number]): boolean {
55-
if (a === 0 || a === 10 || a === 127) return true;
56-
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT, incl. Tailscale IPs
57-
if (a === 169 && b === 254) return true; // link-local
58-
if (a === 172 && b >= 16 && b <= 31) return true;
59-
return a === 192 && b === 168;
60-
}
61-
6255
const PRIVATE_HOST_SUFFIXES = [
6356
".local",
6457
".localhost",
@@ -86,14 +79,14 @@ export function isPrivateHostname(hostname: string): boolean {
8679
const v4Mapped = host.match(/^::ffff:(.+)$/)?.[1];
8780
if (v4Mapped) {
8881
const octets = parseIpv4(v4Mapped);
89-
return octets ? isPrivateIpv4(octets) : false;
82+
return octets ? isPrivateIpv4Octets(octets[0], octets[1]) : false;
9083
}
9184
// fc00::/7 unique-local, fe80::/10 link-local
9285
return /^(f[cd]|fe[89ab])/.test(host);
9386
}
9487

9588
const octets = parseIpv4(host);
96-
if (octets) return isPrivateIpv4(octets);
89+
if (octets) return isPrivateIpv4Octets(octets[0], octets[1]);
9790

9891
// Bare intranet names ("nas", "router") only resolve on the local network.
9992
if (!host.includes(".")) return true;

packages/harness/src/extensions/web-access/web-fetch.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isIP } from "node:net";
22
import { defineTool } from "@earendil-works/pi-coding-agent";
3+
import { isPrivateIpv4Octets } from "@posthog/shared";
34
import { LRUCache } from "lru-cache";
45
import TurndownService from "turndown";
56
import { Type } from "typebox";
@@ -70,18 +71,7 @@ function isBlockedHost(rawHostname: string): boolean {
7071
if (lower === "localhost" || lower.endsWith(".localhost")) return true;
7172

7273
const v4 = ipv4Octets(hostname);
73-
if (v4) {
74-
const [a, b] = v4;
75-
if (a === 127) return true; // loopback (127.0.0.0/8)
76-
if (a === 10) return true; // private (10.0.0.0/8)
77-
if (a === 0) return true; // "this network" (0.0.0.0/8)
78-
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata (169.254.0.0/16)
79-
if (a === 172 && b >= 16 && b <= 31) return true; // private (172.16.0.0/12)
80-
if (a === 192 && b === 168) return true; // private (192.168.0.0/16)
81-
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT (100.64.0.0/10)
82-
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking (198.18.0.0/15)
83-
return false;
84-
}
74+
if (v4) return isPrivateIpv4Octets(v4[0], v4[1]);
8575

8676
if (isIP(hostname) === 6) {
8777
if (lower === "::1") return true; // loopback

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export {
165165
pathToFileUri,
166166
toRelativePath,
167167
} from "./path";
168+
export { isPrivateIpv4Octets } from "./private-network";
168169
export {
169170
buildPrOutput,
170171
mergePrUrls,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isPrivateIpv4Octets } from "./private-network";
3+
4+
describe("isPrivateIpv4Octets", () => {
5+
it.each([
6+
[0, 1, "this network"],
7+
[127, 0, "loopback"],
8+
[10, 0, "RFC1918 10/8"],
9+
[172, 16, "RFC1918 172.16/12 lower bound"],
10+
[172, 31, "RFC1918 172.16/12 upper bound"],
11+
[192, 168, "RFC1918 192.168/16"],
12+
[169, 254, "link-local"],
13+
[100, 64, "CGNAT lower bound"],
14+
[100, 127, "CGNAT upper bound"],
15+
[198, 18, "benchmarking lower bound"],
16+
[198, 19, "benchmarking upper bound"],
17+
])("treats %d.%d.x.x (%s) as private", (a, b) => {
18+
expect(isPrivateIpv4Octets(a, b)).toBe(true);
19+
});
20+
21+
it.each([
22+
[8, 8, "public DNS"],
23+
[1, 1, "public DNS"],
24+
[172, 15, "just below RFC1918 172.16/12"],
25+
[172, 32, "just above RFC1918 172.16/12"],
26+
[100, 63, "just below CGNAT"],
27+
[100, 128, "just above CGNAT"],
28+
[198, 17, "just below benchmarking"],
29+
[198, 20, "just above benchmarking"],
30+
])("treats %d.%d.x.x (%s) as public", (a, b) => {
31+
expect(isPrivateIpv4Octets(a, b)).toBe(false);
32+
});
33+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Whether a parsed IPv4 address falls in a non-public range: loopback
3+
* (127/8), "this network" (0/8), RFC1918 private space (10/8, 172.16/12,
4+
* 192.168/16), link-local (169.254/16, including the cloud metadata
5+
* endpoint), carrier-grade NAT (100.64/10, which also covers Tailscale IPs),
6+
* or the benchmarking range (198.18/15). Shared by every private/public host
7+
* classifier in this monorepo so the range table can't drift between them —
8+
* see `@posthog/core`'s `isPrivateHostname` and the web-fetch tool's
9+
* `isBlockedHost`.
10+
*/
11+
export function isPrivateIpv4Octets(a: number, b: number): boolean {
12+
if (a === 0 || a === 10 || a === 127) return true;
13+
if (a === 100 && b >= 64 && b <= 127) return true;
14+
if (a === 169 && b === 254) return true;
15+
if (a === 172 && b >= 16 && b <= 31) return true;
16+
if (a === 192 && b === 168) return true;
17+
return a === 198 && (b === 18 || b === 19);
18+
}

0 commit comments

Comments
 (0)