Moderate severityOSV Advisory· Published Jan 27, 2026· Updated Jan 27, 2026
Hono has a Cross-site Scripting vulnerability
CVE-2026-24771
Description
Hono is a Web application framework that provides support for any JavaScript runtime. Prior to version 4.11.7, a Cross-Site Scripting (XSS) vulnerability exists in the ErrorBoundary component of the hono/jsx library. Under certain usage patterns, untrusted user-controlled strings may be rendered as raw HTML, allowing arbitrary script execution in the victim's browser. Version 4.11.7 patches the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
hononpm | < 4.11.7 | 4.11.7 |
Affected products
1Patches
12 files changed · +150 −11
src/jsx/components.test.tsx+109 −0 modified@@ -86,6 +86,67 @@ describe('ErrorBoundary', () => { errorBoundaryCounter-- suspenseCounter-- }) + + it('string content', async () => { + const html = <ErrorBoundary fallback={<Fallback />}>{'< ok >'}</ErrorBoundary> + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< ok >') + + errorBoundaryCounter-- + suspenseCounter-- + }) + + it('error: string content', async () => { + const html = ( + <ErrorBoundary fallback={'< error >'}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + errorBoundaryCounter-- + suspenseCounter-- + }) + + it('error: Promise<string> from fallback', async () => { + const html = ( + <ErrorBoundary fallback={Promise.resolve('< error >')}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + errorBoundaryCounter-- + suspenseCounter-- + }) + + it('error: string content from fallbackRender', async () => { + const html = ( + <ErrorBoundary fallbackRender={() => '< error >'}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + errorBoundaryCounter-- + suspenseCounter-- + }) + + it('error: Promise<string> from fallbackRender', async () => { + const html = ( + <ErrorBoundary fallbackRender={() => Promise.resolve('< error >')}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + errorBoundaryCounter-- + suspenseCounter-- + }) }) describe('async', async () => { @@ -123,6 +184,54 @@ describe('ErrorBoundary', () => { suspenseCounter-- }) + + it('error: string content', async () => { + const html = ( + <ErrorBoundary fallback={'< error >'}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + suspenseCounter-- + }) + + it('error: Promise<string> from fallback', async () => { + const html = ( + <ErrorBoundary fallback={Promise.resolve('< error >')}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + suspenseCounter-- + }) + + it('error: string content from fallbackRender', async () => { + const html = ( + <ErrorBoundary fallbackRender={() => '< error >'}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + suspenseCounter-- + }) + + it('error: Promise<string> from fallbackRender', async () => { + const html = ( + <ErrorBoundary fallbackRender={() => Promise.resolve('< error >')}> + <Component error={true} /> + </ErrorBoundary> + ) + + expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') + + suspenseCounter-- + }) }) describe('async : nested', async () => {
src/jsx/components.ts+41 −11 modified@@ -1,6 +1,7 @@ import { raw } from '../helper/html' import type { HtmlEscapedCallback, HtmlEscapedString } from '../utils/html' import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html' +import { jsx, Fragment } from './base' import { DOM_RENDERER } from './constants' import { useContext } from './context' import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components' @@ -25,6 +26,21 @@ export const childrenToString = async (children: Child[]): Promise<HtmlEscapedSt } } +const resolveChildEarly = (c: Child): HtmlEscapedString | Promise<HtmlEscapedString> => { + if (c == null || typeof c === 'boolean') { + return '' as HtmlEscapedString + } else if (typeof c === 'string') { + return c as HtmlEscapedString + } else { + const str = c.toString() + if (!(str instanceof Promise)) { + return raw(str) + } else { + return str as Promise<HtmlEscapedString> + } + } +} + export type ErrorHandler = (error: Error) => void export type FallbackRender = (error: Error) => Child @@ -51,37 +67,51 @@ export const ErrorBoundary: FC< const nonce = useContext(StreamingContext)?.scriptNonce let fallbackStr: string | undefined - const fallbackRes = (error: Error): HtmlEscapedString => { + const resolveFallbackStr = async () => { + const awaitedFallback = await fallback + if (typeof awaitedFallback === 'string') { + fallbackStr = awaitedFallback + } else { + fallbackStr = await awaitedFallback?.toString() + if (typeof fallbackStr === 'string') { + // should not apply `raw` if fallbackStr is undefined, null, or boolean + fallbackStr = raw(fallbackStr) + } + } + } + const fallbackRes = (error: Error): HtmlEscapedString | Promise<HtmlEscapedString> => { onError?.(error) - return (fallbackStr || fallbackRender?.(error) || '').toString() as HtmlEscapedString + return (fallbackStr || + (fallbackRender && jsx(Fragment, {}, fallbackRender(error) as HtmlEscapedString)) || + '') as HtmlEscapedString } let resArray: HtmlEscapedString[] | Promise<HtmlEscapedString[]>[] = [] try { - resArray = children.map((c) => - c == null || typeof c === 'boolean' ? '' : c.toString() - ) as HtmlEscapedString[] + resArray = children.map(resolveChildEarly) as unknown as HtmlEscapedString[] } catch (e) { - fallbackStr = await fallback?.toString() + await resolveFallbackStr() if (e instanceof Promise) { resArray = [ e.then(() => childrenToString(children as Child[])).catch((e) => fallbackRes(e)), ] as Promise<HtmlEscapedString[]>[] } else { - resArray = [fallbackRes(e as Error)] + resArray = [fallbackRes(e as Error) as HtmlEscapedString] } } if (resArray.some((res) => (res as {}) instanceof Promise)) { - fallbackStr ||= await fallback?.toString() + await resolveFallbackStr() const index = errorBoundaryCounter++ const replaceRe = RegExp(`(<template id="E:${index}"></template>.*?)(.*?)(<!--E:${index}-->)`) const caught = false - const catchCallback = ({ error, buffer }: { error: Error; buffer?: [string] }) => { + const catchCallback = async ({ error, buffer }: { error: Error; buffer?: [string] }) => { if (caught) { return '' } - const fallbackResString = fallbackRes(error) + const fallbackResString = await Fragment({ + children: fallbackRes(error), + }).toString() if (buffer) { buffer[0] = buffer[0].replace(replaceRe, fallbackResString) } @@ -195,7 +225,7 @@ d.remove() }, ]) } else { - return raw(resArray.join('')) + return Fragment({ children: resArray as Child[] }) } } ;(ErrorBoundary as HasRenderToDom)[DOM_RENDERER] = ErrorBoundaryDomRenderer
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
4- github.com/advisories/GHSA-9r54-q6cx-xmh5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24771ghsaADVISORY
- github.com/honojs/hono/commit/2cf60046d730df9fd0aba85178f3ecfe8212d990ghsax_refsource_MISCWEB
- github.com/honojs/hono/security/advisories/GHSA-9r54-q6cx-xmh5ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.