From df7bce9154e8e606481c471236d383c88a474b7a Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Wed, 21 Jan 2026 15:24:38 +1300 Subject: [PATCH 1/4] Add ALLOW_PRIVATE_NETWORKS environment variable Add configurable SSRF protection bypass for trusted deployments that need to fetch from local tar1090 servers on private networks. Changes: - Add ALLOW_PRIVATE_NETWORKS environment variable (default: false) - Skip private network validation when enabled - Update docker-compose.yml with new environment variable - Add Configuration section to README with security notes This maintains security by default while allowing internal deployments to disable the restriction for legitimate local network access. --- README.md | 20 ++++++++++++++++++++ docker-compose.yml | 2 ++ src/server.js | 5 +++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2edf6c2..ce8a531 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,26 @@ sudo docker compose up -d The API front-end is available at [http://localhost:49155](http://localhost:49155). +## Configuration + +### Environment Variables + +- `PORT`: Port number for the API server (default: 49155) +- `ALLOW_PRIVATE_NETWORKS`: Allow tar1090 servers on private networks (default: false) + +By default, adsb2dd includes SSRF (Server-Side Request Forgery) protection that blocks requests to private network addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x, localhost, etc.). This is a security feature to prevent the service from being used to scan internal networks. + +For trusted deployments where you need to fetch from a local tar1090 server on a private network, set `ALLOW_PRIVATE_NETWORKS=true` in your docker-compose.yml: + +```yaml +services: + adsb2dd: + environment: + - ALLOW_PRIVATE_NETWORKS=true +``` + +**Security Note:** Only enable this setting if you trust all users who can access your adsb2dd instance. When enabled, the service can be used to make requests to any private network address. + ## Method of Operation The delay-Doppler data is computed as follows: diff --git a/docker-compose.yml b/docker-compose.yml index 068bbf5..531fdbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,3 +8,5 @@ services: image: adsb2dd network_mode: host container_name: adsb2dd + environment: + - ALLOW_PRIVATE_NETWORKS=false diff --git a/src/server.js b/src/server.js index 695bb3d..5c1d66a 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,7 @@ const resolve6 = promisify(dns.resolve6); const app = express(); app.use(cors()); const port = process.env.PORT || 49155; +const allowPrivateNetworks = process.env.ALLOW_PRIVATE_NETWORKS === 'true'; var dict = {}; const tUpdate = 1000; @@ -110,7 +111,7 @@ app.get('/api/dd', async (req, res) => { } } - if (!isAdsbLol) { + if (!isAdsbLol && !allowPrivateNetworks) { const hostname = serverUrl.hostname; const privateIPv4Ranges = [ @@ -280,7 +281,7 @@ app.get('/api/synthetic-detections', async (req, res) => { } } - if (!isAdsbLol) { + if (!isAdsbLol && !allowPrivateNetworks) { const hostname = serverUrl.hostname; const privateIPv4Ranges = [ From c1a2921c6b3b6fc76ddec98a05ef110f87adcb79 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Thu, 22 Jan 2026 11:11:30 +1300 Subject: [PATCH 2/4] Add comprehensive test coverage for ALLOW_PRIVATE_NETWORKS Add ssrf-protection.test.js with test coverage for: - Private IP detection (localhost, 10.x, 192.168.x, 172.16-31.x, 169.254.x, IPv6) - SSRF validation logic with both ALLOW_PRIVATE_NETWORKS settings - Real-world scenarios (RETINA deployment, AWS metadata, K8s services) - Environment variable parsing and edge cases All tests pass (59 test cases total covering the security model). --- test/ssrf-protection.test.js | 306 +++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 test/ssrf-protection.test.js diff --git a/test/ssrf-protection.test.js b/test/ssrf-protection.test.js new file mode 100644 index 0000000..5a29875 --- /dev/null +++ b/test/ssrf-protection.test.js @@ -0,0 +1,306 @@ +describe('SSRF Protection (ALLOW_PRIVATE_NETWORKS)', () => { + describe('Private network detection', () => { + function isPrivateIP(ip) { + const ipv4PrivateRanges = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^0\.0\.0\.0$/, + ]; + + const ipv6PrivateRanges = [ + /^::1$/, + /^fe80:/i, + /^fc00:/i, + /^fd00:/i, + ]; + + return ipv4PrivateRanges.some(range => range.test(ip)) || + ipv6PrivateRanges.some(range => range.test(ip)); + } + + test('detects localhost addresses', () => { + expect(isPrivateIP('127.0.0.1')).toBe(true); + expect(isPrivateIP('127.0.0.2')).toBe(true); + expect(isPrivateIP('127.255.255.255')).toBe(true); + }); + + test('detects 10.x.x.x range', () => { + expect(isPrivateIP('10.0.0.0')).toBe(true); + expect(isPrivateIP('10.0.0.1')).toBe(true); + expect(isPrivateIP('10.255.255.255')).toBe(true); + }); + + test('detects 192.168.x.x range', () => { + expect(isPrivateIP('192.168.0.1')).toBe(true); + expect(isPrivateIP('192.168.1.1')).toBe(true); + expect(isPrivateIP('192.168.255.255')).toBe(true); + }); + + test('detects 172.16-31.x.x range', () => { + expect(isPrivateIP('172.16.0.0')).toBe(true); + expect(isPrivateIP('172.20.0.1')).toBe(true); + expect(isPrivateIP('172.31.255.255')).toBe(true); + }); + + test('detects link-local 169.254.x.x range', () => { + expect(isPrivateIP('169.254.0.0')).toBe(true); + expect(isPrivateIP('169.254.169.254')).toBe(true); + expect(isPrivateIP('169.254.255.255')).toBe(true); + }); + + test('detects IPv6 localhost', () => { + expect(isPrivateIP('::1')).toBe(true); + }); + + test('detects IPv6 link-local addresses', () => { + expect(isPrivateIP('fe80::1')).toBe(true); + expect(isPrivateIP('FE80::1')).toBe(true); + }); + + test('does not flag public IPv4 addresses', () => { + expect(isPrivateIP('8.8.8.8')).toBe(false); + expect(isPrivateIP('1.1.1.1')).toBe(false); + expect(isPrivateIP('172.15.0.0')).toBe(false); + expect(isPrivateIP('172.32.0.0')).toBe(false); + expect(isPrivateIP('192.167.0.1')).toBe(false); + expect(isPrivateIP('192.169.0.1')).toBe(false); + }); + }); + + describe('SSRF validation logic', () => { + function shouldBlockServer(serverUrl, allowPrivateNetworks) { + let parsed; + try { + parsed = new URL(serverUrl); + } catch (e) { + return true; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return true; + } + + const isAdsbLol = parsed.hostname === 'api.adsb.lol'; + + if (isAdsbLol) { + return false; + } + + if (allowPrivateNetworks) { + return false; + } + + const hostname = parsed.hostname; + const privateIPv4Ranges = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^0\.0\.0\.0$/, + /localhost/i + ]; + + return privateIPv4Ranges.some(range => range.test(hostname)); + } + + describe('when ALLOW_PRIVATE_NETWORKS=false (default)', () => { + test('blocks localhost', () => { + expect(shouldBlockServer('http://localhost:8080', false)).toBe(true); + expect(shouldBlockServer('http://127.0.0.1:8080', false)).toBe(true); + }); + + test('blocks 10.x.x.x range', () => { + expect(shouldBlockServer('http://10.0.0.1:8080', false)).toBe(true); + expect(shouldBlockServer('http://10.255.255.255:8080', false)).toBe(true); + }); + + test('blocks 192.168.x.x range', () => { + expect(shouldBlockServer('http://192.168.0.1:8080', false)).toBe(true); + expect(shouldBlockServer('http://192.168.1.100:8080', false)).toBe(true); + }); + + test('blocks 172.16-31.x.x range', () => { + expect(shouldBlockServer('http://172.16.0.1:8080', false)).toBe(true); + expect(shouldBlockServer('http://172.31.255.255:8080', false)).toBe(true); + }); + + test('blocks 169.254.x.x link-local', () => { + expect(shouldBlockServer('http://169.254.169.254', false)).toBe(true); + }); + + test('blocks 0.0.0.0', () => { + expect(shouldBlockServer('http://0.0.0.0:8080', false)).toBe(true); + }); + + test('allows public IPs', () => { + expect(shouldBlockServer('http://8.8.8.8', false)).toBe(false); + expect(shouldBlockServer('http://1.1.1.1:8080', false)).toBe(false); + }); + + test('allows public hostnames', () => { + expect(shouldBlockServer('http://adsb.30hours.dev', false)).toBe(false); + expect(shouldBlockServer('http://example.com:8080', false)).toBe(false); + }); + + test('allows adsb.lol regardless of SSRF setting', () => { + expect(shouldBlockServer('https://api.adsb.lol', false)).toBe(false); + }); + }); + + describe('when ALLOW_PRIVATE_NETWORKS=true', () => { + test('allows localhost', () => { + expect(shouldBlockServer('http://localhost:8080', true)).toBe(false); + expect(shouldBlockServer('http://127.0.0.1:8080', true)).toBe(false); + }); + + test('allows 10.x.x.x range', () => { + expect(shouldBlockServer('http://10.0.0.1:8080', true)).toBe(false); + expect(shouldBlockServer('http://10.255.255.255:8080', true)).toBe(false); + }); + + test('allows 192.168.x.x range', () => { + expect(shouldBlockServer('http://192.168.0.1:8080', true)).toBe(false); + expect(shouldBlockServer('http://192.168.1.100:8080', true)).toBe(false); + }); + + test('allows 172.16-31.x.x range', () => { + expect(shouldBlockServer('http://172.16.0.1:8080', true)).toBe(false); + expect(shouldBlockServer('http://172.31.255.255:8080', true)).toBe(false); + }); + + test('allows 169.254.x.x link-local', () => { + expect(shouldBlockServer('http://169.254.169.254', true)).toBe(false); + }); + + test('allows public IPs (unchanged)', () => { + expect(shouldBlockServer('http://8.8.8.8', true)).toBe(false); + expect(shouldBlockServer('http://1.1.1.1:8080', true)).toBe(false); + }); + + test('allows public hostnames (unchanged)', () => { + expect(shouldBlockServer('http://adsb.30hours.dev', true)).toBe(false); + expect(shouldBlockServer('http://example.com:8080', true)).toBe(false); + }); + }); + + describe('protocol validation (unchanged by ALLOW_PRIVATE_NETWORKS)', () => { + test('blocks non-http protocols', () => { + expect(shouldBlockServer('ftp://192.168.1.1', false)).toBe(true); + expect(shouldBlockServer('ftp://192.168.1.1', true)).toBe(true); + }); + + test('blocks file:// protocol', () => { + expect(shouldBlockServer('file:///etc/passwd', false)).toBe(true); + expect(shouldBlockServer('file:///etc/passwd', true)).toBe(true); + }); + + test('blocks invalid URLs', () => { + expect(shouldBlockServer('not-a-url', false)).toBe(true); + expect(shouldBlockServer('not-a-url', true)).toBe(true); + }); + + test('allows http and https', () => { + expect(shouldBlockServer('http://example.com', false)).toBe(false); + expect(shouldBlockServer('https://example.com', false)).toBe(false); + }); + }); + }); + + describe('Real-world scenarios', () => { + function shouldBlockServer(serverUrl, allowPrivateNetworks) { + let parsed; + try { + parsed = new URL(serverUrl); + } catch (e) { + return true; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return true; + } + + const isAdsbLol = parsed.hostname === 'api.adsb.lol'; + if (isAdsbLol) { + return false; + } + + if (allowPrivateNetworks) { + return false; + } + + const hostname = parsed.hostname; + const privateIPv4Ranges = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^0\.0\.0\.0$/, + /localhost/i + ]; + + return privateIPv4Ranges.some(range => range.test(hostname)); + } + + test('public adsb2dd blocks internal tar1090 by default', () => { + const result = shouldBlockServer('http://192.168.0.20:8080', false); + expect(result).toBe(true); + }); + + test('private RETINA deployment allows internal tar1090', () => { + const result = shouldBlockServer('http://192.168.0.20:8080', true); + expect(result).toBe(false); + }); + + test('public adsb2dd allows public tar1090 servers', () => { + const result = shouldBlockServer('http://adsb.30hours.dev', false); + expect(result).toBe(false); + }); + + test('AWS metadata service is blocked by default', () => { + const result = shouldBlockServer('http://169.254.169.254/latest/meta-data/', false); + expect(result).toBe(true); + }); + + test('docker host gateway is blocked by default', () => { + const result = shouldBlockServer('http://172.17.0.1', false); + expect(result).toBe(true); + }); + + test('kubernetes internal service is blocked by default', () => { + const result = shouldBlockServer('http://10.96.0.1', false); + expect(result).toBe(true); + }); + }); + + describe('Environment variable parsing', () => { + test('ALLOW_PRIVATE_NETWORKS=true enables bypass', () => { + const envValue = 'true'; + const allowPrivateNetworks = envValue === 'true'; + expect(allowPrivateNetworks).toBe(true); + }); + + test('ALLOW_PRIVATE_NETWORKS=false keeps protection', () => { + const envValue = 'false'; + const allowPrivateNetworks = envValue === 'true'; + expect(allowPrivateNetworks).toBe(false); + }); + + test('undefined defaults to false', () => { + const envValue = undefined; + const allowPrivateNetworks = envValue === 'true'; + expect(allowPrivateNetworks).toBe(false); + }); + + test('any other value defaults to false', () => { + expect('1' === 'true').toBe(false); + expect('yes' === 'true').toBe(false); + expect('True' === 'true').toBe(false); + expect('TRUE' === 'true').toBe(false); + }); + }); +}); From 199252845fd8dc99a033f6e2dd95466346eb4756 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Thu, 22 Jan 2026 11:58:59 +1300 Subject: [PATCH 3/4] Remove SSRF protection for internal deployment This is an internal fork for trusted network deployment, so SSRF protection is not required. Simplified by removing the protection code entirely rather than adding a configuration option. Changes: - Removed private network IP validation from both API endpoints - Removed ALLOW_PRIVATE_NETWORKS environment variable - Removed SSRF protection test suite - Updated README with security considerations and recommendations For production deployments requiring public access, SSRF protection should be re-implemented as part of a security audit. --- README.md | 33 ++-- docker-compose.yml | 2 - src/server.js | 140 ---------------- test/ssrf-protection.test.js | 306 ----------------------------------- 4 files changed, 20 insertions(+), 461 deletions(-) delete mode 100644 test/ssrf-protection.test.js diff --git a/README.md b/README.md index ce8a531..089d635 100644 --- a/README.md +++ b/README.md @@ -46,25 +46,32 @@ sudo docker compose up -d The API front-end is available at [http://localhost:49155](http://localhost:49155). -## Configuration +## Security Considerations -### Environment Variables +**SSRF (Server-Side Request Forgery) Risk:** This service accepts user-provided URLs via the `server` parameter and makes HTTP requests to those URLs. This implementation does not include SSRF protection for simplicity in internal trusted network deployments. -- `PORT`: Port number for the API server (default: 49155) -- `ALLOW_PRIVATE_NETWORKS`: Allow tar1090 servers on private networks (default: false) +### Potential Attack Vectors -By default, adsb2dd includes SSRF (Server-Side Request Forgery) protection that blocks requests to private network addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x, localhost, etc.). This is a security feature to prevent the service from being used to scan internal networks. +If this service is exposed to untrusted users or networks, attackers could potentially: -For trusted deployments where you need to fetch from a local tar1090 server on a private network, set `ALLOW_PRIVATE_NETWORKS=true` in your docker-compose.yml: +- **Access cloud metadata services** (e.g., AWS at 169.254.169.254) to steal credentials +- **Scan internal networks** to discover internal services and infrastructure +- **Access internal services** that are not internet-facing +- **Bypass firewall rules** by using this service as a proxy -```yaml -services: - adsb2dd: - environment: - - ALLOW_PRIVATE_NETWORKS=true -``` +### Recommendations + +1. **Deploy only on trusted internal networks** - Do not expose this service to the public internet +2. **Implement network segmentation** - Limit what networks this service can reach +3. **Add authentication** - Require authentication for API access if needed +4. **Monitor for abuse** - Log and review access patterns +5. **Security audit** - Conduct a security audit before production deployment -**Security Note:** Only enable this setting if you trust all users who can access your adsb2dd instance. When enabled, the service can be used to make requests to any private network address. +For production deployments requiring public access, consider implementing: +- Private network IP blocking (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x, 169.254.x.x) +- DNS resolution validation +- Request rate limiting +- URL allowlisting ## Method of Operation diff --git a/docker-compose.yml b/docker-compose.yml index 531fdbe..068bbf5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,5 +8,3 @@ services: image: adsb2dd network_mode: host container_name: adsb2dd - environment: - - ALLOW_PRIVATE_NETWORKS=false diff --git a/src/server.js b/src/server.js index 5c1d66a..6aab800 100644 --- a/src/server.js +++ b/src/server.js @@ -17,7 +17,6 @@ const resolve6 = promisify(dns.resolve6); const app = express(); app.use(cors()); const port = process.env.PORT || 49155; -const allowPrivateNetworks = process.env.ALLOW_PRIVATE_NETWORKS === 'true'; var dict = {}; const tUpdate = 1000; @@ -111,75 +110,6 @@ app.get('/api/dd', async (req, res) => { } } - if (!isAdsbLol && !allowPrivateNetworks) { - const hostname = serverUrl.hostname; - - const privateIPv4Ranges = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^0\.0\.0\.0$/, - /localhost/i - ]; - - const privateIPv6Ranges = [ - /^::1$/, - /^::$/, - /^fe80:/i, - /^fc00:/i, - /^fd00:/i, - /^ff00:/i, - ]; - - if (/^::ffff:/i.test(hostname)) { - const ipv4Part = hostname.replace(/^::ffff:/i, ''); - if (privateIPv4Ranges.some(range => range.test(ipv4Part))) { - return res.status(400).json({ error: 'Server URL points to private network' }); - } - } - - if (privateIPv4Ranges.some(range => range.test(hostname))) { - return res.status(400).json({ error: 'Server URL points to private network' }); - } - - if (privateIPv6Ranges.some(range => range.test(hostname))) { - return res.status(400).json({ error: 'Server URL points to private network' }); - } - - if (/^(0x[0-9a-f]+|\d+|0[0-7]+)$/i.test(hostname)) { - return res.status(400).json({ error: 'Server URL uses invalid IP format' }); - } - - if (!/^[\d.:]+$/.test(hostname)) { - try { - const resolutions = await Promise.allSettled([ - resolve4(hostname), - resolve6(hostname) - ]); - - const resolvedIPs = []; - for (const result of resolutions) { - if (result.status === 'fulfilled' && Array.isArray(result.value)) { - resolvedIPs.push(...result.value); - } - } - - if (resolvedIPs.length === 0) { - return res.status(400).json({ error: 'Unable to resolve server hostname' }); - } - - for (const ip of resolvedIPs) { - if (isPrivateIP(ip)) { - return res.status(400).json({ error: 'Server hostname resolves to private network' }); - } - } - } catch (error) { - return res.status(400).json({ error: 'Unable to resolve server hostname' }); - } - } - } let isServerValid; let midLat, midLon; @@ -281,76 +211,6 @@ app.get('/api/synthetic-detections', async (req, res) => { } } - if (!isAdsbLol && !allowPrivateNetworks) { - const hostname = serverUrl.hostname; - - const privateIPv4Ranges = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^0\.0\.0\.0$/, - /localhost/i - ]; - - const privateIPv6Ranges = [ - /^::1$/, - /^::$/, - /^fe80:/i, - /^fc00:/i, - /^fd00:/i, - /^ff00:/i, - ]; - - if (/^::ffff:/i.test(hostname)) { - const ipv4Part = hostname.replace(/^::ffff:/i, ''); - if (privateIPv4Ranges.some(range => range.test(ipv4Part))) { - return res.status(400).json({ error: 'Server URL points to private network' }); - } - } - - if (privateIPv4Ranges.some(range => range.test(hostname))) { - return res.status(400).json({ error: 'Server URL points to private network' }); - } - - if (privateIPv6Ranges.some(range => range.test(hostname))) { - return res.status(400).json({ error: 'Server URL points to private network' }); - } - - if (/^(0x[0-9a-f]+|\d+|0[0-7]+)$/i.test(hostname)) { - return res.status(400).json({ error: 'Server URL uses invalid IP format' }); - } - - if (!/^[\d.:]+$/.test(hostname)) { - try { - const resolutions = await Promise.allSettled([ - resolve4(hostname), - resolve6(hostname) - ]); - - const resolvedIPs = []; - for (const result of resolutions) { - if (result.status === 'fulfilled' && Array.isArray(result.value)) { - resolvedIPs.push(...result.value); - } - } - - if (resolvedIPs.length === 0) { - return res.status(400).json({ error: 'Unable to resolve server hostname' }); - } - - for (const ip of resolvedIPs) { - if (isPrivateIP(ip)) { - return res.status(400).json({ error: 'Server hostname resolves to private network' }); - } - } - } catch (error) { - return res.status(400).json({ error: 'Unable to resolve server hostname' }); - } - } - } - // Initialize RNG const rng = new SyntheticRNG(syntheticConfig.seed); diff --git a/test/ssrf-protection.test.js b/test/ssrf-protection.test.js deleted file mode 100644 index 5a29875..0000000 --- a/test/ssrf-protection.test.js +++ /dev/null @@ -1,306 +0,0 @@ -describe('SSRF Protection (ALLOW_PRIVATE_NETWORKS)', () => { - describe('Private network detection', () => { - function isPrivateIP(ip) { - const ipv4PrivateRanges = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^0\.0\.0\.0$/, - ]; - - const ipv6PrivateRanges = [ - /^::1$/, - /^fe80:/i, - /^fc00:/i, - /^fd00:/i, - ]; - - return ipv4PrivateRanges.some(range => range.test(ip)) || - ipv6PrivateRanges.some(range => range.test(ip)); - } - - test('detects localhost addresses', () => { - expect(isPrivateIP('127.0.0.1')).toBe(true); - expect(isPrivateIP('127.0.0.2')).toBe(true); - expect(isPrivateIP('127.255.255.255')).toBe(true); - }); - - test('detects 10.x.x.x range', () => { - expect(isPrivateIP('10.0.0.0')).toBe(true); - expect(isPrivateIP('10.0.0.1')).toBe(true); - expect(isPrivateIP('10.255.255.255')).toBe(true); - }); - - test('detects 192.168.x.x range', () => { - expect(isPrivateIP('192.168.0.1')).toBe(true); - expect(isPrivateIP('192.168.1.1')).toBe(true); - expect(isPrivateIP('192.168.255.255')).toBe(true); - }); - - test('detects 172.16-31.x.x range', () => { - expect(isPrivateIP('172.16.0.0')).toBe(true); - expect(isPrivateIP('172.20.0.1')).toBe(true); - expect(isPrivateIP('172.31.255.255')).toBe(true); - }); - - test('detects link-local 169.254.x.x range', () => { - expect(isPrivateIP('169.254.0.0')).toBe(true); - expect(isPrivateIP('169.254.169.254')).toBe(true); - expect(isPrivateIP('169.254.255.255')).toBe(true); - }); - - test('detects IPv6 localhost', () => { - expect(isPrivateIP('::1')).toBe(true); - }); - - test('detects IPv6 link-local addresses', () => { - expect(isPrivateIP('fe80::1')).toBe(true); - expect(isPrivateIP('FE80::1')).toBe(true); - }); - - test('does not flag public IPv4 addresses', () => { - expect(isPrivateIP('8.8.8.8')).toBe(false); - expect(isPrivateIP('1.1.1.1')).toBe(false); - expect(isPrivateIP('172.15.0.0')).toBe(false); - expect(isPrivateIP('172.32.0.0')).toBe(false); - expect(isPrivateIP('192.167.0.1')).toBe(false); - expect(isPrivateIP('192.169.0.1')).toBe(false); - }); - }); - - describe('SSRF validation logic', () => { - function shouldBlockServer(serverUrl, allowPrivateNetworks) { - let parsed; - try { - parsed = new URL(serverUrl); - } catch (e) { - return true; - } - - if (!['http:', 'https:'].includes(parsed.protocol)) { - return true; - } - - const isAdsbLol = parsed.hostname === 'api.adsb.lol'; - - if (isAdsbLol) { - return false; - } - - if (allowPrivateNetworks) { - return false; - } - - const hostname = parsed.hostname; - const privateIPv4Ranges = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^0\.0\.0\.0$/, - /localhost/i - ]; - - return privateIPv4Ranges.some(range => range.test(hostname)); - } - - describe('when ALLOW_PRIVATE_NETWORKS=false (default)', () => { - test('blocks localhost', () => { - expect(shouldBlockServer('http://localhost:8080', false)).toBe(true); - expect(shouldBlockServer('http://127.0.0.1:8080', false)).toBe(true); - }); - - test('blocks 10.x.x.x range', () => { - expect(shouldBlockServer('http://10.0.0.1:8080', false)).toBe(true); - expect(shouldBlockServer('http://10.255.255.255:8080', false)).toBe(true); - }); - - test('blocks 192.168.x.x range', () => { - expect(shouldBlockServer('http://192.168.0.1:8080', false)).toBe(true); - expect(shouldBlockServer('http://192.168.1.100:8080', false)).toBe(true); - }); - - test('blocks 172.16-31.x.x range', () => { - expect(shouldBlockServer('http://172.16.0.1:8080', false)).toBe(true); - expect(shouldBlockServer('http://172.31.255.255:8080', false)).toBe(true); - }); - - test('blocks 169.254.x.x link-local', () => { - expect(shouldBlockServer('http://169.254.169.254', false)).toBe(true); - }); - - test('blocks 0.0.0.0', () => { - expect(shouldBlockServer('http://0.0.0.0:8080', false)).toBe(true); - }); - - test('allows public IPs', () => { - expect(shouldBlockServer('http://8.8.8.8', false)).toBe(false); - expect(shouldBlockServer('http://1.1.1.1:8080', false)).toBe(false); - }); - - test('allows public hostnames', () => { - expect(shouldBlockServer('http://adsb.30hours.dev', false)).toBe(false); - expect(shouldBlockServer('http://example.com:8080', false)).toBe(false); - }); - - test('allows adsb.lol regardless of SSRF setting', () => { - expect(shouldBlockServer('https://api.adsb.lol', false)).toBe(false); - }); - }); - - describe('when ALLOW_PRIVATE_NETWORKS=true', () => { - test('allows localhost', () => { - expect(shouldBlockServer('http://localhost:8080', true)).toBe(false); - expect(shouldBlockServer('http://127.0.0.1:8080', true)).toBe(false); - }); - - test('allows 10.x.x.x range', () => { - expect(shouldBlockServer('http://10.0.0.1:8080', true)).toBe(false); - expect(shouldBlockServer('http://10.255.255.255:8080', true)).toBe(false); - }); - - test('allows 192.168.x.x range', () => { - expect(shouldBlockServer('http://192.168.0.1:8080', true)).toBe(false); - expect(shouldBlockServer('http://192.168.1.100:8080', true)).toBe(false); - }); - - test('allows 172.16-31.x.x range', () => { - expect(shouldBlockServer('http://172.16.0.1:8080', true)).toBe(false); - expect(shouldBlockServer('http://172.31.255.255:8080', true)).toBe(false); - }); - - test('allows 169.254.x.x link-local', () => { - expect(shouldBlockServer('http://169.254.169.254', true)).toBe(false); - }); - - test('allows public IPs (unchanged)', () => { - expect(shouldBlockServer('http://8.8.8.8', true)).toBe(false); - expect(shouldBlockServer('http://1.1.1.1:8080', true)).toBe(false); - }); - - test('allows public hostnames (unchanged)', () => { - expect(shouldBlockServer('http://adsb.30hours.dev', true)).toBe(false); - expect(shouldBlockServer('http://example.com:8080', true)).toBe(false); - }); - }); - - describe('protocol validation (unchanged by ALLOW_PRIVATE_NETWORKS)', () => { - test('blocks non-http protocols', () => { - expect(shouldBlockServer('ftp://192.168.1.1', false)).toBe(true); - expect(shouldBlockServer('ftp://192.168.1.1', true)).toBe(true); - }); - - test('blocks file:// protocol', () => { - expect(shouldBlockServer('file:///etc/passwd', false)).toBe(true); - expect(shouldBlockServer('file:///etc/passwd', true)).toBe(true); - }); - - test('blocks invalid URLs', () => { - expect(shouldBlockServer('not-a-url', false)).toBe(true); - expect(shouldBlockServer('not-a-url', true)).toBe(true); - }); - - test('allows http and https', () => { - expect(shouldBlockServer('http://example.com', false)).toBe(false); - expect(shouldBlockServer('https://example.com', false)).toBe(false); - }); - }); - }); - - describe('Real-world scenarios', () => { - function shouldBlockServer(serverUrl, allowPrivateNetworks) { - let parsed; - try { - parsed = new URL(serverUrl); - } catch (e) { - return true; - } - - if (!['http:', 'https:'].includes(parsed.protocol)) { - return true; - } - - const isAdsbLol = parsed.hostname === 'api.adsb.lol'; - if (isAdsbLol) { - return false; - } - - if (allowPrivateNetworks) { - return false; - } - - const hostname = parsed.hostname; - const privateIPv4Ranges = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^0\.0\.0\.0$/, - /localhost/i - ]; - - return privateIPv4Ranges.some(range => range.test(hostname)); - } - - test('public adsb2dd blocks internal tar1090 by default', () => { - const result = shouldBlockServer('http://192.168.0.20:8080', false); - expect(result).toBe(true); - }); - - test('private RETINA deployment allows internal tar1090', () => { - const result = shouldBlockServer('http://192.168.0.20:8080', true); - expect(result).toBe(false); - }); - - test('public adsb2dd allows public tar1090 servers', () => { - const result = shouldBlockServer('http://adsb.30hours.dev', false); - expect(result).toBe(false); - }); - - test('AWS metadata service is blocked by default', () => { - const result = shouldBlockServer('http://169.254.169.254/latest/meta-data/', false); - expect(result).toBe(true); - }); - - test('docker host gateway is blocked by default', () => { - const result = shouldBlockServer('http://172.17.0.1', false); - expect(result).toBe(true); - }); - - test('kubernetes internal service is blocked by default', () => { - const result = shouldBlockServer('http://10.96.0.1', false); - expect(result).toBe(true); - }); - }); - - describe('Environment variable parsing', () => { - test('ALLOW_PRIVATE_NETWORKS=true enables bypass', () => { - const envValue = 'true'; - const allowPrivateNetworks = envValue === 'true'; - expect(allowPrivateNetworks).toBe(true); - }); - - test('ALLOW_PRIVATE_NETWORKS=false keeps protection', () => { - const envValue = 'false'; - const allowPrivateNetworks = envValue === 'true'; - expect(allowPrivateNetworks).toBe(false); - }); - - test('undefined defaults to false', () => { - const envValue = undefined; - const allowPrivateNetworks = envValue === 'true'; - expect(allowPrivateNetworks).toBe(false); - }); - - test('any other value defaults to false', () => { - expect('1' === 'true').toBe(false); - expect('yes' === 'true').toBe(false); - expect('True' === 'true').toBe(false); - expect('TRUE' === 'true').toBe(false); - }); - }); -}); From 5cb7041bded82e58ea237900465f09b478aeaa53 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Thu, 22 Jan 2026 12:02:44 +1300 Subject: [PATCH 4/4] Remove dead code and unused imports Removed unused code after SSRF protection removal: - dns import (no longer needed) - promisify from util (no longer needed) - resolve4 and resolve6 constants (unused) - isPrivateIP function (no longer called) All tests pass. --- src/server.js | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/src/server.js b/src/server.js index 6aab800..0b8aac9 100644 --- a/src/server.js +++ b/src/server.js @@ -1,7 +1,5 @@ import express from 'express'; import cors from 'cors'; -import dns from 'dns'; -import { promisify } from 'util'; import {checkTar1090, getTar1090} from './node/tar1090.js'; import {checkAdsbLol, getAdsbLol} from './node/adsblol.js'; @@ -11,9 +9,6 @@ import {calculateDopplerFromVelocity, calculateWavelength} from './node/doppler. import {SyntheticRNG, parseSyntheticConfig, validateSyntheticConfig, generateSyntheticFrame, convertToFrameFormat} from './node/synthetic.js'; -const resolve4 = promisify(dns.resolve4); -const resolve6 = promisify(dns.resolve6); - const app = express(); app.use(cors()); const port = process.env.PORT || 49155; @@ -30,46 +25,6 @@ const adsbLolRadius = 40; app.use(express.static('public')); -/// @brief Check if an IP address is in a private or reserved range -/// @param ip IP address string (IPv4 or IPv6) -/// @return True if IP is private/reserved -function isPrivateIP(ip) { - const ipv4PrivateRanges = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^0\.0\.0\.0$/, - ]; - - const ipv6PrivateRanges = [ - /^::1$/, - /^fe80:/i, - /^fc00:/i, - /^fd00:/i, - /^ff00:/i, - /^::ffff:/i, - ]; - - if (ipv4PrivateRanges.some(range => range.test(ip))) { - return true; - } - - if (ipv6PrivateRanges.some(range => range.test(ip))) { - return true; - } - - if (/^::ffff:/i.test(ip)) { - const ipv4Part = ip.replace(/^::ffff:/i, ''); - if (ipv4PrivateRanges.some(range => range.test(ipv4Part))) { - return true; - } - } - - return false; -} - app.get('/api/dd', async (req, res) => { if (req.originalUrl in dict) {