CVE-2022-31142: Observable Discrepancy (CWE-203)
value: @fastify/bearer-auth is a Fastify plugin to require bearer Authorization headers. @fastify/bearer-auth prior to versions 7.0.2 and 8.0.1 does not securely use crypto.timingSafeEqual. A malicious attacker could estimate the length of one valid bearer token. According to the corresponding RFC 6750, the bearer token has only base64 valid characters, reducing the range of characters for a brute force attack. Version 7.0.2 and 8.0.1 of @fastify/bearer-auth contain a patch. There are currently no known workarounds. The package fastify-bearer-auth, which covers versions 6.0.3 and prior, is also vulnerable starting at version 5.0.1. Users of fastify-bearer-auth should upgrade to a patched version of @fastify/bearer-auth.
CWE description: The product behaves differently or sends different responses under different circumstances in a way that is observable to an unauthorized actor, which exposes security-relevant information about the state of the product, such as whether a particular operation was successful or not.
CVSS v3 base score: 7.5
Published: 2022-07-14T19:15Z
Repository: https://github.com/fastify/fastify-bearer-auth
Language: JavaScript
Files changed: plugin.js, test/decorate.test.js
Commit date: 2022-07-13 11:28:55 +0200
Commit message: Merge pull request from GHSA-376v-xgjx-7mfr

* Correctly use crypto.timingSafeEqual

Signed-off-by: Matteo Collina <hello@matteocollina.com>
Co-Authored-By: Uzlopak <aras.abbasi@googlemail.com>

* apply requested change

Co-authored-by: Uzlopak <aras.abbasi@googlemail.com>
Diff: diff --git a/plugin.js b/plugin.js
index dda6edd..30822ca 100644
--- a/plugin.js
+++ b/plugin.js
@@ -16,10 +16,17 @@ const defaultOptions = {
 function verifyBearerAuthFactory (options) {
   const _options = Object.assign({}, defaultOptions, options)
   if (_options.keys instanceof Set) _options.keys = Array.from(_options.keys)
   const { keys, errorResponse, contentType, bearerType, auth, addHook = true, verifyErrorLogLevel = 'error' } = _options
 
+  for (let i = 0, il = keys.length; i < il; ++i) {
+    if (typeof keys[i] !== 'string') {
+      throw new Error('options.keys has to contain only string entries')
+    }
+    keys[i] = Buffer.from(keys[i])
+  }
+
   return function verifyBearerAuth (request, reply, done) {
     const header = request.raw.headers.authorization
     if (!header) {
       const noHeaderError = Error('missing authorization header')
       if (verifyErrorLogLevel) request.log[verifyErrorLogLevel]('unauthorized: %s', noHeaderError.message)
@@ -87,21 +94,23 @@ function verifyBearerAuthFactory (options) {
     })
   }
 }
 
 function authenticate (keys, key) {
-  return keys.findIndex((a) => compare(a, key)) !== -1
+  const b = Buffer.from(key)
+  return keys.findIndex((a) => compare(a, b)) !== -1
 }
 
 // perform constant-time comparison to prevent timing attacks
 function compare (a, b) {
-  try {
-    // may throw if they have different length, can't convert to Buffer, etc...
-    return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
-  } catch {
+  if (a.length !== b.length) {
+    // Delay return with cryptographically secure timing check.
+    crypto.timingSafeEqual(a, a)
     return false
   }
+
+  return crypto.timingSafeEqual(a, b)
 }
 
 function plugin (fastify, options, done) {
   const defaultLogLevel = 'error'
   options = Object.assign({ addHook: true, verifyErrorLogLevel: defaultLogLevel }, options)
diff --git a/test/decorate.test.js b/test/decorate.test.js
index c7c56d0..5dd2ac0 100644
--- a/test/decorate.test.js
+++ b/test/decorate.test.js
@@ -18,5 +18,13 @@ test('verifyBearerAuthFactory', (t) => {
   t.plan(1)
   fastify.ready(() => {
     t.ok(fastify.verifyBearerAuthFactory)
   })
 })
+
+test('verifyBearerAuthFactory', (t) => {
+  t.plan(1)
+  fastify.ready(() => {
+    const keys = { keys: new Set([123456]) }
+    t.throws(() => fastify.verifyBearerAuthFactory(keys), /keys has to contain only string entries/)
+  })
+})

Vulnerable code:   return keys.findIndex((a) => compare(a, key)) !== -1
  try {
    // may throw if they have different length, can't convert to Buffer, etc...
    return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
  } catch {
Fixed code:   for (let i = 0, il = keys.length; i < il; ++i) {
    if (typeof keys[i] !== 'string') {
      throw new Error('options.keys has to contain only string entries')
    }
    keys[i] = Buffer.from(keys[i])
  }

  const b = Buffer.from(key)
  return keys.findIndex((a) => compare(a, b)) !== -1
  if (a.length !== b.length) {
    // Delay return with cryptographically secure timing check.
    crypto.timingSafeEqual(a, a)

  return crypto.timingSafeEqual(a, b)

test('verifyBearerAuthFactory', (t) => {
  t.plan(1)
  fastify.ready(() => {
    const keys = { keys: new Set([123456]) }
    t.throws(() => fastify.verifyBearerAuthFactory(keys), /keys has to contain only string entries/)
  })
})