From 3c36ba5d0a3e4318ca8c535b8b1cb7200c88848e Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Wed, 20 May 2026 16:32:09 +0200 Subject: [PATCH 1/2] fix: block sibling-prefix path traversal --- lib/file.js | 9 +++++-- test/directory-secret/secret.txt | 1 + test/security.js | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 test/directory-secret/secret.txt diff --git a/lib/file.js b/lib/file.js index 68eed92..75240a7 100755 --- a/lib/file.js +++ b/lib/file.js @@ -70,8 +70,13 @@ exports.response = function (path, options, request, _preloaded) { const confineDir = Path.resolve(request.route.settings.files.relativeTo, options.confine); path = Path.isAbsolute(path) ? Path.normalize(path) : Path.join(confineDir, path); - // Verify that resolved path is within confineDir - if (path.lastIndexOf(confineDir, 0) !== 0) { + // Verify that the resolved path is the confine dir itself or a path + // inside it. Comparing against `confineDir + Path.sep` prevents a sibling + // directory with the same string prefix (e.g. confineDir + '-secret') + // from being treated as confined. + if (path !== confineDir && + !path.startsWith(confineDir + Path.sep)) { + path = null; } } diff --git a/test/directory-secret/secret.txt b/test/directory-secret/secret.txt new file mode 100644 index 0000000..1987330 --- /dev/null +++ b/test/directory-secret/secret.txt @@ -0,0 +1 @@ +SECRET!!! diff --git a/test/security.js b/test/security.js index a961448..8026c36 100755 --- a/test/security.js +++ b/test/security.js @@ -163,4 +163,46 @@ describe('security', () => { const res = await server.inject('/file'); expect(res.statusCode).to.equal(403); }); + + it('blocks sibling-prefix path traversal in directory handler', async () => { + + // 'directory-secret/' is a sibling of the served 'directory/'. Its absolute + // path begins with the same string as the confine dir, so a raw string-prefix + // check treats it as confined. + + const server = await provisionServer(); + server.route({ method: 'GET', path: '/{path*}', handler: { directory: { path: './directory' } } }); + + const res = await server.inject('/..%2fdirectory-secret/secret.txt'); + expect(res.statusCode).to.equal(403); + }); + + it('blocks sibling-prefix path traversal for h.file() with default confine', async () => { + + const server = new Hapi.Server({ routes: { files: { relativeTo: Path.join(__dirname, 'directory') } } }); + await server.register(Inert); + + const fileHandler = (request, h) => { + + return h.file('../directory-secret/secret.txt'); // confine defaults to true + }; + + server.route({ method: 'GET', path: '/file', handler: fileHandler }); + + const res = await server.inject('/file'); + expect(res.statusCode).to.equal(403); + }); + + it('blocks sibling-prefix path traversal for file handler with explicit confine', async () => { + + const server = await provisionServer(); + server.route({ + method: 'GET', + path: '/file', + handler: { file: { confine: './directory', path: '../directory-secret/secret.txt' } } + }); + + const res = await server.inject('/file'); + expect(res.statusCode).to.equal(403); + }); }); From c8957b2ed0a890d1bfc12e337ff5a734209ea7df Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Wed, 20 May 2026 16:41:19 +0200 Subject: [PATCH 2/2] chore: fix unit test --- test/file.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/file.js b/test/file.js index e695acc..7c7002f 100755 --- a/test/file.js +++ b/test/file.js @@ -303,7 +303,7 @@ describe('file', () => { const res = await server.inject('/filefn/index.js'); expect(res.statusCode).to.equal(200); expect(res.payload).to.contain('Set correct confine value'); - expect(res.headers['content-type']).to.equal('application/javascript; charset=utf-8'); + expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); expect(res.headers['content-length']).to.exist(); });