CVE-2023-42282: Server-Side Request Forgery (SSRF) (CWE-918)
value: The ip package before 1.1.9 for Node.js might allow SSRF because some IP addresses (such as 0x7f.1) are improperly categorized as globally routable via isPublic.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 9.8
Published: 2024-02-08T17:15Z
Repository: https://github.com/indutny/node-ip
Language: JavaScript
Files changed: lib/ip.js, test/api-test.js
Commit date: 2024-02-18 20:28:50 -0800
Commit message: lib: fixed CVE-2023-42282 and added unit test

Diff: diff --git a/lib/ip.js b/lib/ip.js
index 5b5ccc2..3f6a522 100644
--- a/lib/ip.js
+++ b/lib/ip.js
@@ -309,16 +309,30 @@ ip.isEqual = function (a, b) {
 
   return true;
 };
 
 ip.isPrivate = function (addr) {
-  return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i
-    .test(addr)
+  // check loopback addresses first
+  if (ip.isLoopback(addr)) {
+    return true;
+  }
+
+  // ensure the ipv4 address is valid
+  if (!ip.isV6Format(addr)) {
+    const ipl = ip.normalizeToLong(addr);
+    if (ipl < 0) {
+      throw new Error('invalid ipv4 address');
+    }
+    // normalize the address for the private range checks that follow
+    addr = ip.fromLong(ipl);
+  }
+
+  // check private ranges
+  return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr)
     || /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr)
     || /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i
       .test(addr)
-    || /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr)
     || /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr)
     || /^f[cd][0-9a-f]{2}:/i.test(addr)
     || /^fe80:/i.test(addr)
     || /^::1$/.test(addr)
     || /^::$/.test(addr);
@@ -327,13 +341,20 @@ ip.isPrivate = function (addr) {
 ip.isPublic = function (addr) {
   return !ip.isPrivate(addr);
 };
 
 ip.isLoopback = function (addr) {
+  // If addr is an IPv4 address in long integer form (no dots and no colons), convert it
+  if (!/\./.test(addr) && !/:/.test(addr)) {
+    addr = ip.fromLong(Number(addr));
+  }
+
   return /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/
     .test(addr)
-    || /^fe80::1$/.test(addr)
+    || /^0177\./.test(addr)
+    || /^0x7f\./i.test(addr)
+    || /^fe80::1$/i.test(addr)
     || /^::1$/.test(addr)
     || /^::$/.test(addr);
 };
 
 ip.loopback = function (family) {
@@ -423,5 +444,53 @@ ip.fromLong = function (ipl) {
   return (`${ipl >>> 24}.${
     ipl >> 16 & 255}.${
     ipl >> 8 & 255}.${
     ipl & 255}`);
 };
+
+ip.normalizeToLong = function (addr) {
+  const parts = addr.split('.').map(part => {
+    // Handle hexadecimal format
+    if (part.startsWith('0x') || part.startsWith('0X')) {
+      return parseInt(part, 16);
+    }
+    // Handle octal format (strictly digits 0-7 after a leading zero)
+    else if (part.startsWith('0') && part !== '0' && /^[0-7]+$/.test(part)) {
+      return parseInt(part, 8);
+    }
+    // Handle decimal format, reject invalid leading zeros
+    else if (/^[1-9]\d*$/.test(part) || part === '0') {
+      return parseInt(part, 10);
+    }
+    // Return NaN for invalid formats to indicate parsing failure
+    else {
+      return NaN;
+    }
+  });
+
+  if (parts.some(isNaN)) return -1; // Indicate error with -1
+
+  let val = 0;
+  const n = parts.length;
+
+  switch (n) {
+  case 1:
+    val = parts[0];
+    break;
+  case 2:
+    if (parts[0] > 0xff || parts[1] > 0xffffff) return -1;
+    val = (parts[0] << 24) | (parts[1] & 0xffffff);
+    break;
+  case 3:
+    if (parts[0] > 0xff || parts[1] > 0xff || parts[2] > 0xffff) return -1;
+    val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] & 0xffff);
+    break;
+  case 4:
+    if (parts.some(part => part > 0xff)) return -1;
+    val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
+    break;
+  default:
+    return -1; // Error case
+  }
+
+  return val >>> 0;
+};
diff --git a/test/api-test.js b/test/api-test.js
index f0fd222..0db838d 100644
--- a/test/api-test.js
+++ b/test/api-test.js
@@ -249,10 +249,61 @@ describe('IP library for node.js', () => {
       assert(ip.isEqual('::ffff:127.0.0.1', '::ffff:127.0.0.1'));
       assert(ip.isEqual('::ffff:127.0.0.1', '127.0.0.1'));
     });
   });
 
+  describe('normalizeIpv4() method', () => {
+    // Testing valid inputs with different notations
+    it('should correctly normalize "127.0.0.1"', () => {
+      assert.equal(ip.normalizeToLong('127.0.0.1'), 2130706433);
+    });
+
+    it('should correctly handle "127.1" as two parts', () => {
+      assert.equal(ip.normalizeToLong('127.1'), 2130706433);
+    });
+
+    it('should correctly handle "127.0.1" as three parts', () => {
+      assert.equal(ip.normalizeToLong('127.0.1'), 2130706433);
+    });
+
+
+    it('should correctly handle hexadecimal notation "0x7f.0x0.0x0.0x1"', () => {
+      assert.equal(ip.normalizeToLong('0x7f.0x0.0x0.0x1'), 2130706433);
+    });
+
+    // Testing with fewer than 4 parts
+    it('should correctly handle "0x7f000001" as a single part', () => {
+      assert.equal(ip.normalizeToLong('0x7f000001'), 2130706433);
+    });
+
+    it('should correctly handle octal notation "010.0.0.01"', () => {
+      assert.equal(ip.normalizeToLong('010.0.0.01'), 134217729);
+    });
+
+    // Testing invalid inputs
+    it('should return -1 for an invalid address "256.100.50.25"', () => {
+      assert.equal(ip.normalizeToLong('256.100.50.25'), -1);
+    });
+
+    it('should return -1 for an address with invalid octal "019.0.0.1"', () => {
+      assert.equal(ip.normalizeToLong('019.0.0.1'), -1);
+    });
+
+    it('should return -1 for an address with invalid hex "0xGG.0.0.1"', () => {
+      assert.equal(ip.normalizeToLong('0xGG.0.0.1'), -1);
+    });
+
+    // Testing edge cases
+    it('should return -1 for an empty string', () => {
+      assert.equal(ip.normalizeToLong(''), -1);
+    });
+
+    it('should return -1 for a string with too many parts "192.168.0.1.100"', () => {
+      assert.equal(ip.normalizeToLong('192.168.0.1.100'), -1);
+    });
+  });
+
   describe('isPrivate() method', () => {
     it('should check if an address is localhost', () => {
       assert.equal(ip.isPrivate('127.0.0.1'), true);
     });
 
@@ -298,10 +349,14 @@ describe('IP library for node.js', () => {
     it('should check if an address is a loopback IPv6 address', () => {
       assert.equal(ip.isPrivate('::'), true);
       assert.equal(ip.isPrivate('::1'), true);
       assert.equal(ip.isPrivate('fe80::1'), true);
     });
+
+    it('should correctly identify hexadecimal IP addresses like \'0x7f.1\' as private', () => {
+      assert.equal(ip.isPrivate('0x7f.1'), true);
+    });
   });
 
   describe('loopback() method', () => {
     describe('undefined', () => {
       it('should respond with 127.0.0.1', () => {
@@ -411,6 +466,44 @@ describe('IP library for node.js', () => {
     it('should repond with ipv4 address', () => {
       assert.equal(ip.fromLong(2130706433), '127.0.0.1');
       assert.equal(ip.fromLong(4294967295), '255.255.255.255');
     });
   });
+
+  // IPv4 loopback in octal notation
+  it('should return true for octal representation "0177.0.0.1"', () => {
+    assert.equal(ip.isLoopback('0177.0.0.1'), true);
+  });
+
+  it('should return true for octal representation "0177.0.1"', () => {
+    assert.equal(ip.isLoopback('0177.0.1'), true);
+  });
+
+  it('should return true for octal representation "0177.1"', () => {
+    assert.equal(ip.isLoopback('0177.1'), true);
+  });
+
+  // IPv4 loopback in hexadecimal notation
+  it('should return true for hexadecimal representation "0x7f.0.0.1"', () => {
+    assert.equal(ip.isLoopback('0x7f.0.0.1'), true);
+  });
+
+  // IPv4 loopback in hexadecimal notation
+  it('should return true for hexadecimal representation "0x7f.0.1"', () => {
+    assert.equal(ip.isLoopback('0x7f.0.1'), true);
+  });
+
+  // IPv4 loopback in hexadecimal notation
+  it('should return true for hexadecimal representation "0x7f.1"', () => {
+    assert.equal(ip.isLoopback('0x7f.1'), true);
+  });
+
+  // IPv4 loopback as a single long integer
+  it('should return true for single long integer representation "2130706433"', () => {
+    assert.equal(ip.isLoopback('2130706433'), true);
+  });
+
+  // IPv4 non-loopback address
+  it('should return false for "192.168.1.1"', () => {
+    assert.equal(ip.isLoopback('192.168.1.1'), false);
+  });
 });

Vulnerable code:   return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i
    .test(addr)
    || /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr)
    || /^fe80::1$/.test(addr)
Fixed code:   // check loopback addresses first
  if (ip.isLoopback(addr)) {
    return true;
  }

  // ensure the ipv4 address is valid
  if (!ip.isV6Format(addr)) {
    const ipl = ip.normalizeToLong(addr);
    if (ipl < 0) {
      throw new Error('invalid ipv4 address');
    }
    // normalize the address for the private range checks that follow
    addr = ip.fromLong(ipl);
  }

  // check private ranges
  return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr)
  // If addr is an IPv4 address in long integer form (no dots and no colons), convert it
  if (!/\./.test(addr) && !/:/.test(addr)) {
    addr = ip.fromLong(Number(addr));
  }

    || /^0177\./.test(addr)
    || /^0x7f\./i.test(addr)
    || /^fe80::1$/i.test(addr)

ip.normalizeToLong = function (addr) {
  const parts = addr.split('.').map(part => {
    // Handle hexadecimal format
    if (part.startsWith('0x') || part.startsWith('0X')) {
      return parseInt(part, 16);
    }
    // Handle octal format (strictly digits 0-7 after a leading zero)
    else if (part.startsWith('0') && part !== '0' && /^[0-7]+$/.test(part)) {
      return parseInt(part, 8);
    }
    // Handle decimal format, reject invalid leading zeros
    else if (/^[1-9]\d*$/.test(part) || part === '0') {
      return parseInt(part, 10);
    }
    // Return NaN for invalid formats to indicate parsing failure
    else {
      return NaN;
    }
  });

  if (parts.some(isNaN)) return -1; // Indicate error with -1

  let val = 0;
  const n = parts.length;

  switch (n) {
  case 1:
    val = parts[0];
    break;
  case 2:
    if (parts[0] > 0xff || parts[1] > 0xffffff) return -1;
    val = (parts[0] << 24) | (parts[1] & 0xffffff);
    break;
  case 3:
    if (parts[0] > 0xff || parts[1] > 0xff || parts[2] > 0xffff) return -1;
    val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] & 0xffff);
    break;
  case 4:
    if (parts.some(part => part > 0xff)) return -1;
    val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
    break;
  default:
    return -1; // Error case
  }

  return val >>> 0;
};
  describe('normalizeIpv4() method', () => {
    // Testing valid inputs with different notations
    it('should correctly normalize "127.0.0.1"', () => {
      assert.equal(ip.normalizeToLong('127.0.0.1'), 2130706433);
    });

    it('should correctly handle "127.1" as two parts', () => {
      assert.equal(ip.normalizeToLong('127.1'), 2130706433);
    });

    it('should correctly handle "127.0.1" as three parts', () => {
      assert.equal(ip.normalizeToLong('127.0.1'), 2130706433);
    });


    it('should correctly handle hexadecimal notation "0x7f.0x0.0x0.0x1"', () => {
      assert.equal(ip.normalizeToLong('0x7f.0x0.0x0.0x1'), 2130706433);
    });

    // Testing with fewer than 4 parts
    it('should correctly handle "0x7f000001" as a single part', () => {
      assert.equal(ip.normalizeToLong('0x7f000001'), 2130706433);
    });

    it('should correctly handle octal notation "010.0.0.01"', () => {
      assert.equal(ip.normalizeToLong('010.0.0.01'), 134217729);
    });

    // Testing invalid inputs
    it('should return -1 for an invalid address "256.100.50.25"', () => {
      assert.equal(ip.normalizeToLong('256.100.50.25'), -1);
    });

    it('should return -1 for an address with invalid octal "019.0.0.1"', () => {
      assert.equal(ip.normalizeToLong('019.0.0.1'), -1);
    });

    it('should return -1 for an address with invalid hex "0xGG.0.0.1"', () => {
      assert.equal(ip.normalizeToLong('0xGG.0.0.1'), -1);
    });

    // Testing edge cases
    it('should return -1 for an empty string', () => {
      assert.equal(ip.normalizeToLong(''), -1);
    });

    it('should return -1 for a string with too many parts "192.168.0.1.100"', () => {
      assert.equal(ip.normalizeToLong('192.168.0.1.100'), -1);
    });
  });


    it('should correctly identify hexadecimal IP addresses like \'0x7f.1\' as private', () => {
      assert.equal(ip.isPrivate('0x7f.1'), true);
    });

  // IPv4 loopback in octal notation
  it('should return true for octal representation "0177.0.0.1"', () => {
    assert.equal(ip.isLoopback('0177.0.0.1'), true);
  });

  it('should return true for octal representation "0177.0.1"', () => {
    assert.equal(ip.isLoopback('0177.0.1'), true);
  });

  it('should return true for octal representation "0177.1"', () => {
    assert.equal(ip.isLoopback('0177.1'), true);
  });

  // IPv4 loopback in hexadecimal notation
  it('should return true for hexadecimal representation "0x7f.0.0.1"', () => {
    assert.equal(ip.isLoopback('0x7f.0.0.1'), true);
  });

  // IPv4 loopback in hexadecimal notation
  it('should return true for hexadecimal representation "0x7f.0.1"', () => {
    assert.equal(ip.isLoopback('0x7f.0.1'), true);
  });

  // IPv4 loopback in hexadecimal notation
  it('should return true for hexadecimal representation "0x7f.1"', () => {
    assert.equal(ip.isLoopback('0x7f.1'), true);
  });

  // IPv4 loopback as a single long integer
  it('should return true for single long integer representation "2130706433"', () => {
    assert.equal(ip.isLoopback('2130706433'), true);
  });

  // IPv4 non-loopback address
  it('should return false for "192.168.1.1"', () => {
    assert.equal(ip.isLoopback('192.168.1.1'), false);
  });