mirror of
https://github.com/honojs/hono.git
synced 2024-11-21 18:18:57 +01:00
feat(serve-static): support absolute root (#3420)
* feat(serve-static): support absolute root * add bun runtime test * don't use `Request` * add code for deno * remove unnecessay `console.log` * use `normalizeFilePath` instead of `URL` * use `URL` for `options.root` * don't allow directory traversal and fix the behavior when root including `../`
This commit is contained in:
parent
7779fca9a5
commit
246a06aa1d
@ -1,4 +1,6 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { stream, streamSSE } from '../..//src/helper/streaming'
|
||||
import { serveStatic, toSSG } from '../../src/adapter/bun'
|
||||
import { createBunWebSocket } from '../../src/adapter/bun/websocket'
|
||||
@ -11,7 +13,6 @@ import { Hono } from '../../src/index'
|
||||
import { jsx } from '../../src/jsx'
|
||||
import { basicAuth } from '../../src/middleware/basic-auth'
|
||||
import { jwt } from '../../src/middleware/jwt'
|
||||
import { HonoRequest } from '../../src/request'
|
||||
|
||||
// Test just only minimal patterns.
|
||||
// Because others are tested well in Cloudflare Workers environment already.
|
||||
@ -42,7 +43,7 @@ describe('Basic', () => {
|
||||
|
||||
describe('Environment Variables', () => {
|
||||
it('Should return the environment variable', async () => {
|
||||
const c = new Context(new HonoRequest(new Request('http://localhost/')))
|
||||
const c = new Context(new Request('http://localhost/'))
|
||||
const { NAME } = env<{ NAME: string }>(c)
|
||||
expect(NAME).toBe('Bun')
|
||||
})
|
||||
@ -107,6 +108,8 @@ describe('Serve Static Middleware', () => {
|
||||
})
|
||||
)
|
||||
|
||||
app.all('/static-absolute-root/*', serveStatic({ root: path.dirname(__filename) }))
|
||||
|
||||
beforeEach(() => onNotFound.mockClear())
|
||||
|
||||
it('Should return static file correctly', async () => {
|
||||
@ -157,6 +160,13 @@ describe('Serve Static Middleware', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('Hi\n')
|
||||
})
|
||||
|
||||
it('Should return 200 response - /static-absolute-root/plain.txt', async () => {
|
||||
const res = await app.request('http://localhost/static-absolute-root/plain.txt')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('Bun!')
|
||||
expect(onNotFound).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Bun support WebCrypto since v0.2.2
|
||||
@ -310,8 +320,6 @@ describe('WebSockets Helper', () => {
|
||||
expect(receivedMessage).toBe(message)
|
||||
})
|
||||
})
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
async function deleteDirectory(dirPath) {
|
||||
if (
|
||||
|
1
runtime-tests/bun/static-absolute-root/plain.txt
Normal file
1
runtime-tests/bun/static-absolute-root/plain.txt
Normal file
@ -0,0 +1 @@
|
||||
Bun!
|
2
runtime-tests/deno/.vscode/settings.json
vendored
2
runtime-tests/deno/.vscode/settings.json
vendored
@ -1,7 +1,7 @@
|
||||
{
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"deno.enable": true
|
||||
}
|
||||
|
@ -13,7 +13,8 @@
|
||||
],
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@^1.0.3",
|
||||
"@std/path": "jsr:@std/path@^1.0.3",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.1",
|
||||
"hono/jsx/jsx-runtime": "../../src/jsx/jsx-runtime.ts"
|
||||
}
|
||||
}
|
||||
}
|
@ -2,9 +2,12 @@
|
||||
"version": "3",
|
||||
"packages": {
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@^1.0.3": "jsr:@std/assert@1.0.3",
|
||||
"jsr:@std/assert@^1.0.3": "jsr:@std/assert@1.0.5",
|
||||
"jsr:@std/assert@^1.0.4": "jsr:@std/assert@1.0.5",
|
||||
"jsr:@std/internal@^1.0.2": "jsr:@std/internal@1.0.2",
|
||||
"jsr:@std/testing@^1.0.1": "jsr:@std/testing@1.0.1"
|
||||
"jsr:@std/internal@^1.0.3": "jsr:@std/internal@1.0.3",
|
||||
"jsr:@std/path@^1.0.3": "jsr:@std/path@1.0.6",
|
||||
"jsr:@std/testing@^1.0.1": "jsr:@std/testing@1.0.2"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.3": {
|
||||
@ -13,14 +16,32 @@
|
||||
"jsr:@std/internal@^1.0.2"
|
||||
]
|
||||
},
|
||||
"@std/assert@1.0.5": {
|
||||
"integrity": "e37da8e4033490ce613eec4ac1d78dba1faf5b02a3f6c573a28f15365b9b440f",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal@^1.0.3"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.2": {
|
||||
"integrity": "f4cabe2021352e8bfc24e6569700df87bf070914fc38d4b23eddd20108ac4495"
|
||||
},
|
||||
"@std/internal@1.0.3": {
|
||||
"integrity": "208e9b94a3d5649bd880e9ca38b885ab7651ab5b5303a56ed25de4755fb7b11e"
|
||||
},
|
||||
"@std/path@1.0.6": {
|
||||
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
|
||||
},
|
||||
"@std/testing@1.0.1": {
|
||||
"integrity": "9c25841137ee818933e1722091bb9ed5fdc251c35e84c97979a52196bdb6c5c3",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.3"
|
||||
]
|
||||
},
|
||||
"@std/testing@1.0.2": {
|
||||
"integrity": "9e8a7f4e26c219addabe7942d09c3450fa0a74e9662341961bc0ef502274dec3",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.4"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -28,6 +49,7 @@
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.3",
|
||||
"jsr:@std/path@^1.0.3",
|
||||
"jsr:@std/testing@^1.0.1"
|
||||
]
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { assertEquals, assertMatch } from '@std/assert'
|
||||
import { dirname, fromFileUrl } from '@std/path'
|
||||
import { assertSpyCall, assertSpyCalls, spy } from '@std/testing/mock'
|
||||
import { serveStatic } from '../../src/adapter/deno/index.ts'
|
||||
import { Hono } from '../../src/hono.ts'
|
||||
@ -94,6 +95,16 @@ Deno.test('Serve Static middleware', async () => {
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/static-absolute-root/*', serveStatic({ root: dirname(fromFileUrl(import.meta.url)) }))
|
||||
|
||||
app.get(
|
||||
'/static/*',
|
||||
serveStatic({
|
||||
root: './runtime-tests/deno',
|
||||
onNotFound,
|
||||
})
|
||||
)
|
||||
|
||||
let res = await app.request('http://localhost/favicon.ico')
|
||||
assertEquals(res.status, 200)
|
||||
assertEquals(res.headers.get('Content-Type'), 'image/x-icon')
|
||||
@ -132,6 +143,10 @@ Deno.test('Serve Static middleware', async () => {
|
||||
res = await app.request('http://localhost/static/hello.world')
|
||||
assertEquals(res.status, 200)
|
||||
assertEquals(await res.text(), 'Hi\n')
|
||||
|
||||
res = await app.request('http://localhost/static-absolute-root/plain.txt')
|
||||
assertEquals(res.status, 200)
|
||||
assertEquals(await res.text(), 'Deno!')
|
||||
})
|
||||
|
||||
Deno.test('JWT Authentication middleware', async () => {
|
||||
|
1
runtime-tests/deno/static-absolute-root/plain.txt
Normal file
1
runtime-tests/deno/static-absolute-root/plain.txt
Normal file
@ -0,0 +1 @@
|
||||
Deno!
|
@ -9,13 +9,13 @@ export const serveStatic = <E extends Env = Env>(
|
||||
): MiddlewareHandler => {
|
||||
return async function serveStatic(c, next) {
|
||||
const getContent = async (path: string) => {
|
||||
path = `./${path}`
|
||||
path = path.startsWith('/') ? path : `./${path}`
|
||||
// @ts-ignore
|
||||
const file = Bun.file(path)
|
||||
return (await file.exists()) ? file : null
|
||||
}
|
||||
const pathResolve = (path: string) => {
|
||||
return `./${path}`
|
||||
return path.startsWith('/') ? path : `./${path}`
|
||||
}
|
||||
const isDir = async (path: string) => {
|
||||
let isDir
|
||||
|
@ -20,7 +20,7 @@ export const serveStatic = <E extends Env = Env>(
|
||||
}
|
||||
}
|
||||
const pathResolve = (path: string) => {
|
||||
return `./${path}`
|
||||
return path.startsWith('/') ? path : `./${path}`
|
||||
}
|
||||
const isDir = (path: string) => {
|
||||
let isDir
|
||||
|
@ -226,4 +226,62 @@ describe('Serve Static Middleware', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toBe(body)
|
||||
})
|
||||
|
||||
describe('Changing root path', () => {
|
||||
const pathResolve = (path: string) => {
|
||||
return path.startsWith('/') ? path : `./${path}`
|
||||
}
|
||||
|
||||
it('Should return the content with absolute root path', async () => {
|
||||
const app = new Hono()
|
||||
const serveStatic = baseServeStatic({
|
||||
getContent,
|
||||
pathResolve,
|
||||
root: '/home/hono/child',
|
||||
})
|
||||
app.get('/static/*', serveStatic)
|
||||
|
||||
const res = await app.request('/static/html/hello.html')
|
||||
expect(await res.text()).toBe('Hello in /home/hono/child/static/html/hello.html')
|
||||
})
|
||||
|
||||
it('Should traverse the directories with absolute root path', async () => {
|
||||
const app = new Hono()
|
||||
const serveStatic = baseServeStatic({
|
||||
getContent,
|
||||
pathResolve,
|
||||
root: '/home/hono/../parent',
|
||||
})
|
||||
app.get('/static/*', serveStatic)
|
||||
|
||||
const res = await app.request('/static/html/hello.html')
|
||||
expect(await res.text()).toBe('Hello in /home/parent/static/html/hello.html')
|
||||
})
|
||||
|
||||
it('Should treat the root path includes .. as relative path', async () => {
|
||||
const app = new Hono()
|
||||
const serveStatic = baseServeStatic({
|
||||
getContent,
|
||||
pathResolve,
|
||||
root: '../home/hono',
|
||||
})
|
||||
app.get('/static/*', serveStatic)
|
||||
|
||||
const res = await app.request('/static/html/hello.html')
|
||||
expect(await res.text()).toBe('Hello in ./../home/hono/static/html/hello.html')
|
||||
})
|
||||
|
||||
it('Should not allow directory traversal with . as relative path', async () => {
|
||||
const app = new Hono()
|
||||
const serveStatic = baseServeStatic({
|
||||
getContent,
|
||||
pathResolve,
|
||||
root: '.',
|
||||
})
|
||||
app.get('*', serveStatic)
|
||||
|
||||
const res = await app.request('///etc/passwd')
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -39,6 +39,18 @@ export const serveStatic = <E extends Env = Env>(
|
||||
isDir?: (path: string) => boolean | undefined | Promise<boolean | undefined>
|
||||
}
|
||||
): MiddlewareHandler => {
|
||||
let isAbsoluteRoot = false
|
||||
let root: string
|
||||
|
||||
if (options.root) {
|
||||
if (options.root.startsWith('/')) {
|
||||
isAbsoluteRoot = true
|
||||
root = new URL(`file://${options.root}`).pathname
|
||||
} else {
|
||||
root = options.root
|
||||
}
|
||||
}
|
||||
|
||||
return async (c, next) => {
|
||||
// Do nothing if Response is already set
|
||||
if (c.finalized) {
|
||||
@ -48,7 +60,6 @@ export const serveStatic = <E extends Env = Env>(
|
||||
|
||||
let filename = options.path ?? decodeURI(c.req.path)
|
||||
filename = options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename
|
||||
const root = options.root
|
||||
|
||||
// If it was Directory, force `/` on the end.
|
||||
if (!filename.endsWith('/') && options.isDir) {
|
||||
@ -71,6 +82,10 @@ export const serveStatic = <E extends Env = Env>(
|
||||
return await next()
|
||||
}
|
||||
|
||||
if (isAbsoluteRoot) {
|
||||
path = '/' + path
|
||||
}
|
||||
|
||||
const getContent = options.getContent
|
||||
const pathResolve = options.pathResolve ?? defaultPathResolve
|
||||
path = pathResolve(path)
|
||||
|
@ -52,5 +52,9 @@ export const getFilePathWithoutDefaultDocument = (
|
||||
let path = root ? root + '/' + filename : filename
|
||||
path = path.replace(/^\.?\//, '')
|
||||
|
||||
if (root[0] !== '/' && path[0] === '/') {
|
||||
return
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user