0
0
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:
Yusuke Wada 2024-09-24 12:23:35 +09:00 committed by GitHub
parent 7779fca9a5
commit 246a06aa1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 137 additions and 12 deletions

View File

@ -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 (

View File

@ -0,0 +1 @@
Bun!

View File

@ -1,7 +1,7 @@
{
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"deno.enable": true
}

View File

@ -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"
}
}
}

View File

@ -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"
]
}

View File

@ -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 () => {

View File

@ -0,0 +1 @@
Deno!

View File

@ -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

View File

@ -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

View File

@ -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)
})
})
})

View File

@ -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)

View File

@ -52,5 +52,9 @@ export const getFilePathWithoutDefaultDocument = (
let path = root ? root + '/' + filename : filename
path = path.replace(/^\.?\//, '')
if (root[0] !== '/' && path[0] === '/') {
return
}
return path
}