CVE-2021-21321: Improper Input Validation (CWE-20)
value: fastify-reply-from is an npm package which is a fastify plugin to forward the current http request to another server. In fastify-reply-from before version 4.0.2, 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.0.2.
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: 10
Published: 2021-03-02T04:15Z
Repository: https://github.com/fastify/fastify-reply-from
Language: JavaScript
Files changed: lib/utils.js, test/build-url.js, test/unix-http.js
Commit date: 2021-02-19 18:45:06 +0100
Commit message: Merge pull request from GHSA-qmw8-3v4g-gwj4

* fix: Re-enable unix+http:// base URL, prevent escape of base URL path

Co-Authored-By: Corey Farrell <git@cfware.com>

* More fixes

* Test more build-url error cases

Co-authored-by: Corey Farrell <git@cfware.com>
Diff: diff --git a/lib/utils.js b/lib/utils.js
index 48572cd..3bcd685 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -57,12 +57,18 @@ function stripHttp1ConnectionHeaders (headers) {
 // issue ref: https://github.com/fastify/fast-proxy/issues/42
 function buildURL (source, reqBase) {
   const dest = new URL(source, reqBase)
 
   // if base is specified, source url should not override it
-  if (reqBase && !reqBase.startsWith(dest.origin)) {
-    throw new Error('source must be a relative path string')
+  if (reqBase) {
+    if (!reqBase.endsWith('/') && dest.href.length > reqBase.length) {
+      reqBase = reqBase + '/'
+    }
+
+    if (!dest.href.startsWith(reqBase)) {
+      throw new Error('source must be a relative path string')
+    }
   }
 
   return dest
 }
 
diff --git a/test/build-url.js b/test/build-url.js
index f9010dc..2eb5b3a 100644
--- a/test/build-url.js
+++ b/test/build-url.js
@@ -19,17 +19,35 @@ test('should return same source when base is not specified', (t) => {
   t.plan(1)
   const url = buildURL('http://localhost/hi')
   t.equal(url.href, 'http://localhost/hi')
 })
 
+test('should handle lack of trailing slash in base', (t) => {
+  t.plan(3)
+  let url = buildURL('hi', 'http://localhost/hi')
+  t.equal(url.href, 'http://localhost/hi')
+
+  url = buildURL('hi/', 'http://localhost/hi')
+  t.equal(url.href, 'http://localhost/hi/')
+
+  url = buildURL('hi/more', 'http://localhost/hi')
+  t.equal(url.href, 'http://localhost/hi/more')
+})
+
 const errorInputs = [
   { source: '//10.0.0.10/hi', base: 'http://localhost' },
   { source: 'http://10.0.0.10/hi', base: 'http://localhost' },
   { source: 'https://10.0.0.10/hi', base: 'http://localhost' },
   { source: 'blah://10.0.0.10/hi', base: 'http://localhost' },
   { source: '//httpbin.org/hi', base: 'http://localhost' },
-  { source: 'urn:foo:bar', base: 'http://localhost' }
+  { source: 'urn:foo:bar', base: 'http://localhost' },
+  { source: 'http://localhost/private', base: 'http://localhost/exposed/' },
+  { source: 'http://localhost/exposed-extra', base: 'http://localhost/exposed' },
+  { source: '/private', base: 'http://localhost/exposed/' },
+  { source: '/exposed-extra', base: 'http://localhost/exposed' },
+  { source: '../private', base: 'http://localhost/exposed/' },
+  { source: 'exposed-extra', base: 'http://localhost/exposed' }
 ]
 
 test('should throw when trying to override base', (t) => {
   t.plan(errorInputs.length)
 
diff --git a/test/unix-http.js b/test/unix-http.js
index a8845c4..fddeb5c 100644
--- a/test/unix-http.js
+++ b/test/unix-http.js
@@ -11,18 +11,19 @@ const get = require('simple-get').concat
 if (process.platform === 'win32') {
   t.pass()
   process.exit(0)
 }
 
+const socketPath = `${__filename}.socket`
+const upstream = `unix+http://${querystring.escape(socketPath)}/`
+
 const instance = Fastify()
-instance.register(From)
+instance.register(From, { base: upstream })
 
 t.plan(10)
 t.tearDown(instance.close.bind(instance))
 
-const socketPath = `${__filename}.socket`
-
 try {
   fs.unlinkSync(socketPath)
 } catch (_) {
 }
 
@@ -35,11 +36,11 @@ const target = http.createServer((req, res) => {
   res.setHeader('x-my-header', 'hello!')
   res.end('hello world')
 })
 
 instance.get('/', (request, reply) => {
-  reply.from(`unix+http://${querystring.escape(socketPath)}/hello`)
+  reply.from('hello')
 })
 
 t.tearDown(target.close.bind(target))
 
 instance.listen(0, (err) => {

Vulnerable code:   if (reqBase && !reqBase.startsWith(dest.origin)) {
    throw new Error('source must be a relative path string')
  { source: 'urn:foo:bar', base: 'http://localhost' }
instance.register(From)
const socketPath = `${__filename}.socket`

  reply.from(`unix+http://${querystring.escape(socketPath)}/hello`)
Fixed code:   if (reqBase) {
    if (!reqBase.endsWith('/') && dest.href.length > reqBase.length) {
      reqBase = reqBase + '/'
    }

    if (!dest.href.startsWith(reqBase)) {
      throw new Error('source must be a relative path string')
    }
test('should handle lack of trailing slash in base', (t) => {
  t.plan(3)
  let url = buildURL('hi', 'http://localhost/hi')
  t.equal(url.href, 'http://localhost/hi')

  url = buildURL('hi/', 'http://localhost/hi')
  t.equal(url.href, 'http://localhost/hi/')

  url = buildURL('hi/more', 'http://localhost/hi')
  t.equal(url.href, 'http://localhost/hi/more')
})

  { source: 'urn:foo:bar', base: 'http://localhost' },
  { source: 'http://localhost/private', base: 'http://localhost/exposed/' },
  { source: 'http://localhost/exposed-extra', base: 'http://localhost/exposed' },
  { source: '/private', base: 'http://localhost/exposed/' },
  { source: '/exposed-extra', base: 'http://localhost/exposed' },
  { source: '../private', base: 'http://localhost/exposed/' },
  { source: 'exposed-extra', base: 'http://localhost/exposed' }
const socketPath = `${__filename}.socket`
const upstream = `unix+http://${querystring.escape(socketPath)}/`

instance.register(From, { base: upstream })
  reply.from('hello')