From 850077413209b1b45f714daa857b927a8abd29bb Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:05:36 +0900 Subject: [PATCH 01/16] feat(serve-static): support absolute path --- src/serve-static.ts | 36 +++++++++++++++++++++++++++++++----- test/serve-static.test.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 87d4fae..cb2dbfa 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -48,6 +48,10 @@ const addCurrentDirPrefix = (path: string) => { return `./${path}` } +const addRootPrefix = (path: string) => { + return `/${path}` +} + const getStats = (path: string) => { let stats: Stats | undefined try { @@ -60,6 +64,28 @@ const getStats = (path: string) => { export const serveStatic = ( options: ServeStaticOptions = { root: '' } ): MiddlewareHandler => { + let isAbsolutePath = false + let optionRoot: string + let optionPath: string + + if (options.root) { + if (options.root.startsWith('/')) { + isAbsolutePath = true + optionRoot = new URL(`file://${options.root}`).pathname + } else { + optionRoot = options.root + } + } + + if (options.path) { + if (options.path.startsWith('/')) { + isAbsolutePath = true + optionPath = new URL(`file://${options.path}`).pathname + } else { + optionPath = options.path + } + } + return async (c, next) => { // Do nothing if Response is already set if (c.finalized) { @@ -69,7 +95,7 @@ export const serveStatic = ( let filename: string try { - filename = options.path ?? decodeURIComponent(c.req.path) + filename = optionPath ?? decodeURIComponent(c.req.path) } catch { await options.onNotFound?.(c.req.path, c) return next() @@ -77,11 +103,11 @@ export const serveStatic = ( let path = getFilePathWithoutDefaultDocument({ filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename, - root: options.root, + root: optionRoot, }) if (path) { - path = addCurrentDirPrefix(path) + path = isAbsolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) } else { return next() } @@ -91,12 +117,12 @@ export const serveStatic = ( if (stats && stats.isDirectory()) { path = getFilePath({ filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename, - root: options.root, + root: optionRoot, defaultDocument: options.index ?? 'index.html', }) if (path) { - path = addCurrentDirPrefix(path) + path = isAbsolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) } else { return next() } diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 6ba4f07..57f9b1b 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' - import request from 'supertest' +import path from 'node:path' import { serveStatic } from './../src/serve-static' import { createAdaptorServer } from './../src/server' @@ -226,4 +226,35 @@ describe('Serve Static Middleware', () => { expect(res.headers['vary']).toBeUndefined() expect(res.text).toBe('Hello Not Compressed') }) + + describe('Absolute path', () => { + const rootPaths = [path.join(__dirname, 'assets'), __dirname + '/../test/assets'] + rootPaths.forEach((root) => { + describe(root, () => { + const app = new Hono() + const server = createAdaptorServer(app) + app.use('/static/*', serveStatic({ root })) + app.use('/favicon.ico', serveStatic({ path: root + '/favicon.ico' })) + + it(`Should return index.html`, async () => { + const res = await request(server).get('/static') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('text/html; charset=utf-8') + expect(res.text).toBe('

Hello Hono

') + }) + + it(`Should return correct headers and data for text`, async () => { + const res = await request(server).get('/static/plain.txt') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') + expect(res.text).toBe('This is plain.txt') + }) + it('Should return correct headers for icons', async () => { + const res = await request(server).get('/favicon.ico') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('image/x-icon') + }) + }) + }) + }) }) From cbfed14d230b909fa7d5afce5b21d0494f4cd9d4 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:15:19 +0900 Subject: [PATCH 02/16] add ci-windows --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a4101d..1b34a58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,15 @@ jobs: - run: bun run lint - run: bun run build - run: bun run test + + ci-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run test From 2e9b4e91c92a23782621c21ecc57553060ad37e7 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:18:04 +0900 Subject: [PATCH 03/16] fix the formats --- test/serve-static.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 57f9b1b..72550e0 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -236,14 +236,14 @@ describe('Serve Static Middleware', () => { app.use('/static/*', serveStatic({ root })) app.use('/favicon.ico', serveStatic({ path: root + '/favicon.ico' })) - it(`Should return index.html`, async () => { + it('Should return index.html', async () => { const res = await request(server).get('/static') expect(res.status).toBe(200) expect(res.headers['content-type']).toBe('text/html; charset=utf-8') expect(res.text).toBe('

Hello Hono

') }) - it(`Should return correct headers and data for text`, async () => { + it('Should return correct headers and data for text', async () => { const res = await request(server).get('/static/plain.txt') expect(res.status).toBe(200) expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') From e420e47069413d05c4d3bebfeb84ed63f92b0947 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:28:44 +0900 Subject: [PATCH 04/16] fix the jest path --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f6e4dfc..3b6e9b8 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ } }, "scripts": { - "test": "node --expose-gc ./node_modules/.bin/jest", + "test": "node --expose-gc node_modules/jest/bin/jest.js", "build": "tsup --external hono", "watch": "tsup --watch", "postbuild": "publint", @@ -99,4 +99,4 @@ "peerDependencies": { "hono": "^4" } -} +} \ No newline at end of file From 9a14f207bdc2f8078cf3061d84a0f558da3a7a34 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:35:02 +0900 Subject: [PATCH 05/16] support Windows --- src/serve-static.ts | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index cb2dbfa..45ed0b1 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -60,18 +60,30 @@ const getStats = (path: string) => { return stats } +const isAbsolutePath = (path: string) => { + const isUnixAbsolutePath = path.startsWith('/') + const hasDriveLetter = /^[a-zA-Z]:\\/.test(path) + const isUncPath = /^\\\\[^\\]+\\[^\\]+/.test(path) + return isUnixAbsolutePath || hasDriveLetter || isUncPath +} + +const windowsPathToUnixPath = (path: string) => { + return path.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/') +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const serveStatic = ( options: ServeStaticOptions = { root: '' } ): MiddlewareHandler => { - let isAbsolutePath = false + let absolutePath = false let optionRoot: string let optionPath: string if (options.root) { - if (options.root.startsWith('/')) { - isAbsolutePath = true - optionRoot = new URL(`file://${options.root}`).pathname + if (isAbsolutePath(options.root)) { + absolutePath = true + optionRoot = windowsPathToUnixPath(options.root) + optionRoot = new URL(`file://${optionRoot}`).pathname } else { optionRoot = options.root } @@ -79,8 +91,9 @@ export const serveStatic = ( if (options.path) { if (options.path.startsWith('/')) { - isAbsolutePath = true - optionPath = new URL(`file://${options.path}`).pathname + absolutePath = true + optionPath = windowsPathToUnixPath(options.path) + optionPath = new URL(`file://${optionPath}`).pathname } else { optionPath = options.path } @@ -107,7 +120,7 @@ export const serveStatic = ( }) if (path) { - path = isAbsolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) + path = absolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) } else { return next() } @@ -122,7 +135,7 @@ export const serveStatic = ( }) if (path) { - path = isAbsolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) + path = absolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) } else { return next() } From 33f3ccf25ff50cbff9e1e017ceff2520200a0bbd Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:47:04 +0900 Subject: [PATCH 06/16] test support Windows --- test/serve-static.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 72550e0..5af4bd7 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -228,13 +228,16 @@ describe('Serve Static Middleware', () => { }) describe('Absolute path', () => { - const rootPaths = [path.join(__dirname, 'assets'), __dirname + '/../test/assets'] + const rootPaths = [ + path.join(__dirname, 'assets'), + __dirname + path.sep + '..' + path.sep + 'test' + path.sep + 'assets', + ] rootPaths.forEach((root) => { describe(root, () => { const app = new Hono() const server = createAdaptorServer(app) app.use('/static/*', serveStatic({ root })) - app.use('/favicon.ico', serveStatic({ path: root + '/favicon.ico' })) + app.use('/favicon.ico', serveStatic({ path: root + path.sep + 'favicon.ico' })) it('Should return index.html', async () => { const res = await request(server).get('/static') From 22d69c013a0f4070a6644a929623eab4420a2ba2 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:52:19 +0900 Subject: [PATCH 07/16] fix the logic --- src/serve-static.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 45ed0b1..03a6a27 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -82,8 +82,7 @@ export const serveStatic = ( if (options.root) { if (isAbsolutePath(options.root)) { absolutePath = true - optionRoot = windowsPathToUnixPath(options.root) - optionRoot = new URL(`file://${optionRoot}`).pathname + optionRoot = new URL(`file://${options.root}`).pathname } else { optionRoot = options.root } @@ -92,8 +91,7 @@ export const serveStatic = ( if (options.path) { if (options.path.startsWith('/')) { absolutePath = true - optionPath = windowsPathToUnixPath(options.path) - optionPath = new URL(`file://${optionPath}`).pathname + optionPath = new URL(`file://${options.path}`).pathname } else { optionPath = options.path } @@ -143,6 +141,8 @@ export const serveStatic = ( stats = getStats(path) } + path = windowsPathToUnixPath(path) + if (!stats) { await options.onNotFound?.(path, c) return next() From 54ecac397a99d293efd823015555e58b649110d0 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 07:59:48 +0900 Subject: [PATCH 08/16] correct handling paths for Windows --- src/serve-static.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 03a6a27..d0bb86e 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -82,6 +82,7 @@ export const serveStatic = ( if (options.root) { if (isAbsolutePath(options.root)) { absolutePath = true + optionRoot = windowsPathToUnixPath(options.root) optionRoot = new URL(`file://${options.root}`).pathname } else { optionRoot = options.root @@ -89,9 +90,10 @@ export const serveStatic = ( } if (options.path) { - if (options.path.startsWith('/')) { + if (isAbsolutePath(options.path)) { absolutePath = true - optionPath = new URL(`file://${options.path}`).pathname + optionPath = windowsPathToUnixPath(options.path) + optionPath = new URL(`file://${optionPath}`).pathname } else { optionPath = options.path } @@ -141,8 +143,6 @@ export const serveStatic = ( stats = getStats(path) } - path = windowsPathToUnixPath(path) - if (!stats) { await options.onNotFound?.(path, c) return next() From d5c10025b85f30b5960996364efe7a0875e77c61 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 08:04:17 +0900 Subject: [PATCH 09/16] typo --- src/serve-static.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index d0bb86e..4005c51 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -83,7 +83,7 @@ export const serveStatic = ( if (isAbsolutePath(options.root)) { absolutePath = true optionRoot = windowsPathToUnixPath(options.root) - optionRoot = new URL(`file://${options.root}`).pathname + optionRoot = new URL(`file://${optionRoot}`).pathname } else { optionRoot = options.root } From 52bd0e63f228c221937b2fdb3c88afb6472e6eba Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Tue, 15 Jul 2025 08:38:08 +0900 Subject: [PATCH 10/16] update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ac509f..fd046c3 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ import { serveStatic } from '@hono/node-server/serve-static' app.use('/static/*', serveStatic({ root: './' })) ``` -Note that `root` must be _relative_ to the current working directory from which the app was started. Absolute paths are not supported. +If using a relative path, `root` will be relative to the current working directory from which the app was started. This can cause confusion when running your application locally. From ee6159cf8fac7fd7df8171e2b561f7ea8923e170 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Wed, 16 Jul 2025 11:58:01 +0900 Subject: [PATCH 11/16] simplify the implementation --- src/serve-static.ts | 87 +++++++++++++-------------------------- test/serve-static.test.ts | 32 ++++++++++++-- 2 files changed, 57 insertions(+), 62 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 4005c51..8586697 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -1,8 +1,8 @@ import type { Context, Env, MiddlewareHandler } from 'hono' -import { getFilePath, getFilePathWithoutDefaultDocument } from 'hono/utils/filepath' import { getMimeType } from 'hono/utils/mime' -import { createReadStream, lstatSync } from 'node:fs' import type { ReadStream, Stats } from 'node:fs' +import { createReadStream, lstatSync } from 'node:fs' +import { join, resolve } from 'node:path' export type ServeStaticOptions = { /** @@ -44,14 +44,6 @@ const createStreamBody = (stream: ReadStream) => { return body } -const addCurrentDirPrefix = (path: string) => { - return `./${path}` -} - -const addRootPrefix = (path: string) => { - return `/${path}` -} - const getStats = (path: string) => { let stats: Stats | undefined try { @@ -60,44 +52,12 @@ const getStats = (path: string) => { return stats } -const isAbsolutePath = (path: string) => { - const isUnixAbsolutePath = path.startsWith('/') - const hasDriveLetter = /^[a-zA-Z]:\\/.test(path) - const isUncPath = /^\\\\[^\\]+\\[^\\]+/.test(path) - return isUnixAbsolutePath || hasDriveLetter || isUncPath -} - -const windowsPathToUnixPath = (path: string) => { - return path.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/') -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any export const serveStatic = ( options: ServeStaticOptions = { root: '' } ): MiddlewareHandler => { - let absolutePath = false - let optionRoot: string - let optionPath: string - - if (options.root) { - if (isAbsolutePath(options.root)) { - absolutePath = true - optionRoot = windowsPathToUnixPath(options.root) - optionRoot = new URL(`file://${optionRoot}`).pathname - } else { - optionRoot = options.root - } - } - - if (options.path) { - if (isAbsolutePath(options.path)) { - absolutePath = true - optionPath = windowsPathToUnixPath(options.path) - optionPath = new URL(`file://${optionPath}`).pathname - } else { - optionPath = options.path - } - } + const optionRoot = options.root || '.' + const optionPath = options.path return async (c, next) => { // Do nothing if Response is already set @@ -108,35 +68,44 @@ export const serveStatic = ( let filename: string try { + const rawPath = optionPath ?? c.req.path + // Prevent encoded path traversal attacks + if (!optionPath) { + const decodedPath = decodeURIComponent(rawPath) + if (decodedPath.includes('..')) { + await options.onNotFound?.(rawPath, c) + return next() + } + } filename = optionPath ?? decodeURIComponent(c.req.path) } catch { await options.onNotFound?.(c.req.path, c) return next() } - let path = getFilePathWithoutDefaultDocument({ - filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename, - root: optionRoot, - }) + const requestPath = options.rewriteRequestPath + ? options.rewriteRequestPath(filename, c) + : filename + const rootResolved = resolve(optionRoot) + let path: string - if (path) { - path = absolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) + if (optionPath) { + // Use path option directly if specified + path = resolve(optionPath) } else { - return next() + // Build with root + requestPath + path = resolve(join(optionRoot, requestPath)) } let stats = getStats(path) if (stats && stats.isDirectory()) { - path = getFilePath({ - filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename, - root: optionRoot, - defaultDocument: options.index ?? 'index.html', - }) + const indexFile = options.index ?? 'index.html' + path = resolve(join(path, indexFile)) - if (path) { - path = absolutePath ? addRootPrefix(path) : addCurrentDirPrefix(path) - } else { + // Security check: prevent path traversal attacks + if (!optionPath && !path.startsWith(rootResolved)) { + await options.onNotFound?.(path, c) return next() } diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 5af4bd7..050c45a 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -68,7 +68,7 @@ describe('Serve Static Middleware', () => { expect(res.status).toBe(200) expect(res.text).toBe('

Hello Hono

') expect(res.headers['content-type']).toBe('text/html; charset=utf-8') - expect(res.headers['x-custom']).toBe('Found the file at ./test/assets/static/index.html') + expect(res.headers['x-custom']).toMatch(/Found the file at .*\/test\/assets\/static\/index\.html$/) }) it('Should return hono.html', async () => { @@ -167,8 +167,8 @@ describe('Serve Static Middleware', () => { it('Should handle the `onNotFound` option', async () => { const res = await request(server).get('/on-not-found/foo.txt') expect(res.status).toBe(404) - expect(notFoundMessage).toBe( - './not-found/on-not-found/foo.txt is not found, request to /on-not-found/foo.txt' + expect(notFoundMessage).toMatch( + /.*\/not-found\/on-not-found\/foo\.txt is not found, request to \/on-not-found\/foo\.txt$/ ) }) @@ -260,4 +260,30 @@ describe('Serve Static Middleware', () => { }) }) }) + + describe('Security tests', () => { + const app = new Hono() + const server = createAdaptorServer(app) + app.use('/static/*', serveStatic({ root: './test/assets' })) + + it('Should prevent path traversal attacks with double dots', async () => { + const res = await request(server).get('/static/../secret.txt') + expect(res.status).toBe(404) + }) + + it('Should prevent path traversal attacks with multiple levels', async () => { + const res = await request(server).get('/static/../../package.json') + expect(res.status).toBe(404) + }) + + it('Should prevent path traversal attacks with mixed separators', async () => { + const res = await request(server).get('/static/..\\..\\package.json') + expect(res.status).toBe(404) + }) + + it('Should prevent path traversal attacks with encoded dots', async () => { + const res = await request(server).get('/static/%2e%2e%2fsecret.txt') + expect(res.status).toBe(404) + }) + }) }) From 1737089415f8bb99ad6537cd613ede170c4d7c18 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Wed, 16 Jul 2025 12:03:38 +0900 Subject: [PATCH 12/16] format --- test/serve-static.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 050c45a..f9be607 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -68,7 +68,9 @@ describe('Serve Static Middleware', () => { expect(res.status).toBe(200) expect(res.text).toBe('

Hello Hono

') expect(res.headers['content-type']).toBe('text/html; charset=utf-8') - expect(res.headers['x-custom']).toMatch(/Found the file at .*\/test\/assets\/static\/index\.html$/) + expect(res.headers['x-custom']).toMatch( + /Found the file at .*\/test\/assets\/static\/index\.html$/ + ) }) it('Should return hono.html', async () => { From ff18658e07af692f90f18aa040de06aa72059a43 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Wed, 16 Jul 2025 12:10:50 +0900 Subject: [PATCH 13/16] fixed file pathes in the test --- test/serve-static.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index f9be607..f3325e1 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -69,7 +69,7 @@ describe('Serve Static Middleware', () => { expect(res.text).toBe('

Hello Hono

') expect(res.headers['content-type']).toBe('text/html; charset=utf-8') expect(res.headers['x-custom']).toMatch( - /Found the file at .*\/test\/assets\/static\/index\.html$/ + /Found the file at .*[\/\\]test[\/\\]assets[\/\\]static[\/\\]index\.html$/ ) }) @@ -170,7 +170,7 @@ describe('Serve Static Middleware', () => { const res = await request(server).get('/on-not-found/foo.txt') expect(res.status).toBe(404) expect(notFoundMessage).toMatch( - /.*\/not-found\/on-not-found\/foo\.txt is not found, request to \/on-not-found\/foo\.txt$/ + /.*[\/\\]not-found[\/\\]on-not-found[\/\\]foo\.txt is not found, request to \/on-not-found\/foo\.txt$/ ) }) From f6f5b9034936d8589b8d5f7733a1105bf5a0a3de Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Thu, 17 Jul 2025 07:32:50 +0900 Subject: [PATCH 14/16] resolve if both `root` and `path` set Co-authored-by: Taku Amano --- src/serve-static.ts | 2 +- test/serve-static.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 8586697..8a0acc7 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -91,7 +91,7 @@ export const serveStatic = ( if (optionPath) { // Use path option directly if specified - path = resolve(optionPath) + path = resolve(optionRoot, optionPath) } else { // Build with root + requestPath path = resolve(join(optionRoot, requestPath)) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index f3325e1..73d9895 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -263,6 +263,34 @@ describe('Serve Static Middleware', () => { }) }) + describe('Root and path combination tests', () => { + const rootPaths = [ + path.join(__dirname, 'assets'), + path.join(__dirname, 'assets'), + __dirname + path.sep + '..' + path.sep + 'test' + path.sep + 'assets', + ] + rootPaths.forEach((root) => { + describe(root, () => { + const app = new Hono() + const server = createAdaptorServer(app) + + app.use( + '/favicon.ico', + serveStatic({ + root: './test/assets', + path: 'favicon.ico', + }) + ) + + it('Should return 200 response if both root and path set', async () => { + const res = await request(server).get('/favicon.ico') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('image/x-icon') + }) + }) + }) + }) + describe('Security tests', () => { const app = new Hono() const server = createAdaptorServer(app) From 8f150535af3b37b2583f5de1c691aae9d4359a0d Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Thu, 17 Jul 2025 07:38:01 +0900 Subject: [PATCH 15/16] resolve root when initialize the app Co-authored-by: Taku Amano --- src/serve-static.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 8a0acc7..e3b108d 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -56,7 +56,7 @@ const getStats = (path: string) => { export const serveStatic = ( options: ServeStaticOptions = { root: '' } ): MiddlewareHandler => { - const optionRoot = options.root || '.' + const root = resolve(options.root || '.') const optionPath = options.path return async (c, next) => { @@ -86,15 +86,14 @@ export const serveStatic = ( const requestPath = options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename - const rootResolved = resolve(optionRoot) let path: string if (optionPath) { // Use path option directly if specified - path = resolve(optionRoot, optionPath) + path = resolve(root, optionPath) } else { // Build with root + requestPath - path = resolve(join(optionRoot, requestPath)) + path = resolve(join(root, requestPath)) } let stats = getStats(path) @@ -104,7 +103,7 @@ export const serveStatic = ( path = resolve(join(path, indexFile)) // Security check: prevent path traversal attacks - if (!optionPath && !path.startsWith(rootResolved)) { + if (!optionPath && !path.startsWith(root)) { await options.onNotFound?.(path, c) return next() } From d26e6eed0dff681e11a4a5f104716a05abeac09f Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 18 Jul 2025 13:14:12 +0900 Subject: [PATCH 16/16] Currect the behavior if root is set and path is `/favicon.ico` --- src/serve-static.ts | 15 ++++++--------- test/serve-static.test.ts | 33 ++++++++++++++++++--------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index e3b108d..d841f75 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -86,15 +86,12 @@ export const serveStatic = ( const requestPath = options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename - let path: string - - if (optionPath) { - // Use path option directly if specified - path = resolve(root, optionPath) - } else { - // Build with root + requestPath - path = resolve(join(root, requestPath)) - } + + let path = optionPath + ? options.root + ? resolve(join(root, optionPath)) + : optionPath + : resolve(join(root, requestPath)) let stats = getStats(path) diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 73d9895..1e9c5a3 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -269,23 +269,26 @@ describe('Serve Static Middleware', () => { path.join(__dirname, 'assets'), __dirname + path.sep + '..' + path.sep + 'test' + path.sep + 'assets', ] + const optionPaths = ['favicon.ico', '/favicon.ico'] rootPaths.forEach((root) => { - describe(root, () => { - const app = new Hono() - const server = createAdaptorServer(app) - - app.use( - '/favicon.ico', - serveStatic({ - root: './test/assets', - path: 'favicon.ico', + optionPaths.forEach((optionPath) => { + describe(`${root} + ${optionPath}`, () => { + const app = new Hono() + const server = createAdaptorServer(app) + + app.use( + '/favicon.ico', + serveStatic({ + root, + path: optionPath, + }) + ) + + it('Should return 200 response if both root and path set', async () => { + const res = await request(server).get('/favicon.ico') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('image/x-icon') }) - ) - - it('Should return 200 response if both root and path set', async () => { - const res = await request(server).get('/favicon.ico') - expect(res.status).toBe(200) - expect(res.headers['content-type']).toBe('image/x-icon') }) }) })