CVE-2021-29418: Improper Input Validation (CWE-20)
value: The netmask package before 2.0.1 for Node.js mishandles certain unexpected characters in an IP address string, such as an octal digit of 9. This (in some situations) allows attackers to bypass access control that is based on IP addresses. NOTE: this issue exists because of an incomplete fix for CVE-2021-28918.
CWE description: The product receives input or data, but it does
        not validate or incorrectly validates that the input has the
        properties that are required to process the data safely and
        correctly.
CVSS v3 base score: 5.3
Published: 2021-03-30T07:15Z
Repository: https://github.com/rs/node-netmask
Language: Other
Files changed: lib/netmask.coffee, test/badnets.coffee
Commit date: 2021-03-29 13:12:05 +0200
Commit message: Rewrite byte parsing in full JS without depending on parseInt

The parseInt function is full of surprises. Instead of trying to find
all its pitfalls, let's parse bytes manually.

Diff: diff --git a/lib/netmask.coffee b/lib/netmask.coffee
index 44c6263..a56ee8e 100644
--- a/lib/netmask.coffee
+++ b/lib/netmask.coffee
@@ -4,31 +4,61 @@ long2ip = (long) ->
     c = (long & (0xff << 8)) >>> 8;
     d = long & 0xff;
     return [a, b, c, d].join('.')
 
 ip2long = (ip) ->
-    b = (ip + '').split('.');
-    if b.length is 0 or b.length > 4 then throw new Error('Invalid IP')
-    for byte, i in b
-        if byte and byte[0] == '0'
-            if byte.length > 2 and (byte[1] == 'x' or byte[1] == 'x')
-                # make sure 0x prefixed bytes are parsed as hex
-                byte = parseInt(byte, 16)
-            else
-                # make sure 0 prefixed bytes are parsed as octal
-                byte = parseInt(byte, 8)
-        else if byte and (byte[0] == ' ' or byte[byte.length-1] == ' ')
-            throw new Error('Invalid IP')
-        else
-            byte = parseInt(byte, 10)
-        if isNaN(byte) then throw new Error("Invalid byte: #{byte}")
-        if byte < 0 or byte > 255 then throw new Error("Invalid byte: #{byte}")
-        b[i] = byte
+    b = []
+    for i in [0..3]
+        if ip.length == 0
+            break
+        if i > 0
+            if ip[0] != '.'
+                throw new Error('Invalid IP')
+            ip = ip.substring(1)
+        [n, c] = atob(ip)
+        ip = ip.substring(c)
+        b.push(n)
+    if ip.length != 0
+        throw new Error('Invalid IP')
     while b.length < 4
         b.unshift(0)
     return (b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]) >>> 0
 
+atob = (s) ->
+    n = 0
+    base = 10
+    dmax = '9'
+    i = 0
+    if s.length > 1 and s[i] == '0'
+        if s[i+1] == 'x' or s[i+1] == 'X'
+            i+=2
+            base = 16
+        else if '0' <= s[i+1] and s[i+1] <= '7'
+            i++
+            base = 8
+            dmax = '7'
+    start = i
+    chr = (b) -> return b.charCodeAt(0)
+    while s.length > 0
+        if '0' <= s[i] and s[i] <= dmax
+            n = n*base + (chr(s[i])-chr('0'))
+        else if base == 16
+            if 'a' <= s[i] and s[i] <= 'f'
+                n = n*base + (10+chr(s[i])-chr('a'))
+            else if 'A' <= s[i] and s[i] <= 'F'
+                n = n*base + (10+chr(s[i])-chr('A'))
+            else
+                break
+        else
+            break
+        if n > 0xFF
+            throw new Error('byte overflow')
+        i++
+    if i == start
+        throw new Error('empty octet')
+    return [n, i]
+
 class Netmask
     constructor: (net, mask) ->
         throw new Error("Missing `net' parameter") unless typeof net is 'string'
         unless mask
             # try to find the mask in the net (i.e.: 1.2.3.4/24 or 1.2.3.4/255.255.255.0)
diff --git a/test/badnets.coffee b/test/badnets.coffee
index 175ddf2..3c779c4 100644
--- a/test/badnets.coffee
+++ b/test/badnets.coffee
@@ -33,15 +33,18 @@ vows.describe('IPs with bytes greater than 255')
     .export(module)
 
 vows.describe('Invalid IP format')
     .addBatch
         ' 1.2.3.4': shouldFailWithError 'Invalid net'
+        '  1.2.3.4': shouldFailWithError 'Invalid net'
         '1. 2.3.4': shouldFailWithError 'Invalid net'
         '1.2. 3.4': shouldFailWithError 'Invalid net'
         '1.2.3. 4': shouldFailWithError 'Invalid net'
         '1.2.3.4 ': shouldFailWithError 'Invalid net'
         '1 .2.3.4': shouldFailWithError 'Invalid net'
+        '018.0.0.0': shouldFailWithError 'Invalid net'
+        '0xfg.0.0.0': shouldFailWithError 'Invalid net'
     .export(module)
 
 vows.describe('Ranges that are a power-of-two big, but are not legal blocks')
     .addBatch
         '218.0.0.0/221.255.255.255': shouldFailWithError 'Invalid mask'

Vulnerable code:     b = (ip + '').split('.');
    if b.length is 0 or b.length > 4 then throw new Error('Invalid IP')
    for byte, i in b
        if byte and byte[0] == '0'
            if byte.length > 2 and (byte[1] == 'x' or byte[1] == 'x')
                # make sure 0x prefixed bytes are parsed as hex
                byte = parseInt(byte, 16)
            else
                # make sure 0 prefixed bytes are parsed as octal
                byte = parseInt(byte, 8)
        else if byte and (byte[0] == ' ' or byte[byte.length-1] == ' ')
            throw new Error('Invalid IP')
        else
            byte = parseInt(byte, 10)
        if isNaN(byte) then throw new Error("Invalid byte: #{byte}")
        if byte < 0 or byte > 255 then throw new Error("Invalid byte: #{byte}")
        b[i] = byte
Fixed code:     b = []
    for i in [0..3]
        if ip.length == 0
            break
        if i > 0
            if ip[0] != '.'
                throw new Error('Invalid IP')
            ip = ip.substring(1)
        [n, c] = atob(ip)
        ip = ip.substring(c)
        b.push(n)
    if ip.length != 0
        throw new Error('Invalid IP')
atob = (s) ->
    n = 0
    base = 10
    dmax = '9'
    i = 0
    if s.length > 1 and s[i] == '0'
        if s[i+1] == 'x' or s[i+1] == 'X'
            i+=2
            base = 16
        else if '0' <= s[i+1] and s[i+1] <= '7'
            i++
            base = 8
            dmax = '7'
    start = i
    chr = (b) -> return b.charCodeAt(0)
    while s.length > 0
        if '0' <= s[i] and s[i] <= dmax
            n = n*base + (chr(s[i])-chr('0'))
        else if base == 16
            if 'a' <= s[i] and s[i] <= 'f'
                n = n*base + (10+chr(s[i])-chr('a'))
            else if 'A' <= s[i] and s[i] <= 'F'
                n = n*base + (10+chr(s[i])-chr('A'))
            else
                break
        else
            break
        if n > 0xFF
            throw new Error('byte overflow')
        i++
    if i == start
        throw new Error('empty octet')
    return [n, i]

        '  1.2.3.4': shouldFailWithError 'Invalid net'
        '018.0.0.0': shouldFailWithError 'Invalid net'
        '0xfg.0.0.0': shouldFailWithError 'Invalid net'