Skip to content

Commit 1196f14

Browse files
yusukebeusualoma
andauthored
feat(serve-static): use join to correct path resolution (#4291)
* feat(serve-static): use `join` to correct path resolution * add windows tests * implement `defaultJoin` * add retrun type * bun test support win * remove warning add doc * skip test * remove warning * fix the styles * make `defaultJoin` behave like `path.posix.join` * refactored and make it supports only Windows * use `join` from 'node:path' for Deno * simplify the `defaultJoin` Co-authored-by: Taku Amano <[email protected]> --------- Co-authored-by: Taku Amano <[email protected]>
1 parent fbd2ff1 commit 1196f14

File tree

8 files changed

+125
-106
lines changed

8 files changed

+125
-106
lines changed

runtime-tests/bun/index.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ describe('Serve Static Middleware', () => {
124124
expect(res.status).toBe(404)
125125
expect(res.headers.get('X-Custom')).toBe('Bun')
126126
expect(onNotFound).toHaveBeenCalledWith(
127-
'./runtime-tests/bun/favicon-notfound.ico',
127+
process.platform === 'win32'
128+
? 'runtime-tests\\bun\\favicon-notfound.ico'
129+
: 'runtime-tests/bun/favicon-notfound.ico',
128130
expect.anything()
129131
)
130132
})

src/adapter/bun/serve-static.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/ban-ts-comment */
22
import { stat } from 'node:fs/promises'
3+
import { join } from 'node:path'
34
import { serveStatic as baseServeStatic } from '../../middleware/serve-static'
45
import type { ServeStaticOptions } from '../../middleware/serve-static'
56
import type { Env, MiddlewareHandler } from '../../types'
@@ -9,14 +10,10 @@ export const serveStatic = <E extends Env = Env>(
910
): MiddlewareHandler => {
1011
return async function serveStatic(c, next) {
1112
const getContent = async (path: string) => {
12-
path = path.startsWith('/') ? path : `./${path}`
1313
// @ts-ignore
1414
const file = Bun.file(path)
1515
return (await file.exists()) ? file : null
1616
}
17-
const pathResolve = (path: string) => {
18-
return path.startsWith('/') ? path : `./${path}`
19-
}
2017
const isDir = async (path: string) => {
2118
let isDir
2219
try {
@@ -28,7 +25,7 @@ export const serveStatic = <E extends Env = Env>(
2825
return baseServeStatic({
2926
...options,
3027
getContent,
31-
pathResolve,
28+
join,
3229
isDir,
3330
})(c, next)
3431
}

src/adapter/cloudflare-workers/serve-static.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ describe('ServeStatic Middleware', () => {
7979
expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8')
8080
})
8181

82-
it('Should return index.html', async () => {
82+
// Serve static on Cloudflare Workers cannot determine whether the target path is a directory or not
83+
it.skip('Should return index.html', async () => {
8384
const res = await app.request('http://localhost/static/top')
8485
expect(res.status).toBe(200)
8586
expect(await res.text()).toBe('<h1>Top</h1>')

src/adapter/deno/serve-static.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { join } from 'node:path'
12
import type { ServeStaticOptions } from '../../middleware/serve-static'
23
import { serveStatic as baseServeStatic } from '../../middleware/serve-static'
34
import type { Env, MiddlewareHandler } from '../../types'
@@ -23,9 +24,6 @@ export const serveStatic = <E extends Env = Env>(
2324
return null
2425
}
2526
}
26-
const pathResolve = (path: string) => {
27-
return path.startsWith('/') ? path : `./${path}`
28-
}
2927
const isDir = (path: string) => {
3028
let isDir
3129
try {
@@ -34,11 +32,10 @@ export const serveStatic = <E extends Env = Env>(
3432
} catch {}
3533
return isDir
3634
}
37-
3835
return baseServeStatic({
3936
...options,
4037
getContent,
41-
pathResolve,
38+
join,
4239
isDir,
4340
})(c, next)
4441
}

src/middleware/serve-static/index.test.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ describe('Serve Static Middleware', () => {
1212

1313
const serveStatic = baseServeStatic({
1414
getContent,
15-
pathResolve: (path) => {
16-
return `./${path}`
17-
},
1815
isDir: (path) => {
19-
return path === 'static/hello.world'
16+
if (path === 'static/sub' || path === 'static/hello.world') {
17+
return true
18+
}
2019
},
2120
onFound: (path, c) => {
2221
if (path.endsWith('hello.html')) {
@@ -36,36 +35,29 @@ describe('Serve Static Middleware', () => {
3635
expect(res.status).toBe(200)
3736
expect(res.headers.get('Content-Encoding')).toBeNull()
3837
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
39-
expect(await res.text()).toBe('Hello in ./static/hello.html')
40-
expect(res.headers.get('X-Custom')).toBe('Found the file at ./static/hello.html')
38+
expect(await res.text()).toBe('Hello in static/hello.html')
39+
expect(res.headers.get('X-Custom')).toBe('Found the file at static/hello.html')
4140
})
4241

4342
it('Should return 200 response - /static/sub', async () => {
4443
const res = await app.request('/static/sub')
4544
expect(res.status).toBe(200)
4645
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
47-
expect(await res.text()).toBe('Hello in ./static/sub/index.html')
48-
})
49-
50-
it('Should return 200 response - /static/helloworld', async () => {
51-
const res = await app.request('/static/helloworld')
52-
expect(res.status).toBe(200)
53-
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
54-
expect(await res.text()).toBe('Hello in ./static/helloworld/index.html')
46+
expect(await res.text()).toBe('Hello in static/sub/index.html')
5547
})
5648

5749
it('Should return 200 response - /static/hello.world', async () => {
5850
const res = await app.request('/static/hello.world')
5951
expect(res.status).toBe(200)
6052
expect(res.headers.get('Content-Type')).toMatch(/^text\/html/)
61-
expect(await res.text()).toBe('Hello in ./static/hello.world/index.html')
53+
expect(await res.text()).toBe('Hello in static/hello.world/index.html')
6254
})
6355

6456
it('Should decode URI strings - /static/%E7%82%8E.txt', async () => {
6557
const res = await app.request('/static/%E7%82%8E.txt')
6658
expect(res.status).toBe(200)
6759
expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/)
68-
expect(await res.text()).toBe('Hello in ./static/炎.txt')
60+
expect(await res.text()).toBe('Hello in static/炎.txt')
6961
})
7062

7163
it('Should return 404 response - /static/not-found.txt', async () => {
@@ -228,15 +220,10 @@ describe('Serve Static Middleware', () => {
228220
})
229221

230222
describe('Changing root path', () => {
231-
const pathResolve = (path: string) => {
232-
return path.startsWith('/') ? path : `./${path}`
233-
}
234-
235223
it('Should return the content with absolute root path', async () => {
236224
const app = new Hono()
237225
const serveStatic = baseServeStatic({
238226
getContent,
239-
pathResolve,
240227
root: '/home/hono/child',
241228
})
242229
app.get('/static/*', serveStatic)
@@ -249,7 +236,6 @@ describe('Serve Static Middleware', () => {
249236
const app = new Hono()
250237
const serveStatic = baseServeStatic({
251238
getContent,
252-
pathResolve,
253239
root: '/home/hono/../parent',
254240
})
255241
app.get('/static/*', serveStatic)
@@ -262,26 +248,24 @@ describe('Serve Static Middleware', () => {
262248
const app = new Hono()
263249
const serveStatic = baseServeStatic({
264250
getContent,
265-
pathResolve,
266251
root: '../home/hono',
267252
})
268253
app.get('/static/*', serveStatic)
269254

270255
const res = await app.request('/static/html/hello.html')
271-
expect(await res.text()).toBe('Hello in ./../home/hono/static/html/hello.html')
256+
expect(await res.text()).toBe('Hello in ../home/hono/static/html/hello.html')
272257
})
273258

274259
it('Should not allow directory traversal with . as relative path', async () => {
275260
const app = new Hono()
276261
const serveStatic = baseServeStatic({
277262
getContent,
278-
pathResolve,
279263
root: '.',
280264
})
281265
app.get('*', serveStatic)
282266

283267
const res = await app.request('///etc/passwd')
284-
expect(res.status).toBe(404)
268+
expect(await res.text()).toBe('Hello in etc/passwd')
285269
})
286270
})
287271
})

src/middleware/serve-static/index.ts

Lines changed: 31 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import type { Context, Data } from '../../context'
77
import type { Env, MiddlewareHandler } from '../../types'
88
import { COMPRESSIBLE_CONTENT_TYPE_REGEX } from '../../utils/compress'
9-
import { getFilePath, getFilePathWithoutDefaultDocument } from '../../utils/filepath'
109
import { getMimeType } from '../../utils/mime'
10+
import { defaultJoin } from './path'
1111

1212
export type ServeStaticOptions<E extends Env = Env> = {
1313
root?: string
@@ -27,100 +27,63 @@ const ENCODINGS = {
2727
const ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS) as (keyof typeof ENCODINGS)[]
2828

2929
const DEFAULT_DOCUMENT = 'index.html'
30-
const defaultPathResolve = (path: string) => path
31-
32-
const isAbsolutePath = (path: string) => {
33-
const isUnixAbsolutePath = path.startsWith('/')
34-
const hasDriveLetter = /^[a-zA-Z]:\\/.test(path)
35-
const isUncPath = /^\\\\[^\\]+\\[^\\]+/.test(path)
36-
return isUnixAbsolutePath || hasDriveLetter || isUncPath
37-
}
38-
39-
const windowsPathToUnixPath = (path: string) => {
40-
return path.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/')
41-
}
4230

4331
/**
4432
* This middleware is not directly used by the user. Create a wrapper specifying `getContent()` by the environment such as Deno or Bun.
4533
*/
4634
export const serveStatic = <E extends Env = Env>(
4735
options: ServeStaticOptions<E> & {
4836
getContent: (path: string, c: Context<E>) => Promise<Data | Response | null>
37+
/**
38+
*
39+
* `join` option according to the runtime. Example `import { join } from 'node:path`. If not specified, it will fall back to the default join function.`
40+
*/
41+
join?: (...paths: string[]) => string
42+
/**
43+
* @deprecated Currently, `pathResolve` is no longer used.
44+
*/
4945
pathResolve?: (path: string) => string
5046
isDir?: (path: string) => boolean | undefined | Promise<boolean | undefined>
5147
}
5248
): MiddlewareHandler => {
53-
let isAbsoluteRoot = false
54-
let root: string
55-
56-
if (options.root) {
57-
if (isAbsolutePath(options.root)) {
58-
isAbsoluteRoot = true
59-
root = windowsPathToUnixPath(options.root)
60-
root = new URL(`file://${root}`).pathname
61-
} else {
62-
root = options.root
63-
}
64-
}
49+
const root = options.root ?? './'
50+
const optionPath = options.path
51+
const join = options.join ?? defaultJoin
6552

6653
return async (c, next) => {
6754
// Do nothing if Response is already set
6855
if (c.finalized) {
69-
await next()
70-
return
56+
return next()
7157
}
7258

73-
let filename = options.path ?? decodeURI(c.req.path)
74-
filename = options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename
75-
76-
// If it was Directory, force `/` on the end.
77-
if (!filename.endsWith('/') && options.isDir) {
78-
const path = getFilePathWithoutDefaultDocument({
79-
filename,
80-
root,
81-
})
82-
if (path && (await options.isDir(path))) {
83-
filename += '/'
59+
let filename: string
60+
61+
if (options.path) {
62+
filename = options.path
63+
} else {
64+
try {
65+
filename = decodeURIComponent(c.req.path)
66+
if (/(?:^|[\/\\])\.\.(?:$|[\/\\])/.test(filename)) {
67+
throw new Error()
68+
}
69+
} catch {
70+
await options.onNotFound?.(c.req.path, c)
71+
return next()
8472
}
8573
}
8674

87-
let path = getFilePath({
88-
filename,
75+
let path = join(
8976
root,
90-
defaultDocument: DEFAULT_DOCUMENT,
91-
})
92-
93-
if (!path) {
94-
return await next()
95-
}
77+
!optionPath && options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename
78+
)
9679

97-
if (isAbsoluteRoot) {
98-
path = '/' + path
80+
if (options.isDir && (await options.isDir(path))) {
81+
path = join(path, DEFAULT_DOCUMENT)
9982
}
10083

10184
const getContent = options.getContent
102-
const pathResolve = options.pathResolve ?? defaultPathResolve
103-
path = pathResolve(path)
10485
let content = await getContent(path, c)
10586

106-
if (!content) {
107-
let pathWithoutDefaultDocument = getFilePathWithoutDefaultDocument({
108-
filename,
109-
root,
110-
})
111-
if (!pathWithoutDefaultDocument) {
112-
return await next()
113-
}
114-
pathWithoutDefaultDocument = pathResolve(pathWithoutDefaultDocument)
115-
116-
if (pathWithoutDefaultDocument !== path) {
117-
content = await getContent(pathWithoutDefaultDocument, c)
118-
if (content) {
119-
path = pathWithoutDefaultDocument
120-
}
121-
}
122-
}
123-
12487
if (content instanceof Response) {
12588
return c.newResponse(content.body, content)
12689
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { join as posixJoin } from 'node:path/posix'
2+
import { defaultJoin } from './path'
3+
4+
describe('defaultJoin', () => {
5+
describe('Comparison with node:path/posix.join', () => {
6+
it('Should behave like path.posix.join for all path operations', () => {
7+
const testCases = [
8+
// Basic path joining
9+
['/home/yusuke/work/app/public', 'static/main.html'],
10+
['public', 'sub/', 'file.html'],
11+
['', 'file.html'],
12+
['public', ''],
13+
['public/', 'file.html'],
14+
['public', 'static', 'main.html'],
15+
['assets', 'images', 'logo.png'],
16+
17+
// Parent directory references
18+
['public', '../parent/file.html'],
19+
['public', '../../grandparent/file.html'],
20+
['/abs/path', '../relative.html'],
21+
['a/b', '../c'],
22+
['a/b/c', '../../d'],
23+
24+
// Current directory references
25+
['./public', 'static/main.html'],
26+
['public', './file.html'],
27+
['./public', '/absolute/path.html'],
28+
['.', 'file.html'],
29+
['public/.', 'file.html'],
30+
31+
// Edge cases
32+
[],
33+
['.'],
34+
[''],
35+
['/'],
36+
['a', 'b', 'c'],
37+
38+
// Backslash handling (security)
39+
['static', 'test\\file.txt'],
40+
['public', 'path\\with\\backslash'],
41+
]
42+
43+
testCases.forEach(([...args]) => {
44+
const expected = posixJoin(...args)
45+
const actual = defaultJoin(...args)
46+
expect(actual).toBe(expected)
47+
})
48+
})
49+
})
50+
})

0 commit comments

Comments
 (0)