High severity7.5NVD Advisory· Published Apr 8, 2026· Updated Apr 21, 2026
CVE-2026-39408
CVE-2026-39408
Description
Hono is a Web application framework that provides support for any JavaScript runtime. Prior to 4.12.12, a path traversal issue in toSSG() allows files to be written outside the configured output directory during static site generation. When using dynamic route parameters via ssgParams, specially crafted values can cause generated file paths to escape the intended output directory. This vulnerability is fixed in 4.12.12.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
hononpm | >= 4.0.0, < 4.12.12 | 4.12.12 |
Affected products
1Patches
14 files changed · +90 −10
src/helper/ssg/ssg.test.tsx+34 −0 modified@@ -563,6 +563,40 @@ describe('saveContentToFile function', () => { expect(fsMock.writeFile).toHaveBeenCalledWith('static/html.htm', yamlContent) // extensionMap expect(fsMock.writeFile).toHaveBeenCalledWith('static/html.html', yamlContent) // default + extensionMap }) + + it('should reject writing files outside outDir via path traversal', async () => { + await expect( + saveContentToFile( + Promise.resolve({ + routePath: '/../pwned', + content: 'owned', + mimeType: 'text/html', + }), + fsMock, + './static' + ) + ).rejects.toThrow('Path traversal detected') + + expect(fsMock.mkdir).not.toHaveBeenCalled() + expect(fsMock.writeFile).not.toHaveBeenCalled() + }) + + it('should reject paths that only partially match outDir name', async () => { + await expect( + saveContentToFile( + Promise.resolve({ + routePath: '/../static-evil/pwned', + content: 'owned', + mimeType: 'text/html', + }), + fsMock, + './static' + ) + ).rejects.toThrow('Path traversal detected') + + expect(fsMock.mkdir).not.toHaveBeenCalled() + expect(fsMock.writeFile).not.toHaveBeenCalled() + }) }) describe('Dynamic route handling', () => {
src/helper/ssg/ssg.ts+18 −9 modified@@ -6,7 +6,13 @@ import { getExtension } from '../../utils/mime' import type { AddedSSGDataRequest, SSGParams } from './middleware' import { SSG_CONTEXT, X_HONO_DISABLE_SSG_HEADER_KEY } from './middleware' import { defaultPlugin } from './plugins' -import { dirname, filterStaticGenerateRoutes, isDynamicRoute, joinPaths } from './utils' +import { + dirname, + ensureWithinOutDir, + filterStaticGenerateRoutes, + isDynamicRoute, + joinPaths, +} from './utils' const DEFAULT_CONCURRENCY = 2 // default concurrency for ssg @@ -49,17 +55,20 @@ const generateFilePath = ( ): string => { const extension = determineExtension(mimeType, extensionMap) + let filePath: string if (routePath.endsWith(`.${extension}`)) { - return joinPaths(outDir, routePath) + filePath = joinPaths(outDir, routePath) + } else if (routePath === '/') { + filePath = joinPaths(outDir, `index.${extension}`) + } else if (routePath.endsWith('/')) { + filePath = joinPaths(outDir, routePath, `index.${extension}`) + } else { + filePath = joinPaths(outDir, `${routePath}.${extension}`) } - if (routePath === '/') { - return joinPaths(outDir, `index.${extension}`) - } - if (routePath.endsWith('/')) { - return joinPaths(outDir, routePath, `index.${extension}`) - } - return joinPaths(outDir, `${routePath}.${extension}`) + ensureWithinOutDir(outDir, filePath) + + return filePath } const parseResponseContent = async (response: Response): Promise<string | ArrayBuffer> => {
src/helper/ssg/utils.test.ts+26 −1 modified@@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { dirname, joinPaths } from './utils' +import { dirname, ensureWithinOutDir, joinPaths } from './utils' describe('joinPath', () => { it('Should joined path is valid.', () => { @@ -27,9 +27,34 @@ describe('joinPath', () => { expect(joinPaths('a\\b\\c', 'd\\e')).toBe('a/b/c/d/e') }) }) + describe('dirname', () => { it('Should dirname is valid.', () => { expect(dirname('parent/child')).toBe('parent') expect(dirname('windows\\test.txt')).toBe('windows') }) }) + +describe('ensureWithinOutDir', () => { + it('Should not throw for paths within outDir', () => { + expect(() => ensureWithinOutDir('./static', 'static/index.html')).not.toThrow() + expect(() => ensureWithinOutDir('./static', 'static/sub/page.html')).not.toThrow() + expect(() => ensureWithinOutDir('/out', '/out/index.html')).not.toThrow() + expect(() => ensureWithinOutDir('./static', 'static/a/../b.html')).not.toThrow() + }) + + it('Should throw for paths outside outDir via traversal', () => { + expect(() => ensureWithinOutDir('./static', 'pwned.txt')).toThrow('Path traversal detected') + expect(() => ensureWithinOutDir('./static', '../pwned.txt')).toThrow('Path traversal detected') + expect(() => ensureWithinOutDir('./out', 'pwned.txt')).toThrow('Path traversal detected') + expect(() => ensureWithinOutDir('./static', 'static/../../pwned.txt')).toThrow( + 'Path traversal detected' + ) + }) + + it('Should throw for paths that partially match outDir name', () => { + expect(() => ensureWithinOutDir('./static', 'static-evil/pwned.html')).toThrow( + 'Path traversal detected' + ) + }) +})
src/helper/ssg/utils.ts+12 −0 modified@@ -73,3 +73,15 @@ export const filterStaticGenerateRoutes = <E extends Env>( export const isDynamicRoute = (path: string): boolean => { return path.split('/').some((segment) => segment.startsWith(':') || segment.includes('*')) } + +export const ensureWithinOutDir = (outDir: string, filePath: string): void => { + const normalizedOutDir = joinPaths('/', outDir) + const normalizedFilePath = joinPaths('/', filePath) + + if ( + normalizedFilePath !== normalizedOutDir && + !normalizedFilePath.startsWith(`${normalizedOutDir}/`) + ) { + throw new Error(`Path traversal detected: "${filePath}" is outside the output directory`) + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/honojs/hono/commit/b470278920fffcfd6d76002755d6db53db827679nvdPatchWEB
- github.com/honojs/hono/security/advisories/GHSA-xf4j-xp2r-rqqxnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-xf4j-xp2r-rqqxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39408ghsaADVISORY
- github.com/honojs/hono/releases/tag/v4.12.12nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.