VYPR
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.

PackageAffected versionsPatched versions
hononpm
>= 4.0.0, < 4.12.124.12.12

Affected products

1
  • cpe:2.3:a:hono:hono:*:*:*:*:*:node.js:*:*
    Range: >=4.0.0,<=4.12.11

Patches

1
b470278920ff

Merge commit from fork

https://github.com/honojs/honoYusuke WadaApr 7, 2026via ghsa
4 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

News mentions

0

No linked articles in our index yet.