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.
| Package | Affected versions | Patched versions |
|---|---|---|
@fastify/middienpm | < 9.2.0 | 9.2.0 |
Affected products
1- @fastify/middie/@fastify/middiev5Range: 0.0.0
Patches
1140e0dd0359dfix: harden middie path normalization and matching
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- github.com/advisories/GHSA-8p85-9qpw-fwgwghsaADVISORY
- github.com/fastify/middie/security/advisories/GHSA-8p85-9qpw-fwgwnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-2880ghsaADVISORY
- fluidattacks.com/advisories/jimenezghsaWEB
- fluidattacks.com/advisories/policyghsaWEB
- github.com/fastify/middie/commit/140e0dd0359d890fec7e6ea1dcc5134d6bd554d4ghsaWEB
- github.com/fastify/middie/releases/tag/v9.2.0ghsaWEB
News mentions
0No linked articles in our index yet.