@fastify/middie
Advanced tools
| '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) |
+37
-7
@@ -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 |
83370
15.95%21
10.53%2286
15.05%Updated