Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"axios": "^1.18.1",
"countly-sdk-nodejs": "^24.10.4",
"dotenv": "^17.4.2"
"dotenv": "^17.4.2",
"ipaddr.js": "^1.9.1"
},
"devDependencies": {
"@types/node": "^20.19.43",
Expand Down
137 changes: 93 additions & 44 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* Pure functions for processing and validating configuration
*/

import { isIP } from 'node:net';
import ipaddr from 'ipaddr.js';

export interface CountlyConfig {
serverUrl: string;
timeout?: number;
Expand Down Expand Up @@ -78,6 +81,60 @@ export function validateServerUrl(url: string): boolean {
}
}

/**
* Hostnames known to serve cloud-metadata / internal orchestration services.
* Matched case-insensitively as an exact hostname. Kept in sync with the
* countly-server `api/utils/ssrf-protection.js` blocklist.
*/
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog',
'metadata.google.com',
'kubernetes.default.svc',
'kubernetes.default',
'kubernetes',
]);

/**
* Classify a literal IP (v4 or v6) using ipaddr.js range() detection and
* decide whether it must be blocked for SSRF safety. Only globally-routable
* `unicast` addresses are considered safe; every other range (loopback,
* private, link-local, unique-local, carrier-grade NAT, multicast, reserved,
* unspecified, broadcast, NAT64, …) is blocked.
*
* IPv4-mapped IPv6 (`::ffff:a.b.c.d`, e.g. `::ffff:127.0.0.1`) is unwrapped to
* its embedded IPv4 address and re-classified, closing the representation
* bypass where a mapped address is routed to IPv4 loopback/metadata by the OS
* but slips past naive string/prefix checks.
*
* Returns a human-readable reason when the IP is unsafe, or null when safe.
*/
function classifyIpLiteral(ip: string, original: string): string | null {
let parsed: ipaddr.IPv4 | ipaddr.IPv6;
try {
parsed = ipaddr.parse(ip);
} catch {
// Unparseable despite net.isIP accepting it — refuse to be safe.
return `IP literal "${original}" could not be classified`;
}

let range = parsed.range();

// Unwrap IPv4-mapped IPv6 (::ffff:0:0/96) and classify the inner IPv4 so
// e.g. ::ffff:127.0.0.1 is treated as 127.0.0.1 (loopback).
if (
parsed.kind() === 'ipv6' &&
(parsed as ipaddr.IPv6).isIPv4MappedAddress()
) {
range = (parsed as ipaddr.IPv6).toIPv4Address().range();
}

if (range !== 'unicast') {
return `IP "${original}" is in a non-public range (${range}) and is not allowed`;
}
return null;
}

/**
* Test whether a hostname or literal IP resolves to a "sensitive" network
* target that the MCP server must refuse to call. This is an SSRF mitigation
Expand All @@ -95,50 +152,35 @@ export function assertSafeServerHost(hostname: string): string | null {
if (!hostname) {
return 'hostname is empty';
}
const lower = hostname.toLowerCase();

// Block bare localhost aliases and mDNS names
if (lower === 'localhost' || lower.endsWith('.localhost') || lower.endsWith('.local')) {
return `hostname "${hostname}" points at the local machine`;
}

// Block IPv6 loopback / link-local / unique-local / unspecified
if (lower === '::1' || lower === '[::1]' || lower === '::' || lower === '[::]') {
return `IPv6 loopback/unspecified address "${hostname}" is not allowed`;
}
// IPv6 link-local (fe80::/10) and unique-local (fc00::/7) — strip brackets first
const stripped = lower.replace(/^\[|\]$/g, '');
if (stripped.startsWith('fe8') || stripped.startsWith('fe9') || stripped.startsWith('fea') || stripped.startsWith('feb') ||
stripped.startsWith('fc') || stripped.startsWith('fd')) {
return `IPv6 private/link-local address "${hostname}" is not allowed`;
}

// Parse IPv4 literal if present
const ipv4Match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(lower);
if (ipv4Match) {
const [a, b] = ipv4Match.slice(1).map(n => parseInt(n, 10));
if (a === 0) {
return `IPv4 "${hostname}" in 0.0.0.0/8 (unspecified) is not allowed`;
}
if (a === 10) {
return `IPv4 "${hostname}" in 10.0.0.0/8 (private) is not allowed`;
}
if (a === 127) {
return `IPv4 "${hostname}" in 127.0.0.0/8 (loopback) is not allowed`;
}
if (a === 169 && b === 254) {
// 169.254.0.0/16: link-local; also covers AWS IMDS (169.254.169.254)
return `IPv4 "${hostname}" in 169.254.0.0/16 (link-local, includes cloud metadata) is not allowed`;
}
if (a === 172 && b >= 16 && b <= 31) {
return `IPv4 "${hostname}" in 172.16.0.0/12 (private) is not allowed`;
}
if (a === 192 && b === 168) {
return `IPv4 "${hostname}" in 192.168.0.0/16 (private) is not allowed`;
}
if (a === 100 && b >= 64 && b <= 127) {
return `IPv4 "${hostname}" in 100.64.0.0/10 (shared carrier-grade NAT) is not allowed`;
}

// URL parsers hand back bracketed IPv6 literals ("[::1]"); strip the
// brackets so net.isIP / ipaddr.js can classify the address.
let host = hostname;
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
const lower = host.toLowerCase();

// Block bare localhost aliases, mDNS, and internal orchestration TLDs
if (
lower === 'localhost' ||
lower.endsWith('.localhost') ||
lower.endsWith('.local') ||
lower.endsWith('.internal')
) {
return `hostname "${hostname}" points at the local machine or an internal service`;
}

// Block known cloud-metadata / internal-service hostnames
if (BLOCKED_HOSTNAMES.has(lower)) {
return `hostname "${hostname}" is a blocked internal/metadata service`;
}

// If the host is a literal IP (v4 or v6, in any representation), classify it
// with ipaddr.js. This covers dotted-quad, integer/hex-collapsed forms that
// Node's URL parser already normalizes, full IPv6, and IPv4-mapped IPv6.
if (isIP(lower)) {
return classifyIpLiteral(lower, hostname);
}

return null;
Expand All @@ -160,6 +202,13 @@ export function assertSafeServerUrl(url: string): void {
`Refusing server URL with scheme "${parsed.protocol}" — only http:/https: are allowed.`
);
}
// Reject embedded credentials (user:pass@host) — they are never needed for a
// Countly server URL and are a common SSRF/credential-smuggling vector.
if (parsed.username || parsed.password) {
throw new Error(
`Refusing server URL "${url}" for SSRF safety: embedded credentials are not allowed.`
);
}
const reason = assertSafeServerHost(parsed.hostname);
if (reason) {
throw new Error(
Expand Down
77 changes: 67 additions & 10 deletions tests/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,28 +38,28 @@ describe('assertSafeServerHost: SSRF denylist', () => {
});

it('rejects AWS / cloud metadata endpoint 169.254.169.254', () => {
expect(assertSafeServerHost('169.254.169.254')).toMatch(/169\.254/);
expect(assertSafeServerHost('169.254.169.254')).toMatch(/linkLocal/);
});

it('rejects RFC 1918 10/8', () => {
expect(assertSafeServerHost('10.0.0.1')).toMatch(/10\.0\.0\.0\/8/);
expect(assertSafeServerHost('10.0.0.1')).toMatch(/private/);
});

it('rejects RFC 1918 172.16/12', () => {
expect(assertSafeServerHost('172.16.0.1')).toMatch(/172\.16/);
expect(assertSafeServerHost('172.31.255.254')).toMatch(/172\.16/);
expect(assertSafeServerHost('172.16.0.1')).toMatch(/private/);
expect(assertSafeServerHost('172.31.255.254')).toMatch(/private/);
});

it('accepts a public IPv4 (1.1.1.1)', () => {
expect(assertSafeServerHost('1.1.1.1')).toBeNull();
});

it('rejects RFC 1918 192.168/16', () => {
expect(assertSafeServerHost('192.168.1.1')).toMatch(/192\.168/);
expect(assertSafeServerHost('192.168.1.1')).toMatch(/private/);
});

it('rejects carrier-grade NAT 100.64/10', () => {
expect(assertSafeServerHost('100.64.0.1')).toMatch(/100\.64/);
expect(assertSafeServerHost('100.64.0.1')).toMatch(/carrierGradeNat/);
});

it('rejects 0.0.0.0/8', () => {
Expand All @@ -74,16 +74,25 @@ describe('assertSafeServerHost: SSRF denylist', () => {
expect(assertSafeServerHost('my-countly.local')).toMatch(/local machine/);
});

it('rejects .internal hostnames', () => {
expect(assertSafeServerHost('foo.internal')).toMatch(/internal service/);
});

it('rejects known cloud-metadata hostnames', () => {
expect(assertSafeServerHost('metadata.google.internal')).toBeTruthy();
expect(assertSafeServerHost('kubernetes.default.svc')).toMatch(/metadata service/);
});

it('rejects IPv6 loopback', () => {
expect(assertSafeServerHost('::1')).toMatch(/loopback/);
});

it('rejects IPv6 link-local fe80::', () => {
expect(assertSafeServerHost('fe80::1')).toMatch(/link-local/);
expect(assertSafeServerHost('fe80::1')).toMatch(/linkLocal/);
});

it('rejects IPv6 unique-local fd00::', () => {
expect(assertSafeServerHost('fd00::1')).toMatch(/link-local/);
expect(assertSafeServerHost('fd00::1')).toMatch(/uniqueLocal/);
});

it('accepts api.count.ly (normal case)', () => {
Expand All @@ -93,6 +102,35 @@ describe('assertSafeServerHost: SSRF denylist', () => {
it('accepts public IPv4 like 1.1.1.1', () => {
expect(assertSafeServerHost('1.1.1.1')).toBeNull();
});

// ---- Regression: IPv4-mapped IPv6 representation bypass ----
// https://github.com/Countly/countly-mcp-server — the previous string/regex
// guard let `::ffff:127.0.0.1` (which the OS routes to IPv4 loopback) slip
// through because its normalized form `::ffff:7f00:1` matched neither the
// dotted-quad IPv4 regex nor the blocked IPv6 prefixes.
it('rejects IPv4-mapped IPv6 loopback (::ffff:127.0.0.1)', () => {
expect(assertSafeServerHost('::ffff:127.0.0.1')).toMatch(/not allowed/);
});

it('rejects IPv4-mapped IPv6 cloud metadata (::ffff:169.254.169.254)', () => {
expect(assertSafeServerHost('::ffff:169.254.169.254')).toMatch(/not allowed/);
});

it('rejects bracketed IPv4-mapped IPv6 loopback ([::ffff:127.0.0.1])', () => {
expect(assertSafeServerHost('[::ffff:127.0.0.1]')).toMatch(/not allowed/);
});

it('rejects the hex-collapsed IPv4-mapped form (::ffff:7f00:1)', () => {
expect(assertSafeServerHost('::ffff:7f00:1')).toMatch(/not allowed/);
});

it('rejects IPv4-mapped RFC1918 (::ffff:10.0.0.1)', () => {
expect(assertSafeServerHost('::ffff:10.0.0.1')).toMatch(/not allowed/);
});

it('accepts an IPv4-mapped public address (::ffff:1.1.1.1)', () => {
expect(assertSafeServerHost('::ffff:1.1.1.1')).toBeNull();
});
});

describe('assertSafeServerUrl: full URL validation', () => {
Expand Down Expand Up @@ -122,12 +160,31 @@ describe('assertSafeServerUrl: full URL validation', () => {
expect(() => assertSafeServerUrl('http://localhost/')).toThrow(/SSRF/);
});

// ---- Regression: end-to-end URL bypass via IPv4-mapped IPv6 ----
it('throws on IPv4-mapped IPv6 loopback URL', () => {
expect(() =>
assertSafeServerUrl('http://[::ffff:127.0.0.1]:8080')
).toThrow(/SSRF/);
});

it('throws on IPv4-mapped IPv6 cloud-metadata URL', () => {
expect(() =>
assertSafeServerUrl('http://[::ffff:169.254.169.254]/latest/meta-data/')
).toThrow(/SSRF/);
});

it('rejects URLs with embedded credentials', () => {
expect(() =>
assertSafeServerUrl('http://user:pass@api.count.ly')
).toThrow(/credentials/);
});

it('accepts a normal Countly URL', () => {
expect(() => assertSafeServerUrl('https://api.count.ly')).not.toThrow();
});

it('accepts an on-prem Countly on a public IP', () => {
expect(() => assertSafeServerUrl('https://203.0.113.10')).not.toThrow();
it('accepts an on-prem Countly on a routable public IP', () => {
expect(() => assertSafeServerUrl('https://8.8.8.8')).not.toThrow();
});
});

Expand Down
Loading