CVE-2022-0528 (published 2022-03-03T07:15Z)

Server-Side Request Forgery (SSRF) in GitHub repository transloadit/uppy prior to 3.3.1.\n\n

Severity: MEDIUM
CVSS v2 base score: 5.0 (AV:N/AC:L/Au:N/C:P/I:N/A:N)
CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
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.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/transloadit/uppy
commit_details: repo_url: https://github.com/transloadit/uppy
author: Mikael Finstad
author_date: 2022-02-26 00:22:18+08:00
msg: replace debug

remove debug flag
instead add allowLocalUrls (COMPANION_ALLOW_LOCAL_URLS)
which defaults to false
this fixes https://huntr.dev/bounties/8b060cc3-2420-468e-8293-b9216620175b/

also don't allow localhost for any providers getURLMeta
repository: repo_url: https://github.com/transloadit/uppy
repo_name: transloadit/uppy
description: The next open source file uploader for web browsers :dog: 
repo_language: TypeScript
owner: transloadit
file_changes: filename: .env.example
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,9 @@ COMPANION_PORT=3020
 COMPANION_CLIENT_ORIGINS=
 COMPANION_SECRET=development
 
+# NOTE: Only enable this in development. Enabling it in production is a security risk
+COMPANION_ALLOW_LOCAL_URLS=true
+
 COMPANION_DROPBOX_KEY=***
 COMPANION_DROPBOX_SECRET=***
 

programming_language: Shell
filename: companion.sh
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ else
     COMPANION_PORT=3020 \
     COMPANION_CLIENT_ORIGINS="" \
     COMPANION_SECRET="development" \
+    COMPANION_ALLOW_LOCAL_URLS="true" \
     nodemon --watch packages/@uppy/companion/src --exec node ./packages/@uppy/companion/src/standalone/start-server.js
 fi
 

programming_language: Shell
filename: companion.js
change_type: ModificationType.MODIFY
diff: @@ -41,7 +41,7 @@ const defaultOptions = {
       expires: ms('5 minutes') / 1000,
     },
   },
-  debug: true,
+  allowLocalUrls: false,
   logClientVersion: true,
   periodicPingUrls: [],
   streamingUpload: false,

programming_language: JavaScript
filename: url.js
change_type: ModificationType.MODIFY
diff: @@ -11,9 +11,9 @@ const logger = require('../logger')
  * Validates that the download URL is secure
  *
  * @param {string} url the url to validate
- * @param {boolean} debug whether the server is running in debug mode
+ * @param {boolean} ignoreTld whether to allow local addresses
  */
-const validateURL = (url, debug) => {
+const validateURL = (url, ignoreTld) => {
   if (!url) {
     return false
   }
@@ -21,7 +21,7 @@ const validateURL = (url, debug) => {
   const validURLOpts = {
     protocols: ['http', 'https'],
     require_protocol: true,
-    require_tld: !debug,
+    require_tld: !ignoreTld,
   }
   if (!validator.isURL(url, validURLOpts)) {
     return false
@@ -83,13 +83,13 @@ const downloadURL = async (url, blockLocalIPs, traceId) => {
 const meta = async (req, res) => {
   try {
     logger.debug('URL file import handler running', null, req.id)
-    const { debug } = req.companion.options
-    if (!validateURL(req.body.url, debug)) {
+    const { allowLocalUrls } = req.companion.options
+    if (!validateURL(req.body.url, allowLocalUrls)) {
       logger.debug('Invalid request body detected. Exiting url meta handler.', null, req.id)
       return res.status(400).json({ error: 'Invalid request body' })
     }
 
-    const urlMeta = await getURLMeta(req.body.url, !debug)
+    const urlMeta = await getURLMeta(req.body.url, !allowLocalUrls)
     return res.json(urlMeta)
   } catch (err) {
     logger.error(err, 'controller.url.meta.error', req.id)
@@ -107,20 +107,20 @@ const meta = async (req, res) => {
  */
 const get = async (req, res) => {
   logger.debug('URL file import handler running', null, req.id)
-  const { debug } = req.companion.options
-  if (!validateURL(req.body.url, debug)) {
+  const { allowLocalUrls } = req.companion.options
+  if (!validateURL(req.body.url, allowLocalUrls)) {
     logger.debug('Invalid request body detected. Exiting url import handler.', null, req.id)
     res.status(400).json({ error: 'Invalid request body' })
     return
   }
 
   async function getSize () {
-    const { size } = await getURLMeta(req.body.url, !debug)
+    const { size } = await getURLMeta(req.body.url, !allowLocalUrls)
     return size
   }
 
   async function download () {
-    return downloadURL(req.body.url, !debug, req.id)
+    return downloadURL(req.body.url, !allowLocalUrls, req.id)
   }
 
   function onUnhandledError (err) {

programming_language: JavaScript
filename: logger.js
change_type: ModificationType.MODIFY
diff: @@ -112,7 +112,6 @@ exports.error = (msg, tag, traceId, shouldLogStackTrace) => {
  * @param {string=} traceId a unique id to easily trace logs tied to a request
  */
 exports.debug = (msg, tag, traceId) => {
-  // @todo: this function should depend on companion's debug option instead
   if (process.env.NODE_ENV !== 'production') {
     // @ts-ignore
     log(msg, tag, 'debug', traceId, chalk.bold.blue)

programming_language: JavaScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -127,7 +127,7 @@ class Facebook extends Provider {
           return done(err)
         }
 
-        getURLMeta(this._getMediaUrl(body))
+        getURLMeta(this._getMediaUrl(body), true)
           .then(({ size }) => done(null, size))
           .catch((err2) => {
             logger.error(err2, 'provider.facebook.size.error')

programming_language: JavaScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -118,7 +118,7 @@ class Instagram extends Provider {
           return done(err)
         }
 
-        getURLMeta(body.media_url)
+        getURLMeta(body.media_url, true)
           .then(({ size }) => done(null, size))
           .catch((err2) => {
             logger.error(err2, 'provider.instagram.size.error')

programming_language: JavaScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -129,7 +129,7 @@ class Unsplash extends SearchProvider {
         return
       }
 
-      getURLMeta(body.links.download)
+      getURLMeta(body.links.download, true)
         .then(({ size }) => done(null, size))
         .catch((err2) => {
           logger.error(err2, 'provider.unsplash.size.error')

programming_language: CoffeeScript
filename: helper.js
change_type: ModificationType.MODIFY
diff: @@ -106,7 +106,7 @@ const getConfigFromEnv = () => {
     uploadUrls: uploadUrls ? uploadUrls.split(',') : null,
     secret: getSecret('COMPANION_SECRET') || generateSecret(),
     preAuthSecret: getSecret('COMPANION_PREAUTH_SECRET') || generateSecret(),
-    debug: process.env.NODE_ENV && process.env.NODE_ENV !== 'production',
+    allowLocalUrls: process.env.COMPANION_ALLOW_LOCAL_URLS === 'true',
     // cookieDomain is kind of a hack to support distributed systems. This should be improved but we never got so far.
     cookieDomain: process.env.COMPANION_COOKIE_DOMAIN,
     multipleInstances: true,

programming_language: JavaScript
filename: companion.js
change_type: ModificationType.MODIFY
diff: @@ -171,6 +171,34 @@ it('periodically pings', (done) => {
   })
 }, 1000)
 
+it('respects allowLocalUrls', async () => {
+  const server = getServer()
+  const url = 'http://localhost/'
+
+  let res
+
+  res = await request(server)
+    .post('/url/meta')
+    .send({ url })
+    .expect(400)
+
+  expect(res.body).toEqual({ error: 'Invalid request body' })
+
+  res = await request(server)
+    .post('/url/get')
+    .send({
+      fileId: url,
+      metadata: {},
+      endpoint: 'http://url.myendpoint.com/files',
+      protocol: 'tus',
+      size: null,
+      url,
+    })
+    .expect(400)
+
+  expect(res.body).toEqual({ error: 'Invalid request body' })
+}, 1000)
+
 afterAll(() => {
   nock.cleanAll()
   nock.restore()

programming_language: JavaScript
filename: mockserver.js
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ const defaultEnv = {
   COMPANION_HIDE_WELCOME: 'false',
 
   COMPANION_STREAMING_UPLOAD: 'true',
+  COMPANION_ALLOW_LOCAL_URLS : 'false',
 
   COMPANION_PROTOCOL: 'http',
   COMPANION_DATADIR: './test/output',

programming_language: JavaScript
filename: companion.md
change_type: ModificationType.MODIFY
diff: @@ -246,6 +246,9 @@ export COMPANION_UPLOAD_URLS="http://tusd.tusdemo.net/files/,https://tusd.tusdem
 # corresponds to the streamingUpload option
 export COMPANION_STREAMING_UPLOAD=true
 
+# corresponds to the allowLocalUrls option
+export COMPANION_ALLOW_LOCAL_URLS=false
+
 # corresponds to the maxFileSize option
 export COMPANION_MAX_FILE_SIZE="100000000"
 
@@ -309,6 +312,7 @@ const options = {
   debug: true,
   metrics: false,
   streamingUpload: true,
+  allowLocalUrls: false,
   maxFileSize: 100000000,
   periodicPingUrls: [],
   periodicPingInterval: 60000,
@@ -359,6 +363,8 @@ const options = {
 
 18. **periodicPingStaticPayload(optional)** - A `JSON.stringify`-able JavaScript Object that will be sent as part of the JSON body in the period ping requests.
 
+19. **allowLocalUrls(optional)** - A boolean flag to tell Companion whether to allow requesting local URLs. Note: Only enable this in development. **Enabling it in production is a security risk.**
+
 ### Provider Redirect URIs
 
 When generating your provider API keys on their corresponding developer platforms (e.g [Google Developer Console](https://console.developers.google.com/)), you’d need to provide a `redirect URI` for the OAuth authorization process. In general the redirect URI for each provider takes the format:

programming_language: Shell