CVE-2021-21322: Improper Input Validation (CWE-20)
value: fastify-http-proxy is an npm package which is a fastify plugin for proxying your http requests to another server, with hooks. By crafting a specific URL, it is possible to escape the prefix of the proxied backend service. If the base url of the proxied server is `/pub/`, a user expect that accessing `/priv` on the target service would not be possible. In affected versions, it is possible. This is fixed in version 4.3.1.
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: 9.8
Published: 2021-03-02T04:15Z
Repository: https://github.com/fastify/fastify-http-proxy
Language: JavaScript
Files changed: index.js, package.json, test/test.js, test/ws-prefix-rewrite.js
Commit date: 2021-02-19 18:50:00 +0100
Commit message: Merge pull request from GHSA-c4qr-gmr9-v23w

* added failing test

* fix first test

* Fixed 2/4

* three out of four

* four out of four
Diff: diff --git a/index.js b/index.js
index ecbae80..d096890 100644
--- a/index.js
+++ b/index.js
@@ -51,26 +51,12 @@ function proxyWebSockets (source, target) {
   target.on('close', close)
   target.on('error', error => close(1011, error.message))
   target.on('unexpected-response', () => close(1011, 'unexpected response'))
 }
 
-function createWebSocketUrl (options, request) {
-  const source = new URL(request.url, 'http://127.0.0.1')
-
-  const target = new URL(
-    options.rewritePrefix || options.prefix || source.pathname,
-    options.upstream
-  )
-
-  target.search = source.search
-
-  return target
-}
-
-function setupWebSocketProxy (fastify, options) {
+function setupWebSocketProxy (fastify, options, rewritePrefix) {
   const server = new WebSocket.Server({
-    path: options.prefix,
     server: fastify.server,
     ...options.wsServerOptions
   })
 
   fastify.addHook('onClose', (instance, done) => server.close(done))
@@ -91,26 +77,59 @@ function setupWebSocketProxy (fastify, options) {
   server.on('error', (err) => {
     fastify.log.error(err)
   })
 
   server.on('connection', (source, request) => {
-    const url = createWebSocketUrl(options, request)
+    if (fastify.prefix && !request.url.startsWith(fastify.prefix)) {
+      fastify.log.debug({ url: request.url }, 'not matching prefix')
+      source.close()
+      return
+    }
+
+    const url = createWebSocketUrl(request)
 
     const target = new WebSocket(url, options.wsClientOptions)
 
     fastify.log.debug({ url: url.href }, 'proxy websocket')
     proxyWebSockets(source, target)
   })
+
+  function createWebSocketUrl (request) {
+    const source = new URL(request.url, 'http://127.0.0.1')
+
+    const target = new URL(
+      source.pathname.replace(fastify.prefix, rewritePrefix),
+      options.upstream
+    )
+
+    target.search = source.search
+
+    return target
+  }
+}
+
+function generateRewritePrefix (prefix, opts) {
+  if (!prefix) {
+    return ''
+  }
+
+  let rewritePrefix = opts.rewritePrefix || new URL(opts.upstream).pathname
+
+  if (!prefix.endsWith('/') && rewritePrefix.endsWith('/')) {
+    rewritePrefix = rewritePrefix.slice(0, -1)
+  }
+
+  return rewritePrefix
 }
 
 async function httpProxy (fastify, opts) {
   if (!opts.upstream) {
     throw new Error('upstream must be specified')
   }
 
   const preHandler = opts.preHandler || opts.beforeHandler
-  const rewritePrefix = opts.rewritePrefix || ''
+  const rewritePrefix = generateRewritePrefix(fastify.prefix, opts)
 
   const fromOpts = Object.assign({}, opts)
   fromOpts.base = opts.upstream
   fromOpts.prefix = undefined
 
@@ -162,11 +181,11 @@ async function httpProxy (fastify, opts) {
     dest = dest.replace(this.prefix, rewritePrefix)
     reply.from(dest || '/', replyOpts)
   }
 
   if (opts.websocket) {
-    setupWebSocketProxy(fastify, opts)
+    setupWebSocketProxy(fastify, opts, rewritePrefix)
   }
 }
 
 httpProxy[Symbol.for('plugin-meta')] = {
   fastify: '^3.0.0',
diff --git a/package.json b/package.json
index f7b975a..5549c7d 100644
--- a/package.json
+++ b/package.json
@@ -31,10 +31,11 @@
     "eslint-plugin-typescript": "^0.14.0",
     "express": "^4.16.4",
     "express-http-proxy": "^1.6.2",
     "fast-proxy": "^1.7.0",
     "fastify": "^3.0.0",
+    "fastify-websocket": "^3.0.0",
     "got": "^11.5.1",
     "http-errors": "^1.8.0",
     "http-proxy": "^1.17.0",
     "make-promises-safe": "^5.0.0",
     "simple-get": "^4.0.0",
diff --git a/test/test.js b/test/test.js
index 378af8b..035398e 100644
--- a/test/test.js
+++ b/test/test.js
@@ -177,11 +177,11 @@ async function run () {
       upstream: `http://localhost:${origin.server.address().port}`,
       config: { foo: 'bar' },
       async preHandler (request, reply) {
         t.deepEqual(reply.context.config, {
           foo: 'bar',
-          url: '/*',
+          url: '/',
           method: [
             'DELETE',
             'GET',
             'HEAD',
             'PATCH',
diff --git a/test/ws-prefix-rewrite.js b/test/ws-prefix-rewrite.js
new file mode 100644
index 0000000..ad35302
--- /dev/null
+++ b/test/ws-prefix-rewrite.js
@@ -0,0 +1,145 @@
+'use strict'
+
+const t = require('tap')
+const { once } = require('events')
+
+const Fastify = require('fastify')
+const fastifyWebSocket = require('fastify-websocket')
+const proxy = require('..')
+const WebSocket = require('ws')
+const got = require('got')
+
+const level = 'warn'
+
+async function proxyServer (t, backendURL, backendPath, proxyOptions, wrapperOptions) {
+  const frontend = Fastify({ logger: { level } })
+  const registerProxy = async fastify => {
+    fastify.register(proxy, {
+      upstream: backendURL + backendPath,
+      ...proxyOptions
+    })
+  }
+
+  t.comment('starting proxy to ' + backendURL + backendPath)
+
+  if (wrapperOptions) {
+    await frontend.register(registerProxy, wrapperOptions)
+  } else {
+    await registerProxy(frontend)
+  }
+
+  return [frontend, await frontend.listen(0)]
+}
+
+async function processRequest (t, frontendURL, path, expected) {
+  const url = new URL(path, frontendURL)
+  t.comment('ws connecting to ' + url.toString())
+  const ws = new WebSocket(url)
+  let wsResult, gotResult
+
+  try {
+    await once(ws, 'open')
+    t.pass('socket connected')
+
+    const [buf] = await Promise.race([once(ws, 'message'), once(ws, 'close')])
+    if (buf instanceof Buffer) {
+      wsResult = buf.toString()
+    } else {
+      t.comment('websocket closed')
+      wsResult = 'error'
+    }
+  } catch (e) {
+    wsResult = 'error'
+    ws.terminate()
+  }
+
+  try {
+    const result = await got(url)
+    gotResult = result.body
+  } catch (e) {
+    gotResult = 'error'
+  }
+
+  t.is(wsResult, expected)
+  t.is(gotResult, expected)
+}
+
+async function handleProxy (info, { backendPath, proxyOptions, wrapperOptions }, expected, ...paths) {
+  t.test(info, async function (t) {
+    const backend = Fastify({ logger: { level } })
+    await backend.register(fastifyWebSocket)
+
+    backend.get('/*', {
+      handler: (req, reply) => {
+        reply.send(req.url)
+      },
+      wsHandler: (conn, req) => {
+        conn.write(req.url)
+        conn.end()
+      }
+    })
+
+    t.teardown(() => backend.close())
+
+    const backendURL = await backend.listen(0)
+
+    const [frontend, frontendURL] = await proxyServer(t, backendURL, backendPath, proxyOptions, wrapperOptions)
+
+    t.teardown(() => frontend.close())
+
+    for (const path of paths) {
+      await processRequest(t, frontendURL, path, expected(path))
+    }
+
+    t.end()
+  })
+}
+
+handleProxy(
+  'no prefix to `/`',
+  {
+    backendPath: '/',
+    proxyOptions: { websocket: true }
+  },
+  path => path,
+  '/',
+  '/pub',
+  '/pub/'
+)
+
+handleProxy(
+  '`/pub/` to `/`',
+  {
+    backendPath: '/',
+    proxyOptions: { websocket: true, prefix: '/pub/' }
+  },
+  path => path.startsWith('/pub/') ? path.replace('/pub/', '/') : 'error',
+  '/',
+  '/pub/',
+  '/pub/test'
+)
+
+handleProxy(
+  '`/pub/` to `/public/`',
+  {
+    backendPath: '/public/',
+    proxyOptions: { websocket: true, prefix: '/pub/' }
+  },
+  path => path.startsWith('/pub/') ? path.replace('/pub/', '/public/') : 'error',
+  '/',
+  '/pub/',
+  '/pub/test'
+)
+
+handleProxy(
+  'wrapped `/pub/` to `/public/`',
+  {
+    backendPath: '/public/',
+    proxyOptions: { websocket: true },
+    wrapperOptions: { prefix: '/pub/' }
+  },
+  path => path.startsWith('/pub/') ? path.replace('/pub/', '/public/') : 'error',
+  '/',
+  '/pub/',
+  '/pub/test'
+)

Vulnerable code: function createWebSocketUrl (options, request) {
  const source = new URL(request.url, 'http://127.0.0.1')

  const target = new URL(
    options.rewritePrefix || options.prefix || source.pathname,
    options.upstream
  )

  target.search = source.search

  return target
}

function setupWebSocketProxy (fastify, options) {
    path: options.prefix,
    const url = createWebSocketUrl(options, request)
  const rewritePrefix = opts.rewritePrefix || ''
    setupWebSocketProxy(fastify, opts)
          url: '/*',
Fixed code: function setupWebSocketProxy (fastify, options, rewritePrefix) {
    if (fastify.prefix && !request.url.startsWith(fastify.prefix)) {
      fastify.log.debug({ url: request.url }, 'not matching prefix')
      source.close()
      return
    }

    const url = createWebSocketUrl(request)

  function createWebSocketUrl (request) {
    const source = new URL(request.url, 'http://127.0.0.1')

    const target = new URL(
      source.pathname.replace(fastify.prefix, rewritePrefix),
      options.upstream
    )

    target.search = source.search

    return target
  }
}

function generateRewritePrefix (prefix, opts) {
  if (!prefix) {
    return ''
  }

  let rewritePrefix = opts.rewritePrefix || new URL(opts.upstream).pathname

  if (!prefix.endsWith('/') && rewritePrefix.endsWith('/')) {
    rewritePrefix = rewritePrefix.slice(0, -1)
  }

  return rewritePrefix
  const rewritePrefix = generateRewritePrefix(fastify.prefix, opts)
    setupWebSocketProxy(fastify, opts, rewritePrefix)
    "fastify-websocket": "^3.0.0",
          url: '/',
'use strict'

const t = require('tap')
const { once } = require('events')

const Fastify = require('fastify')
const fastifyWebSocket = require('fastify-websocket')
const proxy = require('..')
const WebSocket = require('ws')
const got = require('got')

const level = 'warn'

async function proxyServer (t, backendURL, backendPath, proxyOptions, wrapperOptions) {
  const frontend = Fastify({ logger: { level } })
  const registerProxy = async fastify => {
    fastify.register(proxy, {
      upstream: backendURL + backendPath,
      ...proxyOptions
    })
  }

  t.comment('starting proxy to ' + backendURL + backendPath)

  if (wrapperOptions) {
    await frontend.register(registerProxy, wrapperOptions)
  } else {
    await registerProxy(frontend)
  }

  return [frontend, await frontend.listen(0)]
}

async function processRequest (t, frontendURL, path, expected) {
  const url = new URL(path, frontendURL)
  t.comment('ws connecting to ' + url.toString())
  const ws = new WebSocket(url)
  let wsResult, gotResult

  try {
    await once(ws, 'open')
    t.pass('socket connected')

    const [buf] = await Promise.race([once(ws, 'message'), once(ws, 'close')])
    if (buf instanceof Buffer) {
      wsResult = buf.toString()
    } else {
      t.comment('websocket closed')
      wsResult = 'error'
    }
  } catch (e) {
    wsResult = 'error'
    ws.terminate()
  }

  try {
    const result = await got(url)
    gotResult = result.body
  } catch (e) {
    gotResult = 'error'
  }

  t.is(wsResult, expected)
  t.is(gotResult, expected)
}

async function handleProxy (info, { backendPath, proxyOptions, wrapperOptions }, expected, ...paths) {
  t.test(info, async function (t) {
    const backend = Fastify({ logger: { level } })
    await backend.register(fastifyWebSocket)

    backend.get('/*', {
      handler: (req, reply) => {
        reply.send(req.url)
      },
      wsHandler: (conn, req) => {
        conn.write(req.url)
        conn.end()
      }
    })

    t.teardown(() => backend.close())

    const backendURL = await backend.listen(0)

    const [frontend, frontendURL] = await proxyServer(t, backendURL, backendPath, proxyOptions, wrapperOptions)

    t.teardown(() => frontend.close())

    for (const path of paths) {
      await processRequest(t, frontendURL, path, expected(path))
    }

    t.end()
  })
}

handleProxy(
  'no prefix to `/`',
  {
    backendPath: '/',
    proxyOptions: { websocket: true }
  },
  path => path,
  '/',
  '/pub',
  '/pub/'
)

handleProxy(
  '`/pub/` to `/`',
  {
    backendPath: '/',
    proxyOptions: { websocket: true, prefix: '/pub/' }
  },
  path => path.startsWith('/pub/') ? path.replace('/pub/', '/') : 'error',
  '/',
  '/pub/',
  '/pub/test'
)

handleProxy(
  '`/pub/` to `/public/`',
  {
    backendPath: '/public/',
    proxyOptions: { websocket: true, prefix: '/pub/' }
  },
  path => path.startsWith('/pub/') ? path.replace('/pub/', '/public/') : 'error',
  '/',
  '/pub/',
  '/pub/test'
)

handleProxy(
  'wrapped `/pub/` to `/public/`',
  {
    backendPath: '/public/',
    proxyOptions: { websocket: true },
    wrapperOptions: { prefix: '/pub/' }
  },
  path => path.startsWith('/pub/') ? path.replace('/pub/', '/public/') : 'error',
  '/',
  '/pub/',
  '/pub/test'
)