VYPR
Critical severity9.1NVD Advisory· Published Feb 27, 2026· Updated May 14, 2026

CVE-2026-2880

CVE-2026-2880

Description

A vulnerability in @fastify/middie versions < 9.2.0 can result in authentication/authorization bypass when using path-scoped middleware (for example, app.use('/secret', auth)).

When Fastify router normalization options are enabled (such as ignoreDuplicateSlashes, useSemicolonDelimiter, and related trailing-slash behavior), crafted request paths may bypass middleware checks while still being routed to protected handlers.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@fastify/middienpm
< 9.2.09.2.0

Affected products

1
  • @fastify/middie/@fastify/middiev5
    Range: 0.0.0

Patches

1
140e0dd0359d

fix: harden middie path normalization and matching

https://github.com/fastify/middieMatteo CollinaFeb 27, 2026via ghsa
6 files changed · +388 10
  • index.js+13 2 modified
    @@ -28,7 +28,13 @@ function fastifyMiddie (fastify, options, next) {
       fastify.decorate('use', use)
       fastify[kMiddlewares] = []
       fastify[kMiddieHasMiddlewares] = false
    -  fastify[kMiddie] = Middie(onMiddieEnd)
    +  const routerOptions = fastify.initialConfig?.routerOptions || {}
    +
    +  fastify[kMiddie] = Middie(onMiddieEnd, {
    +    ignoreDuplicateSlashes: routerOptions.ignoreDuplicateSlashes,
    +    useSemicolonDelimiter: routerOptions.useSemicolonDelimiter,
    +    ignoreTrailingSlash: routerOptions.ignoreTrailingSlash
    +  })
     
       const hook = options.hook || 'onRequest'
     
    @@ -87,7 +93,12 @@ function fastifyMiddie (fastify, options, next) {
       function onRegister (instance) {
         const middlewares = instance[kMiddlewares].slice()
         instance[kMiddlewares] = []
    -    instance[kMiddie] = Middie(onMiddieEnd)
    +    const instanceRouterOptions = (instance.initialConfig && instance.initialConfig.routerOptions) || {}
    +    instance[kMiddie] = Middie(onMiddieEnd, {
    +      ignoreDuplicateSlashes: instanceRouterOptions.ignoreDuplicateSlashes,
    +      useSemicolonDelimiter: instanceRouterOptions.useSemicolonDelimiter,
    +      ignoreTrailingSlash: instanceRouterOptions.ignoreTrailingSlash
    +    })
         instance[kMiddieHasMiddlewares] = false
         instance.decorate('use', use)
         for (const middleware of middlewares) {
    
  • lib/engine.js+37 7 modified
    @@ -4,9 +4,17 @@ const reusify = require('reusify')
     const { pathToRegexp } = require('path-to-regexp')
     const FindMyWay = require('find-my-way')
     
    -function middie (complete) {
    +function middie (complete, options = {}) {
       const middlewares = []
       const pool = reusify(Holder)
    +  const ignoreDuplicateSlashes = options.ignoreDuplicateSlashes === true
    +  const useSemicolonDelimiter = options.useSemicolonDelimiter === true
    +  const ignoreTrailingSlash = options.ignoreTrailingSlash === true
    +  const normalizationOptions = {
    +    ignoreDuplicateSlashes,
    +    useSemicolonDelimiter,
    +    ignoreTrailingSlash
    +  }
     
       return {
         use,
    @@ -55,7 +63,8 @@ function middie (complete) {
         const holder = pool.get()
         holder.req = req
         holder.res = res
    -    holder.url = sanitizeUrl(req.url)
    +    holder.normalizedUrl = normalizePathForMatching(sanitizeUrl(req.url), normalizationOptions)
    +    holder.normalizedReqUrl = normalizePathForMatching(req.url, normalizationOptions)
         holder.context = ctx
         holder.done()
       }
    @@ -64,15 +73,17 @@ function middie (complete) {
         this.next = null
         this.req = null
         this.res = null
    -    this.url = null
    +    this.normalizedUrl = null
    +    this.normalizedReqUrl = null
         this.context = null
         this.i = 0
     
         const that = this
         this.done = function (err) {
           const req = that.req
           const res = that.res
    -      const url = that.url
    +      const normalizedUrl = that.normalizedUrl
    +      const normalizedReqUrl = that.normalizedReqUrl
           const context = that.context
           const i = that.i++
     
    @@ -81,6 +92,8 @@ function middie (complete) {
           if (res.finished === true || res.writableEnded === true) {
             that.req = null
             that.res = null
    +        that.normalizedUrl = null
    +        that.normalizedReqUrl = null
             that.context = null
             that.i = 0
             pool.release(that)
    @@ -91,6 +104,8 @@ function middie (complete) {
             complete(err, req, res, context)
             that.req = null
             that.res = null
    +        that.normalizedUrl = null
    +        that.normalizedReqUrl = null
             that.context = null
             that.i = 0
             pool.release(that)
    @@ -99,10 +114,9 @@ function middie (complete) {
             const fn = middleware.fn
             const regexp = middleware.regexp
             if (regexp) {
    -          const decodedUrl = FindMyWay.sanitizeUrlPath(url)
    -          const result = regexp.exec(decodedUrl)
    +          const result = regexp.exec(normalizedUrl)
               if (result) {
    -            req.url = req.url.replace(result[0], '')
    +            req.url = normalizedReqUrl.replace(result[0], '')
                 if (req.url[0] !== '/') {
                   req.url = '/' + req.url
                 }
    @@ -134,4 +148,20 @@ function sanitizePrefixUrl (url) {
       return url
     }
     
    +function normalizePathForMatching (url, options) {
    +  let path = url
    +
    +  if (options.ignoreDuplicateSlashes) {
    +    path = FindMyWay.removeDuplicateSlashes(path)
    +  }
    +
    +  path = FindMyWay.sanitizeUrlPath(path, options.useSemicolonDelimiter)
    +
    +  if (options.ignoreTrailingSlash) {
    +    path = FindMyWay.trimLastSlash(path)
    +  }
    +
    +  return path
    +}
    +
     module.exports = middie
    
  • package.json+1 1 modified
    @@ -72,7 +72,7 @@
       "dependencies": {
         "@fastify/error": "^4.0.0",
         "fastify-plugin": "^5.0.0",
    -    "find-my-way": "^9.4.0",
    +    "find-my-way": "^9.5.0",
         "path-to-regexp": "^8.1.0",
         "reusify": "^1.0.4"
       },
    
  • test/req-url-stripping.test.js+145 0 added
    @@ -0,0 +1,145 @@
    +'use strict'
    +
    +const { test } = require('node:test')
    +const Fastify = require('fastify')
    +const middiePlugin = require('../index')
    +
    +test('req.url stripping with duplicate slashes', async (t) => {
    +  const app = Fastify({
    +    routerOptions: {
    +      ignoreDuplicateSlashes: true
    +    }
    +  })
    +  t.after(() => app.close())
    +
    +  await app.register(middiePlugin)
    +
    +  let capturedUrl = null
    +
    +  app.use('/secret', (req, _res, next) => {
    +    capturedUrl = req.url
    +    next()
    +  })
    +
    +  app.get('/secret/data', async () => ({ ok: true }))
    +
    +  // Normal case
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret/data' })
    +  t.assert.strictEqual(capturedUrl, '/data', 'normal path should strip to /data')
    +
    +  // Double slash before - should normalize and strip correctly
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '//secret/data' })
    +  t.assert.strictEqual(capturedUrl, '/data', '//secret/data should strip to /data, not //data')
    +
    +  // Double slash after prefix - should normalize and strip correctly
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret//data' })
    +  t.assert.strictEqual(capturedUrl, '/data', '/secret//data should strip to /data, not //data')
    +})
    +
    +test('req.url stripping with semicolon delimiter', async (t) => {
    +  const app = Fastify({
    +    routerOptions: {
    +      useSemicolonDelimiter: true
    +    }
    +  })
    +  t.after(() => app.close())
    +
    +  await app.register(middiePlugin)
    +
    +  let capturedUrl = null
    +
    +  app.use('/secret', (req, _res, next) => {
    +    capturedUrl = req.url
    +    next()
    +  })
    +
    +  app.get('/secret', async () => ({ ok: true }))
    +  app.get('/secret/data', async () => ({ ok: true }))
    +
    +  // Normal case
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret' })
    +  t.assert.strictEqual(capturedUrl, '/', 'normal path should strip to /')
    +
    +  // Semicolon variant - should normalize and strip correctly
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret;foo=bar' })
    +  t.assert.strictEqual(capturedUrl, '/', '/secret;foo=bar should strip to /, not /;foo=bar')
    +
    +  // Semicolon with path after - note: semicolon delimiter treats everything after ; as params
    +  // so /secret;foo=bar/data is path=/secret with params, not path=/secret/data
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret;foo=bar/data' })
    +  t.assert.strictEqual(capturedUrl, '/', '/secret;foo=bar/data has path /secret, strips to /')
    +})
    +
    +test('req.url stripping with trailing slash', async (t) => {
    +  const app = Fastify({
    +    routerOptions: {
    +      ignoreTrailingSlash: true
    +    }
    +  })
    +  t.after(() => app.close())
    +
    +  await app.register(middiePlugin)
    +
    +  let capturedUrl = null
    +
    +  app.use('/secret', (req, _res, next) => {
    +    capturedUrl = req.url
    +    next()
    +  })
    +
    +  app.get('/secret', async () => ({ ok: true }))
    +  app.get('/secret/data', async () => ({ ok: true }))
    +
    +  // Normal case
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret' })
    +  t.assert.strictEqual(capturedUrl, '/', 'normal path should strip to /')
    +
    +  // Trailing slash variant
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret/' })
    +  t.assert.strictEqual(capturedUrl, '/', '/secret/ should strip to /')
    +
    +  // With subpath and trailing slash
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '/secret/data/' })
    +  t.assert.strictEqual(capturedUrl, '/data', '/secret/data/ should strip to /data')
    +})
    +
    +test('req.url stripping with all normalization options combined', async (t) => {
    +  const app = Fastify({
    +    routerOptions: {
    +      ignoreDuplicateSlashes: true,
    +      useSemicolonDelimiter: true,
    +      ignoreTrailingSlash: true
    +    }
    +  })
    +  t.after(() => app.close())
    +
    +  await app.register(middiePlugin)
    +
    +  let capturedUrl = null
    +
    +  app.use('/secret', (req, _res, next) => {
    +    capturedUrl = req.url
    +    next()
    +  })
    +
    +  app.get('/secret', async () => ({ ok: true }))
    +  app.get('/secret/data', async () => ({ ok: true }))
    +
    +  // Complex case combining multiple normalizations
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '//secret;foo=bar/' })
    +  t.assert.strictEqual(capturedUrl, '/', '//secret;foo=bar/ should strip to /')
    +
    +  capturedUrl = null
    +  await app.inject({ method: 'GET', url: '//secret//data//' })
    +  t.assert.strictEqual(capturedUrl, '/data', '//secret//data// should strip to /data')
    +})
    \ No newline at end of file
    
  • test/security-normalization-bypass.test.js+106 0 added
    @@ -0,0 +1,106 @@
    +'use strict'
    +
    +const { test } = require('node:test')
    +const Fastify = require('fastify')
    +const middiePlugin = require('../index')
    +
    +const API_KEY = 'mock-api-key-123'
    +
    +function guardMiddie (req, res, next) {
    +  if (req.headers['x-api-key'] !== API_KEY) {
    +    res.statusCode = 401
    +    res.setHeader('content-type', 'application/json; charset=utf-8')
    +    res.end(JSON.stringify({ error: 'Unauthorized', where: 'middie /secret guard' }))
    +    return
    +  }
    +  next()
    +}
    +
    +function buildWithMiddieHook (hook) {
    +  const app = Fastify({
    +    routerOptions: {
    +      ignoreTrailingSlash: true,
    +      ignoreDuplicateSlashes: true,
    +      useSemicolonDelimiter: true
    +    }
    +  })
    +
    +  return { app, register: () => app.register(middiePlugin, hook ? { hook } : undefined) }
    +}
    +
    +test('baseline: /secret is blocked without API key when guarded via middie use(/secret)', async (t) => {
    +  const { app, register } = buildWithMiddieHook()
    +  t.after(() => app.close())
    +
    +  await register()
    +  app.use('/secret', guardMiddie)
    +
    +  app.get('/secret', async () => ({ ok: true, route: '/secret' }))
    +
    +  const res = await app.inject({ method: 'GET', url: '/secret' })
    +  const trailing = await app.inject({ method: 'GET', url: '/secret/' })
    +  t.assert.strictEqual(res.statusCode, 401)
    +  t.assert.strictEqual(trailing.statusCode, 401)
    +})
    +
    +test('regression: crafted paths are blocked by middie use(/secret) under default onRequest hook', async (t) => {
    +  const { app, register } = buildWithMiddieHook('onRequest')
    +  t.after(() => app.close())
    +
    +  await register()
    +  app.use('/secret', guardMiddie)
    +
    +  app.get('/secret', async (request) => ({ ok: true, route: '/secret', url: request.raw.url }))
    +
    +  const baseline = await app.inject({ method: 'GET', url: '/secret' })
    +  t.assert.strictEqual(baseline.statusCode, 401)
    +
    +  const duplicateSlash = await app.inject({ method: 'GET', url: '//secret' })
    +  t.assert.strictEqual(duplicateSlash.statusCode, 401)
    +
    +  const semicolonVariant = await app.inject({ method: 'GET', url: '/secret;foo=bar' })
    +  t.assert.strictEqual(semicolonVariant.statusCode, 401)
    +
    +  const trailingSlash = await app.inject({ method: 'GET', url: '/secret/' })
    +  t.assert.strictEqual(trailingSlash.statusCode, 401)
    +})
    +
    +test('mitigation: registering middie with hook preValidation makes use(/secret) auth block crafted variants', async (t) => {
    +  const { app, register } = buildWithMiddieHook('preValidation')
    +  t.after(() => app.close())
    +
    +  await register()
    +  app.use('/secret', guardMiddie)
    +
    +  app.get('/secret', async () => ({ ok: true, route: '/secret' }))
    +
    +  const r1 = await app.inject({ method: 'GET', url: '/secret' })
    +  const r2 = await app.inject({ method: 'GET', url: '//secret' })
    +  const r3 = await app.inject({ method: 'GET', url: '/secret;foo=bar' })
    +  const r4 = await app.inject({ method: 'GET', url: '/secret/' })
    +
    +  t.assert.strictEqual(r1.statusCode, 401)
    +  t.assert.strictEqual(r2.statusCode, 401)
    +  t.assert.strictEqual(r3.statusCode, 401)
    +  t.assert.strictEqual(r4.statusCode, 401)
    +})
    +
    +test('mitigation: registering middie with hook preHandler makes use(/secret) auth block crafted variants', async (t) => {
    +  const { app, register } = buildWithMiddieHook('preHandler')
    +  t.after(() => app.close())
    +
    +  await register()
    +  app.use('/secret', guardMiddie)
    +
    +  app.get('/secret', async () => ({ ok: true, route: '/secret' }))
    +
    +  const r1 = await app.inject({ method: 'GET', url: '/secret' })
    +  const r2 = await app.inject({ method: 'GET', url: '//secret' })
    +  const r3 = await app.inject({ method: 'GET', url: '/secret;foo=bar' })
    +  const r4 = await app.inject({ method: 'GET', url: '/secret/' })
    +
    +  t.assert.strictEqual(r1.statusCode, 401)
    +  t.assert.strictEqual(r2.statusCode, 401)
    +  t.assert.strictEqual(r3.statusCode, 401)
    +  t.assert.strictEqual(r4.statusCode, 401)
    +})
    
  • test/security-router-options-combinations.test.js+86 0 added
    @@ -0,0 +1,86 @@
    +'use strict'
    +
    +const { test } = require('node:test')
    +const Fastify = require('fastify')
    +const middiePlugin = require('../index')
    +
    +const API_KEY = 'mock-api-key-123'
    +
    +const variants = [
    +  '/secret',
    +  '//secret',
    +  '/secret/',
    +  '/secret?x=1',
    +  '/secret;foo=bar',
    +  '/secret;foo=bar?x=1',
    +  '//secret;foo=bar',
    +  '//secret//',
    +  '/%2fsecret',
    +  '/%2Fsecret',
    +  '/secret%2F'
    +]
    +
    +function guardMiddie (req, res, next) {
    +  if (req.headers['x-api-key'] !== API_KEY) {
    +    res.statusCode = 401
    +    res.setHeader('content-type', 'application/json; charset=utf-8')
    +    res.end(JSON.stringify({ error: 'Unauthorized', where: 'middie /secret guard' }))
    +    return
    +  }
    +  next()
    +}
    +
    +function comboLabel (routerOptions) {
    +  return `dup=${routerOptions.ignoreDuplicateSlashes},trail=${routerOptions.ignoreTrailingSlash},semi=${routerOptions.useSemicolonDelimiter}`
    +}
    +
    +function allRouterOptionCombinations () {
    +  const result = []
    +  for (const ignoreDuplicateSlashes of [false, true]) {
    +    for (const ignoreTrailingSlash of [false, true]) {
    +      for (const useSemicolonDelimiter of [false, true]) {
    +        result.push({ ignoreDuplicateSlashes, ignoreTrailingSlash, useSemicolonDelimiter })
    +      }
    +    }
    +  }
    +  return result
    +}
    +
    +test('router option combinations: crafted variants never bypass middie use(/secret) guard', async (t) => {
    +  const hooks = [undefined, 'onRequest', 'preValidation', 'preHandler']
    +
    +  for (const hook of hooks) {
    +    for (const routerOptions of allRouterOptionCombinations()) {
    +      const guarded = Fastify({ routerOptions })
    +      const plain = Fastify({ routerOptions })
    +
    +      t.after(() => guarded.close())
    +      t.after(() => plain.close())
    +
    +      await guarded.register(middiePlugin, hook ? { hook } : undefined)
    +      guarded.use('/secret', guardMiddie)
    +
    +      guarded.get('/secret', async () => ({ ok: true, app: 'guarded' }))
    +      plain.get('/secret', async () => ({ ok: true, app: 'plain' }))
    +
    +      for (const url of variants) {
    +        const control = await plain.inject({ method: 'GET', url })
    +        const secured = await guarded.inject({ method: 'GET', url })
    +
    +        t.assert.notStrictEqual(
    +          secured.statusCode,
    +          200,
    +          `hook=${hook || 'default'} ${comboLabel(routerOptions)} url=${url} should never bypass auth as 200`
    +        )
    +
    +        if (control.statusCode === 200) {
    +          t.assert.strictEqual(
    +            secured.statusCode,
    +            401,
    +            `hook=${hook || 'default'} ${comboLabel(routerOptions)} url=${url} matches route; middie must block`
    +          )
    +        }
    +      }
    +    }
    +  }
    +})
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.