From e03352248f6eeed8e4822ff08a5e59474577f6bc Mon Sep 17 00:00:00 2001 From: Riya Amemiya Date: Fri, 17 Oct 2025 11:34:51 +0900 Subject: [PATCH 1/4] feat: add enableFallback option to allow route fallback Add enableFallback option that allows requests to fall through to other route handlers when static files are not found, instead of returning 404. This enables use cases where static file serving should not intercept other routes like .all() or API endpoints. --- bun.lock | 3 - src/index.ts | 165 +++++++++++++++++++++++++-------------------- src/types.ts | 11 +++ src/utils.ts | 1 - test/index.test.ts | 54 +++++++++++++++ 5 files changed, 158 insertions(+), 76 deletions(-) diff --git a/bun.lock b/bun.lock index 90a3c53..c35d444 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,6 @@ "@typescript-eslint/eslint-plugin": "^6.7.4", "elysia": "^1.4.11", "esbuild-fix-imports-plugin": "^1.0.22", - "esbuild-plugin-file-path-extensions": "^2.1.4", "eslint": "9.6.0", "fast-decode-uri-component": "^1.0.1", "tsup": "^8.1.0", @@ -256,8 +255,6 @@ "esbuild-fix-imports-plugin": ["esbuild-fix-imports-plugin@1.0.22", "", {}, "sha512-8Q8FDsnZgDwa+dHu0/bpU6gOmNrxmqgsIG1s7p1xtv6CQccRKc3Ja8o09pLNwjFgkOWtmwjS0bZmSWN7ATgdJQ=="], - "esbuild-plugin-file-path-extensions": ["esbuild-plugin-file-path-extensions@2.1.4", "", {}, "sha512-lNjylaAsJMprYg28zjUyBivP3y0ms9b7RJZ5tdhDUFLa3sCbqZw4wDnbFUSmnyZYWhCYDPxxp7KkXM2TXGw3PQ=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/config-array": "^0.17.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "9.6.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.0.1", "eslint-visitor-keys": "^4.0.0", "espree": "^10.1.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ElQkdLMEEqQNM9Njff+2Y4q2afHk7JpkPvrd7Xh7xefwgQynqPxwf55J7di9+MEibWUGdNjFF9ITG9Pck5M84w=="], diff --git a/src/index.ts b/src/index.ts index 45d3ed2..ebf853d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ import { Elysia, NotFoundError } from 'elysia' -import type { Stats } from 'fs' - import fastDecodeURI from 'fast-decode-uri-component' import { @@ -31,7 +29,8 @@ export async function staticPlugin({ extension = true, indexHTML = true, decodeURI, - silent + silent, + enableFallback = false }: StaticOptions = {}): Promise { if ( typeof process === 'undefined' || @@ -239,90 +238,112 @@ export async function staticPlugin({ } } - app.onError(() => {}).get( - `${prefix}/*`, - async ({ params, headers: requestHeaders }) => { - const pathName = path.join( - assets, - decodeURI - ? (fastDecodeURI(params['*']) ?? params['*']) - : params['*'] - ) + const serveStaticFile = async (pathName: string, requestHeaders?: Record) => { + if (shouldIgnore(pathName)) return null + + const cache = fileCache.get(pathName) + if (cache) return cache.clone() - if (shouldIgnore(pathName)) throw new NotFoundError() + const fileStat = await fs.stat(pathName).catch(() => null) + if (!fileStat) return null - const cache = fileCache.get(pathName) + if (!indexHTML && fileStat.isDirectory()) return null + + let file: NonNullable>> | undefined + + if (!isBun && indexHTML) { + const htmlPath = path.join(pathName, 'index.html') + const cache = fileCache.get(htmlPath) if (cache) return cache.clone() - try { - const fileStat = await fs.stat(pathName).catch(() => null) - if (!fileStat) throw new NotFoundError() + if (await fileExists(htmlPath)) + file = await getFile(htmlPath) + } - if (!indexHTML && fileStat.isDirectory()) - throw new NotFoundError() + if (!file && !fileStat.isDirectory() && (await fileExists(pathName))) + file = await getFile(pathName) - // @ts-ignore - let file: - | NonNullable>> - | undefined + if (!file) return null - if (!isBun && indexHTML) { - const htmlPath = path.join(pathName, 'index.html') - const cache = fileCache.get(htmlPath) - if (cache) return cache.clone() + if (!useETag) + return new Response( + file, + isNotEmpty(initialHeaders) + ? { headers: initialHeaders } + : undefined + ) - if (await fileExists(htmlPath)) - file = await getFile(htmlPath) - } + const etag = await generateETag(file) + if (requestHeaders && etag && (await isCached(requestHeaders, etag, pathName))) + return new Response(null, { status: 304 }) + + const response = new Response(file, { + headers: Object.assign( + { + 'Cache-Control': maxAge + ? `${directive}, max-age=${maxAge}` + : directive + }, + initialHeaders, + etag ? { Etag: etag } : {} + ) + }) - if ( - !file && - !fileStat.isDirectory() && - (await fileExists(pathName)) - ) - file = await getFile(pathName) - else throw new NotFoundError() - - if (!useETag) - return new Response( - file, - isNotEmpty(initialHeaders) - ? { headers: initialHeaders } - : undefined - ) + fileCache.set(pathName, response) + return response.clone() + } - const etag = await generateETag(file) - if ( - etag && - (await isCached(requestHeaders, etag, pathName)) - ) - return new Response(null, { - status: 304 - }) + if (enableFallback) { + app.onError({ as: 'global' }, async ({ code, request }) => { + if (code !== 'NOT_FOUND') return - const response = new Response(file, { - headers: Object.assign( - { - 'Cache-Control': maxAge - ? `${directive}, max-age=${maxAge}` - : directive - }, - initialHeaders, - etag ? { Etag: etag } : {} - ) - }) + const url = new URL(request.url) + let pathname = url.pathname - fileCache.set(pathName, response) + if (prefix) { + if (pathname.startsWith(prefix)) { + pathname = pathname.slice(prefix.length) + } else { + return + } + } - return response.clone() - } catch (error) { - if (error instanceof NotFoundError) throw error - if (!silent) console.error(`[@elysiajs/static]`, error) + const pathName = path.join( + assets, + decodeURI + ? (fastDecodeURI(pathname) ?? pathname) + : pathname + ) - throw new NotFoundError() + try { + return await serveStaticFile(pathName) + } catch { + return } - } - ) + }) + } else { + app.onError(() => {}).get( + `${prefix}/*`, + async ({ params, headers: requestHeaders }) => { + const pathName = path.join( + assets, + decodeURI + ? (fastDecodeURI(params['*']) ?? params['*']) + : params['*'] + ) + + try { + const result = await serveStaticFile(pathName, requestHeaders) + if (result) return result + throw new NotFoundError() + } catch (error) { + if (error instanceof NotFoundError) throw error + if (!silent) console.error(`[@elysiajs/static]`, error) + throw new NotFoundError() + } + } + ) + } } return app diff --git a/src/types.ts b/src/types.ts index fa98cef..d39955f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,4 +116,15 @@ export interface StaticOptions { * If set to true, suppresses all logs and warnings from the static plugin */ silent?: boolean + + /** + * enableFallback + * + * @default false + * + * If set to true, when a static file is not found, the request will fall through + * to the next route handler instead of returning a 404 error. + * This allows other routes to handle the request. + */ + enableFallback?: boolean } diff --git a/src/utils.ts b/src/utils.ts index 3a00172..5d080a0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ import type { BunFile } from 'bun' -import type { Stats } from 'fs' let fs: typeof import('fs/promises') let path: typeof import('path') diff --git a/test/index.test.ts b/test/index.test.ts index 6747ff2..a35b7e4 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -442,4 +442,58 @@ describe('Static Plugin', () => { res = await app.handle(req('/public/html')) expect(res.status).toBe(404) }) + + it('should fallback to other routes when enableFallback is true', async () => { + const app = new Elysia() + .get('/api/test', () => ({ success: true })) + .use( + staticPlugin({ + prefix: '/', + enableFallback: true + }) + ) + + await app.modules + + const apiRes = await app.handle(req('/api/test')) + expect(apiRes.status).toBe(200) + + const staticRes = await app.handle(req('/takodachi.png')) + expect(staticRes.status).toBe(200) + }) + + it('should return 404 for non-existent files when enableFallback is false', async () => { + const app = new Elysia() + .get('/api/test', () => ({ success: true })) + .use( + staticPlugin({ + prefix: '/', + enableFallback: false + }) + ) + + await app.modules + + const res = await app.handle(req('/non-existent-file.txt')) + expect(res.status).toBe(404) + + const apiRes = await app.handle(req('/api/test')) + expect(apiRes.status).toBe(200) + }) + + it('should work with .all() method when enableFallback is true', async () => { + const app = new Elysia() + .all('/api/auth/*', () => ({ auth: 'success' })) + .use( + staticPlugin({ + prefix: '/', + enableFallback: true + }) + ) + + await app.modules + + const res = await app.handle(req('/api/auth/get-session')) + expect(res.status).toBe(200) + }) }) From a59eae1abc64abb3626d05d2be6bda2172fa2e63 Mon Sep 17 00:00:00 2001 From: Riya Amemiya Date: Fri, 17 Oct 2025 11:53:01 +0900 Subject: [PATCH 2/4] fix: forward headers and restrict methods in enableFallback mode - Forward request headers to serveStaticFile to enable 304 caching - Restrict static file serving to GET/HEAD methods only - Prevents unexpected behavior with POST/PUT/DELETE requests --- src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index ebf853d..80956f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -297,6 +297,9 @@ export async function staticPlugin({ app.onError({ as: 'global' }, async ({ code, request }) => { if (code !== 'NOT_FOUND') return + // Only serve static files for GET/HEAD + if (request.method !== 'GET' && request.method !== 'HEAD') return + const url = new URL(request.url) let pathname = url.pathname @@ -316,7 +319,8 @@ export async function staticPlugin({ ) try { - return await serveStaticFile(pathName) + const headers = Object.fromEntries(request.headers) + return await serveStaticFile(pathName, headers) } catch { return } From 918323b137efa8128423b1f65d4bf0cbeb790c2e Mon Sep 17 00:00:00 2001 From: Riya Amemiya Date: Fri, 17 Oct 2025 12:06:13 +0900 Subject: [PATCH 3/4] security: prevent directory traversal attacks - Use path.resolve() to get absolute paths and enforce containment - Check that resolved paths stay within assetsDir boundary - Convert to relative paths when checking ignorePatterns - Apply to both enableFallback and non-fallback modes - Add tests to verify directory traversal protection --- src/index.ts | 45 +++++++++++++++++++++++++++++++++------------ test/index.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 80956f9..c5e278a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -311,16 +311,25 @@ export async function staticPlugin({ } } - const pathName = path.join( - assets, - decodeURI - ? (fastDecodeURI(pathname) ?? pathname) - : pathname + const rawPath = decodeURI + ? (fastDecodeURI(pathname) ?? pathname) + : pathname + const resolvedPath = path.resolve( + assetsDir, + rawPath.replace(/^\//, '') ) + // Block path traversal: must stay under assetsDir + if ( + resolvedPath !== assetsDir && + !resolvedPath.startsWith(assetsDir + path.sep) + ) + return + + if (shouldIgnore(resolvedPath.replace(assetsDir, ''))) return try { const headers = Object.fromEntries(request.headers) - return await serveStaticFile(pathName, headers) + return await serveStaticFile(resolvedPath, headers) } catch { return } @@ -329,15 +338,27 @@ export async function staticPlugin({ app.onError(() => {}).get( `${prefix}/*`, async ({ params, headers: requestHeaders }) => { - const pathName = path.join( - assets, - decodeURI - ? (fastDecodeURI(params['*']) ?? params['*']) - : params['*'] + const rawPath = decodeURI + ? (fastDecodeURI(params['*']) ?? params['*']) + : params['*'] + const resolvedPath = path.resolve( + assetsDir, + rawPath.replace(/^\//, '') + ) + if ( + resolvedPath !== assetsDir && + !resolvedPath.startsWith(assetsDir + path.sep) ) + throw new NotFoundError() + + if (shouldIgnore(resolvedPath.replace(assetsDir, ''))) + throw new NotFoundError() try { - const result = await serveStaticFile(pathName, requestHeaders) + const result = await serveStaticFile( + resolvedPath, + requestHeaders + ) if (result) return result throw new NotFoundError() } catch (error) { diff --git a/test/index.test.ts b/test/index.test.ts index a35b7e4..d76ef41 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -496,4 +496,47 @@ describe('Static Plugin', () => { const res = await app.handle(req('/api/auth/get-session')) expect(res.status).toBe(200) }) + + it('should prevent directory traversal attacks', async () => { + const app = new Elysia().use(staticPlugin()) + + await app.modules + + const traversalPaths = [ + '/public/../package.json', + '/public/../../package.json', + '/public/../../../etc/passwd', + '/public/%2e%2e/package.json', + '/public/nested/../../package.json' + ] + + for (const path of traversalPaths) { + const res = await app.handle(req(path)) + expect(res.status).toBe(404) + } + }) + + it('should prevent directory traversal attacks when enableFallback is true', async () => { + const app = new Elysia().use( + staticPlugin({ + prefix: '/', + enableFallback: true + }) + ) + + await app.modules + + const traversalPaths = [ + '/../package.json', + '/../../package.json', + '/../../../etc/passwd', + '/%2e%2e/package.json', + '/nested/../../package.json' + ] + + for (const path of traversalPaths) { + const res = await app.handle(req(path)) + expect(res.status).toBe(404) + } + }) }) From 86dc6f3e2fc8782a64ab775d35029ec8af1adf66 Mon Sep 17 00:00:00 2001 From: Riya Amemiya Date: Fri, 17 Oct 2025 12:16:35 +0900 Subject: [PATCH 4/4] fix: improve serveStaticFile ignore check, 304 headers, and mtime target - Use relative path for shouldIgnore check instead of absolute path - Include initialHeaders in 304 responses for consistency - Use actual file path (index.html) for isCached check instead of directory path --- src/index.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index c5e278a..b721d53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -239,7 +239,11 @@ export async function staticPlugin({ } const serveStaticFile = async (pathName: string, requestHeaders?: Record) => { - if (shouldIgnore(pathName)) return null + // Normalize for ignore matching + const rel = pathName.startsWith(assetsDir) + ? pathName.slice(assetsDir.length) + : pathName + if (shouldIgnore(rel)) return null const cache = fileCache.get(pathName) if (cache) return cache.clone() @@ -250,14 +254,17 @@ export async function staticPlugin({ if (!indexHTML && fileStat.isDirectory()) return null let file: NonNullable>> | undefined + let targetPath = pathName if (!isBun && indexHTML) { const htmlPath = path.join(pathName, 'index.html') const cache = fileCache.get(htmlPath) if (cache) return cache.clone() - if (await fileExists(htmlPath)) + if (await fileExists(htmlPath)) { file = await getFile(htmlPath) + targetPath = htmlPath + } } if (!file && !fileStat.isDirectory() && (await fileExists(pathName))) @@ -274,8 +281,11 @@ export async function staticPlugin({ ) const etag = await generateETag(file) - if (requestHeaders && etag && (await isCached(requestHeaders, etag, pathName))) - return new Response(null, { status: 304 }) + if (requestHeaders && etag && (await isCached(requestHeaders, etag, targetPath))) + return new Response(null, { + status: 304, + headers: isNotEmpty(initialHeaders) ? initialHeaders : undefined + }) const response = new Response(file, { headers: Object.assign(