@fastify/middie
Advanced tools
| name: Lock Threads | ||
| on: | ||
| schedule: | ||
| - cron: '0 0 1 * *' | ||
| workflow_dispatch: | ||
| concurrency: | ||
| group: lock | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| lock-threads: | ||
| permissions: | ||
| issues: write | ||
| pull-requests: write | ||
| uses: fastify/workflows/.github/workflows/lock-threads.yml@v6 |
| '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() | ||
| } | ||
| async function buildGuardedApp (fastifyOptions) { | ||
| const app = Fastify(fastifyOptions) | ||
| await app.register(middiePlugin) | ||
| app.use('/secret', guardMiddie) | ||
| app.get('/secret', async () => ({ ok: true, app: 'guarded' })) | ||
| await app.ready() | ||
| return app | ||
| } | ||
| async function buildPlainApp (fastifyOptions) { | ||
| const app = Fastify(fastifyOptions) | ||
| app.get('/secret', async () => ({ ok: true, app: 'plain' })) | ||
| await app.ready() | ||
| return app | ||
| } | ||
| test('security: deprecated top-level ignoreDuplicateSlashes cannot bypass middie use(/secret)', async (t) => { | ||
| const options = { ignoreDuplicateSlashes: true } | ||
| const guarded = await buildGuardedApp(options) | ||
| const plain = await buildPlainApp(options) | ||
| t.after(() => guarded.close()) | ||
| t.after(() => plain.close()) | ||
| const control = await plain.inject({ method: 'GET', url: '//secret' }) | ||
| const secured = await guarded.inject({ method: 'GET', url: '//secret' }) | ||
| t.assert.strictEqual(control.statusCode, 200, 'fastify route matches //secret with top-level option') | ||
| t.assert.strictEqual(secured.statusCode, 401, 'middie guard must also match //secret and block') | ||
| }) | ||
| test('security: deprecated top-level useSemicolonDelimiter cannot bypass middie use(/secret)', async (t) => { | ||
| const options = { useSemicolonDelimiter: true } | ||
| const guarded = await buildGuardedApp(options) | ||
| const plain = await buildPlainApp(options) | ||
| t.after(() => guarded.close()) | ||
| t.after(() => plain.close()) | ||
| const control = await plain.inject({ method: 'GET', url: '/secret;foo=bar' }) | ||
| const secured = await guarded.inject({ method: 'GET', url: '/secret;foo=bar' }) | ||
| t.assert.strictEqual(control.statusCode, 200, 'fastify route matches semicolon variant with top-level option') | ||
| t.assert.strictEqual(secured.statusCode, 401, 'middie guard must also match semicolon variant and block') | ||
| }) | ||
| test('security: combined deprecated top-level options cannot bypass middie use(/secret)', async (t) => { | ||
| const options = { | ||
| ignoreDuplicateSlashes: true, | ||
| useSemicolonDelimiter: true, | ||
| ignoreTrailingSlash: true | ||
| } | ||
| const guarded = await buildGuardedApp(options) | ||
| const plain = await buildPlainApp(options) | ||
| t.after(() => guarded.close()) | ||
| t.after(() => plain.close()) | ||
| const control = await plain.inject({ method: 'GET', url: '//secret;foo=bar/' }) | ||
| const secured = await guarded.inject({ method: 'GET', url: '//secret;foo=bar/' }) | ||
| t.assert.strictEqual(control.statusCode, 200, 'fastify route matches crafted variant with deprecated top-level options') | ||
| t.assert.strictEqual(secured.statusCode, 401, 'middie guard must also match crafted variant and block') | ||
| }) |
@@ -30,5 +30,5 @@ name: CI | ||
| pull-requests: write | ||
| uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 | ||
| uses: fastify/workflows/.github/workflows/plugins-ci.yml@v6 | ||
| with: | ||
| license-check: true | ||
| lint: true |
+21
-14
@@ -27,2 +27,13 @@ 'use strict' | ||
| function resolveNormalizationOptions (instance) { | ||
| const initialConfig = instance.initialConfig || {} | ||
| const routerOptions = initialConfig.routerOptions || {} | ||
| return { | ||
| ignoreDuplicateSlashes: routerOptions.ignoreDuplicateSlashes ?? initialConfig.ignoreDuplicateSlashes, | ||
| useSemicolonDelimiter: routerOptions.useSemicolonDelimiter ?? initialConfig.useSemicolonDelimiter, | ||
| ignoreTrailingSlash: routerOptions.ignoreTrailingSlash ?? initialConfig.ignoreTrailingSlash | ||
| } | ||
| } | ||
| function fastifyMiddie (fastify, options, next) { | ||
@@ -32,9 +43,4 @@ fastify.decorate('use', use) | ||
| fastify[kMiddieHasMiddlewares] = false | ||
| const routerOptions = fastify.initialConfig?.routerOptions || {} | ||
| fastify[kMiddie] = Middie(onMiddieEnd, { | ||
| ignoreDuplicateSlashes: routerOptions.ignoreDuplicateSlashes, | ||
| useSemicolonDelimiter: routerOptions.useSemicolonDelimiter, | ||
| ignoreTrailingSlash: routerOptions.ignoreTrailingSlash | ||
| }) | ||
| fastify[kMiddie] = Middie(onMiddieEnd, resolveNormalizationOptions(fastify)) | ||
@@ -98,12 +104,13 @@ const hook = options.hook || 'onRequest' | ||
| instance[kMiddlewares] = [] | ||
| const instanceRouterOptions = (instance.initialConfig && instance.initialConfig.routerOptions) || {} | ||
| instance[kMiddie] = Middie(onMiddieEnd, { | ||
| ignoreDuplicateSlashes: instanceRouterOptions.ignoreDuplicateSlashes, | ||
| useSemicolonDelimiter: instanceRouterOptions.useSemicolonDelimiter, | ||
| ignoreTrailingSlash: instanceRouterOptions.ignoreTrailingSlash | ||
| }) | ||
| instance[kMiddie] = Middie(onMiddieEnd, resolveNormalizationOptions(instance)) | ||
| instance[kMiddieHasMiddlewares] = false | ||
| instance.decorate('use', use) | ||
| for (const middleware of middlewares) { | ||
| instance.use(...middleware) | ||
| for (const [path, fn] of middlewares) { | ||
| instance[kMiddlewares].push([path, fn]) | ||
| if (fn == null) { | ||
| instance[kMiddie].use(path) | ||
| } else { | ||
| instance[kMiddie].use(path, fn) | ||
| } | ||
| instance[kMiddieHasMiddlewares] = true | ||
| } | ||
@@ -110,0 +117,0 @@ } |
+2
-2
| { | ||
| "name": "@fastify/middie", | ||
| "version": "9.3.1", | ||
| "version": "9.3.2", | ||
| "description": "Middleware engine for Fastify", | ||
@@ -68,3 +68,3 @@ "main": "index.js", | ||
| "helmet": "^8.0.0", | ||
| "neostandard": "^0.12.0", | ||
| "neostandard": "^0.13.0", | ||
| "serve-static": "^2.2.0", | ||
@@ -71,0 +71,0 @@ "tsd": "^0.33.0" |
+23
-0
@@ -746,1 +746,24 @@ 'use strict' | ||
| }) | ||
| test('fastifyMiddie supports instances without initialConfig', (t) => { | ||
| const { fastifyMiddie } = require('../index') | ||
| const hooks = [] | ||
| const fakeFastify = { | ||
| decorate (name, value) { | ||
| this[name] = value | ||
| return this | ||
| }, | ||
| addHook (name, fn) { | ||
| hooks.push([name, fn]) | ||
| return this | ||
| } | ||
| } | ||
| fastifyMiddie(fakeFastify, {}, (err) => { | ||
| t.assert.ifError(err) | ||
| }) | ||
| t.assert.strictEqual(typeof fakeFastify.use, 'function') | ||
| t.assert.ok(hooks.some(([name]) => name === 'onRequest')) | ||
| t.assert.ok(hooks.some(([name]) => name === 'onRegister')) | ||
| }) |
+318
-0
@@ -461,1 +461,319 @@ 'use strict' | ||
| }) | ||
| test('should not double-prefix inherited middleware paths in child scopes', async t => { | ||
| t.plan(4) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| instance.use('/admin', function (req, res, next) { | ||
| if (req.headers.authorization == null) { | ||
| res.statusCode = 403 | ||
| res.end('forbidden') | ||
| return | ||
| } | ||
| next() | ||
| }) | ||
| instance.get('/admin/root-data', async function () { | ||
| return { data: 'root-secret' } | ||
| }) | ||
| await instance.register(async function (child) { | ||
| child.get('/secret', async function (req) { | ||
| return { data: 'child-secret' } | ||
| }) | ||
| }, { prefix: '/admin' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const rootNoAuth = await fetch(address + '/admin/root-data') | ||
| t.assert.deepStrictEqual(rootNoAuth.status, 403) | ||
| const childNoAuth = await fetch(address + '/admin/secret') | ||
| t.assert.deepStrictEqual(childNoAuth.status, 403) | ||
| const childWithAuth = await fetch(address + '/admin/secret', { | ||
| headers: { | ||
| authorization: 'Bearer test' | ||
| } | ||
| }) | ||
| t.assert.deepStrictEqual(childWithAuth.status, 200) | ||
| t.assert.deepStrictEqual(await childWithAuth.json(), { data: 'child-secret' }) | ||
| }) | ||
| test('should allow child scopes register middleware with same prefix', async t => { | ||
| t.plan(7) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| const count = { admin: 0, child: 0 } | ||
| instance.use('/admin', function (req, res, next) { | ||
| count.admin++ | ||
| next() | ||
| }) | ||
| instance.get('/admin/root-data', async function () { | ||
| return { data: 'admin' } | ||
| }) | ||
| await instance.register(async function (child) { | ||
| child.use('/admin', function (req, res, next) { | ||
| count.child++ | ||
| next() | ||
| }) | ||
| child.get('/secret', async function (req) { | ||
| return { data: 'child' } | ||
| }) | ||
| child.get('/admin', async function (req) { | ||
| return { data: 'child-admin' } | ||
| }) | ||
| }, { prefix: '/admin' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const root = await fetch(address + '/admin/root-data') | ||
| t.assert.deepStrictEqual(root.status, 200) | ||
| t.assert.deepStrictEqual(await root.json(), { data: 'admin' }) | ||
| const child = await fetch(address + '/admin/secret') | ||
| t.assert.deepStrictEqual(child.status, 200) | ||
| t.assert.deepStrictEqual(await child.json(), { data: 'child' }) | ||
| const childAdmin = await fetch(address + '/admin/admin') | ||
| t.assert.deepStrictEqual(childAdmin.status, 200) | ||
| t.assert.deepStrictEqual(await childAdmin.json(), { data: 'child-admin' }) | ||
| t.assert.deepStrictEqual(count, { admin: 3, child: 1 }) | ||
| }) | ||
| test('should enforce inherited middleware in nested grandchild scopes', async t => { | ||
| t.plan(6) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| instance.use('/admin', function (req, res, next) { | ||
| if (req.headers.authorization == null) { | ||
| res.statusCode = 403 | ||
| res.end('forbidden') | ||
| return | ||
| } | ||
| next() | ||
| }) | ||
| instance.get('/admin/root-data', async function () { | ||
| return { data: 'root-secret' } | ||
| }) | ||
| await instance.register(async function (parent) { | ||
| parent.get('/info', async function () { | ||
| return { data: 'parent-info' } | ||
| }) | ||
| await parent.register(async function (grandchild) { | ||
| grandchild.get('/deep', async function () { | ||
| return { data: 'nested-secret' } | ||
| }) | ||
| }, { prefix: '/sub' }) | ||
| }, { prefix: '/admin' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const rootNoAuth = await fetch(address + '/admin/root-data') | ||
| t.assert.deepStrictEqual(rootNoAuth.status, 403) | ||
| const parentNoAuth = await fetch(address + '/admin/info') | ||
| t.assert.deepStrictEqual(parentNoAuth.status, 403) | ||
| const grandchildNoAuth = await fetch(address + '/admin/sub/deep') | ||
| t.assert.deepStrictEqual(grandchildNoAuth.status, 403) | ||
| const grandchildWithAuth = await fetch(address + '/admin/sub/deep', { | ||
| headers: { authorization: 'Bearer test' } | ||
| }) | ||
| t.assert.deepStrictEqual(grandchildWithAuth.status, 200) | ||
| t.assert.deepStrictEqual(await grandchildWithAuth.json(), { data: 'nested-secret' }) | ||
| const parentWithAuth = await fetch(address + '/admin/info', { | ||
| headers: { authorization: 'Bearer test' } | ||
| }) | ||
| t.assert.deepStrictEqual(parentWithAuth.status, 200) | ||
| }) | ||
| test('should enforce inherited middleware across three nesting levels', async t => { | ||
| t.plan(3) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| instance.use('/api', function (req, res, next) { | ||
| if (req.headers.authorization == null) { | ||
| res.statusCode = 403 | ||
| res.end('forbidden') | ||
| return | ||
| } | ||
| next() | ||
| }) | ||
| await instance.register(async function (l1) { | ||
| await l1.register(async function (l2) { | ||
| await l2.register(async function (l3) { | ||
| l3.get('/resource', async function () { | ||
| return { data: 'deep-resource' } | ||
| }) | ||
| }, { prefix: '/c' }) | ||
| }, { prefix: '/b' }) | ||
| }, { prefix: '/api/a' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const noAuth = await fetch(address + '/api/a/b/c/resource') | ||
| t.assert.deepStrictEqual(noAuth.status, 403) | ||
| const withAuth = await fetch(address + '/api/a/b/c/resource', { | ||
| headers: { authorization: 'Bearer test' } | ||
| }) | ||
| t.assert.deepStrictEqual(withAuth.status, 200) | ||
| t.assert.deepStrictEqual(await withAuth.json(), { data: 'deep-resource' }) | ||
| }) | ||
| test('should not apply middleware to unrelated nested prefixes', async t => { | ||
| t.plan(4) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| instance.use('/admin', function (req, res, next) { | ||
| if (req.headers.authorization == null) { | ||
| res.statusCode = 403 | ||
| res.end('forbidden') | ||
| return | ||
| } | ||
| next() | ||
| }) | ||
| await instance.register(async function (child) { | ||
| child.get('/data', async function () { | ||
| return { data: 'public' } | ||
| }) | ||
| await child.register(async function (grandchild) { | ||
| grandchild.get('/info', async function () { | ||
| return { data: 'public-nested' } | ||
| }) | ||
| }, { prefix: '/nested' }) | ||
| }, { prefix: '/public' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const publicData = await fetch(address + '/public/data') | ||
| t.assert.deepStrictEqual(publicData.status, 200) | ||
| t.assert.deepStrictEqual(await publicData.json(), { data: 'public' }) | ||
| const publicNested = await fetch(address + '/public/nested/info') | ||
| t.assert.deepStrictEqual(publicNested.status, 200) | ||
| t.assert.deepStrictEqual(await publicNested.json(), { data: 'public-nested' }) | ||
| }) | ||
| test('should not apply middleware when prefix shares string prefix but not path segment', async t => { | ||
| t.plan(4) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| instance.use('/admin', function (req, res, next) { | ||
| if (req.headers.authorization == null) { | ||
| res.statusCode = 403 | ||
| res.end('forbidden') | ||
| return | ||
| } | ||
| next() | ||
| }) | ||
| await instance.register(async function (child) { | ||
| child.get('/settings', async function () { | ||
| return { data: 'panel-settings' } | ||
| }) | ||
| }, { prefix: '/admin-panel' }) | ||
| await instance.register(async function (child) { | ||
| child.get('/settings', async function () { | ||
| return { data: 'admin-settings' } | ||
| }) | ||
| }, { prefix: '/admin/real' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const panelNoAuth = await fetch(address + '/admin-panel/settings') | ||
| t.assert.deepStrictEqual(panelNoAuth.status, 200) | ||
| t.assert.deepStrictEqual(await panelNoAuth.json(), { data: 'panel-settings' }) | ||
| const realNoAuth = await fetch(address + '/admin/real/settings') | ||
| t.assert.deepStrictEqual(realNoAuth.status, 403) | ||
| const realWithAuth = await fetch(address + '/admin/real/settings', { | ||
| headers: { authorization: 'Bearer test' } | ||
| }) | ||
| t.assert.deepStrictEqual(realWithAuth.status, 200) | ||
| }) | ||
| test('should enforce middleware with partial prefix overlap in nested scopes', async t => { | ||
| t.plan(3) | ||
| const instance = fastify() | ||
| t.after(() => instance.close()) | ||
| await instance.register(middiePlugin) | ||
| instance.use('/admin', function (req, res, next) { | ||
| if (req.headers.authorization == null) { | ||
| res.statusCode = 403 | ||
| res.end('forbidden') | ||
| return | ||
| } | ||
| next() | ||
| }) | ||
| await instance.register(async function (child) { | ||
| await child.register(async function (grandchild) { | ||
| grandchild.get('/settings', async function () { | ||
| return { data: 'admin-settings' } | ||
| }) | ||
| }, { prefix: '/panel' }) | ||
| }, { prefix: '/admin' }) | ||
| const address = await instance.listen({ port: 0 }) | ||
| const noAuth = await fetch(address + '/admin/panel/settings') | ||
| t.assert.deepStrictEqual(noAuth.status, 403) | ||
| const withAuth = await fetch(address + '/admin/panel/settings', { | ||
| headers: { authorization: 'Bearer test' } | ||
| }) | ||
| t.assert.deepStrictEqual(withAuth.status, 200) | ||
| t.assert.deepStrictEqual(await withAuth.json(), { data: 'admin-settings' }) | ||
| }) |
99899
15.11%23
9.52%2699
13.88%60
50%