🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@fastify/middie

Package Overview
Dependencies
Maintainers
17
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@fastify/middie - npm Package Compare versions

Comparing version
9.1.0
to
9.2.0
+145
test/req-url-stripping.test.js
'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')
})
'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)
})
'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`
)
}
}
}
}
})
+13
-2

@@ -31,4 +31,10 @@ 'use strict'

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'

@@ -91,3 +97,8 @@

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

@@ -94,0 +105,0 @@ instance.decorate('use', use)

@@ -7,5 +7,13 @@ 'use strict'

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
}

@@ -59,3 +67,4 @@ return {

holder.res = res
holder.url = sanitizeUrl(req.url)
holder.normalizedUrl = normalizePathForMatching(sanitizeUrl(req.url), normalizationOptions)
holder.normalizedReqUrl = normalizePathForMatching(req.url, normalizationOptions)
holder.context = ctx

@@ -69,3 +78,4 @@ holder.done()

this.res = null
this.url = null
this.normalizedUrl = null
this.normalizedReqUrl = null
this.context = null

@@ -78,3 +88,4 @@ this.i = 0

const res = that.res
const url = that.url
const normalizedUrl = that.normalizedUrl
const normalizedReqUrl = that.normalizedReqUrl
const context = that.context

@@ -88,2 +99,4 @@ const i = that.i++

that.res = null
that.normalizedUrl = null
that.normalizedReqUrl = null
that.context = null

@@ -99,2 +112,4 @@ that.i = 0

that.res = null
that.normalizedUrl = null
that.normalizedReqUrl = null
that.context = null

@@ -108,6 +123,5 @@ that.i = 0

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] !== '/') {

@@ -144,2 +158,18 @@ req.url = '/' + req.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
+2
-2
{
"name": "@fastify/middie",
"version": "9.1.0",
"version": "9.2.0",
"description": "Middleware engine for Fastify",

@@ -75,3 +75,3 @@ "main": "index.js",

"fastify-plugin": "^5.0.0",
"find-my-way": "^9.4.0",
"find-my-way": "^9.5.0",
"path-to-regexp": "^8.1.0",

@@ -78,0 +78,0 @@ "reusify": "^1.0.4"

# Number of days of inactivity before an issue becomes stale
daysUntilStale: 15
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- "discussion"
- "feature request"
- "bug"
- "help wanted"
- "plugin suggestion"
- "good first issue"
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false