From 46dc57fad63b948766177810da439e19e7c2fdbd Mon Sep 17 00:00:00 2001 From: vibhor-aggr Date: Thu, 11 Jun 2026 14:17:07 +0530 Subject: [PATCH 1/3] Redirect directory requests to trailing slash --- Readme.md | 1 + index.js | 73 +++++++++++++++++++++++++++++++++-- test/fixtures/empty/hello.txt | 1 + test/index.js | 35 ++++++++++++++++- 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/empty/hello.txt diff --git a/Readme.md b/Readme.md index ae90604..df4b7b2 100644 --- a/Readme.md +++ b/Readme.md @@ -34,6 +34,7 @@ app.use(serve(root, opts)); - `defer` If true, serves after `return next()`, allowing any downstream middleware to respond first. - `gzip` Try to serve the gzipped version of a file automatically when gzip is supported by a client and if the requested file with .gz extension exists. defaults to true. - `brotli` Try to serve the brotli version of a file automatically when brotli is supported by a client and if the requested file with .br extension exists (note, that brotli is only accepted over https). defaults to true. +- `format` Directory handling mode. By default, directory requests without a trailing slash redirect to the slash-appended URL when an index file exists. If `true`, directory indexes are served without requiring a trailing slash. If `false`, directory index formatting is disabled. - [setHeaders](https://github.com/koajs/send#setheaders) Function to set custom headers on response. - `extensions` Try to match extensions from passed array to search for file when no extension is sufficed in URL. First found is served. (defaults to `false`). e.g. `['html']` diff --git a/index.js b/index.js index 8227b45..fe5c7e7 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,3 @@ - 'use strict' /** @@ -6,6 +5,7 @@ */ const debug = require('debug')('koa-static') +const fs = require('fs') const path = require('path') const assert = require('assert') const send = require('koa-send') @@ -38,7 +38,7 @@ function serve (root, opts = {}) { if (ctx.method === 'HEAD' || ctx.method === 'GET') { try { - done = await send(ctx, ctx.path, opts) + done = await redirectToDirectorySlash(ctx, opts) || await send(ctx, ctx.path, opts) } catch (err) { if (err.status !== 404) { throw err @@ -60,7 +60,7 @@ function serve (root, opts = {}) { if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line try { - await send(ctx, ctx.path, opts) + await redirectToDirectorySlash(ctx, opts) || await send(ctx, ctx.path, opts) } catch (err) { if (err.status !== 404) { throw err @@ -68,3 +68,70 @@ function serve (root, opts = {}) { } } } + +async function redirectToDirectorySlash (ctx, opts) { + if (!opts.index || ctx.path[ctx.path.length - 1] === '/') return false + if (opts.format !== undefined && opts.format !== 'redirect') return false + + const pathname = decode(ctx.path) + if (pathname === -1 || pathname.indexOf('\0') !== -1) return false + + const dir = resolveFromRoot(opts.root, pathname) + if (!dir || (!opts.hidden && isHidden(opts.root, dir))) return false + + let stats + try { + stats = await fs.promises.stat(dir) + } catch (err) { + if (isNotFound(err)) return false + throw err + } + + if (!stats.isDirectory()) return false + + try { + stats = await fs.promises.stat(path.join(dir, opts.index)) + } catch (err) { + if (isNotFound(err)) return false + throw err + } + + if (!stats.isFile()) return false + + ctx.redirect(ctx.path + '/' + ctx.search) + return true +} + +function decode (pathname) { + try { + return decodeURIComponent(pathname) + } catch (err) { + return -1 + } +} + +function isHidden (root, pathname) { + const parts = path.relative(root, pathname).split(path.sep) + + for (let i = 0; i < parts.length; i++) { + if (parts[i][0] === '.') return true + } + + return false +} + +function isNotFound (err) { + return err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'ENAMETOOLONG' +} + +function resolveFromRoot (root, pathname) { + const filename = pathname.substr(path.parse(pathname).root.length) + const resolved = path.resolve(root, filename) + const relative = path.relative(root, resolved) + + if (relative === '' || (relative.substr(0, 2) !== '..' && !path.isAbsolute(relative))) { + return resolved + } + + return null +} diff --git a/test/fixtures/empty/hello.txt b/test/fixtures/empty/hello.txt new file mode 100644 index 0000000..46de785 --- /dev/null +++ b/test/fixtures/empty/hello.txt @@ -0,0 +1 @@ +empty fixture diff --git a/test/index.js b/test/index.js index e2ea964..ebffbd8 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,3 @@ - 'use strict' const request = require('supertest') @@ -120,6 +119,27 @@ describe('serve(root)', function () { .expect('Content-Type', 'text/html; charset=utf-8') .expect('html index', done) }) + + it('should redirect directory requests missing a trailing slash', function (done) { + const app = new Koa() + + app.use(serve('test/fixtures')) + + request(app.listen()) + .get('/world?foo=bar') + .expect(302) + .expect('Location', '/world/?foo=bar', done) + }) + + it('should not redirect when the directory has no index file', function (done) { + const app = new Koa() + + app.use(serve('test/fixtures')) + + request(app.listen()) + .get('/empty') + .expect(404, done) + }) }) describe('when disabled', function () { @@ -230,6 +250,19 @@ describe('serve(root)', function () { .expect('Content-Type', 'text/html; charset=utf-8') .expect('html index', done) }) + + it('should redirect directory requests missing a trailing slash', function (done) { + const app = new Koa() + + app.use(serve('test/fixtures', { + defer: true + })) + + request(app.listen()) + .get('/world?foo=bar') + .expect(302) + .expect('Location', '/world/?foo=bar', done) + }) }) }) From a908b93f7efeeeff7140437916c85e06d953995e Mon Sep 17 00:00:00 2001 From: vibhor-aggr Date: Thu, 11 Jun 2026 14:25:24 +0530 Subject: [PATCH 2/3] Harden directory redirect helper --- index.js | 10 +++++++++- test/index.js | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index fe5c7e7..b59c63f 100644 --- a/index.js +++ b/index.js @@ -70,6 +70,7 @@ function serve (root, opts = {}) { } async function redirectToDirectorySlash (ctx, opts) { + if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return false if (!opts.index || ctx.path[ctx.path.length - 1] === '/') return false if (opts.format !== undefined && opts.format !== 'redirect') return false @@ -98,7 +99,7 @@ async function redirectToDirectorySlash (ctx, opts) { if (!stats.isFile()) return false - ctx.redirect(ctx.path + '/' + ctx.search) + ctx.redirect(ctx.path + '/' + getSearch(ctx)) return true } @@ -124,6 +125,13 @@ function isNotFound (err) { return err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'ENAMETOOLONG' } +function getSearch (ctx) { + const search = ctx.search + + if (!search) return '' + return search[0] === '?' ? search : `?${search}` +} + function resolveFromRoot (root, pathname) { const filename = pathname.substr(path.parse(pathname).root.length) const resolved = path.resolve(root, filename) diff --git a/test/index.js b/test/index.js index ebffbd8..e85874c 100644 --- a/test/index.js +++ b/test/index.js @@ -125,6 +125,17 @@ describe('serve(root)', function () { app.use(serve('test/fixtures')) + request(app.listen()) + .get('/world') + .expect(302) + .expect('Location', '/world/', done) + }) + + it('should preserve the query string when redirecting a directory request', function (done) { + const app = new Koa() + + app.use(serve('test/fixtures')) + request(app.listen()) .get('/world?foo=bar') .expect(302) @@ -379,6 +390,21 @@ describe('serve(root)', function () { .post('/hello.txt') .expect(404, done) }) + + it('should not redirect directory requests', function (done) { + const app = new Koa() + + app.use(serve('test/fixtures', { + defer: true + })) + + request(app.listen()) + .post('/world') + .expect((res) => { + assert.equal(res.headers.location, undefined) + }) + .expect(404, done) + }) }) }) From bf3ef565a5ed1b398d9cac7ba7295a3de58ee99c Mon Sep 17 00:00:00 2001 From: vibhor-aggr Date: Thu, 11 Jun 2026 14:27:52 +0530 Subject: [PATCH 3/3] Address directory redirect review feedback --- index.js | 2 +- test/index.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index b59c63f..e5f2477 100644 --- a/index.js +++ b/index.js @@ -133,7 +133,7 @@ function getSearch (ctx) { } function resolveFromRoot (root, pathname) { - const filename = pathname.substr(path.parse(pathname).root.length) + const filename = pathname.slice(path.parse(pathname).root.length) const resolved = path.resolve(root, filename) const relative = path.relative(root, resolved) diff --git a/test/index.js b/test/index.js index e85874c..4671252 100644 --- a/test/index.js +++ b/test/index.js @@ -274,6 +274,21 @@ describe('serve(root)', function () { .expect(302) .expect('Location', '/world/?foo=bar', done) }) + + it('should not redirect when the directory has no index file', function (done) { + const app = new Koa() + + app.use(serve('test/fixtures', { + defer: true + })) + + request(app.listen()) + .get('/empty?foo=bar') + .expect((res) => { + assert.equal(res.headers.location, undefined) + }) + .expect(404, done) + }) }) })