October 31, 2026
JSON.stringify Is Not a Bouncer: How a Docs Page Ran My JavaScript
Documentation pages are the quiet kid in the back of your web app. Nobody thinks about them. Nobody reviews them. They just sit there…
By Jegann
3 min read
Documentation pages are the quiet kid in the back of your web app. Nobody thinks about them. Nobody reviews them. They just sit there, being helpful.
So naturally I went looking for trouble in one.
@hono/swagger-ui is middleware that serves a Swagger UI docs page for Hono apps. In version 0.6.1, that page could be made to run an attacker's JavaScript. This one is fixed and the fix is public, so I can show you everything.
How I got there
I was reading a published advisory, CVE-2026–33331, about an XSS in @orpc/openapi. Someone had dropped JSON.stringify output straight into an HTML page.
So I asked my favourite question in security: who else did this?
The mistake
JSON.stringify makes valid JSON. It does not make something safe to put inside a <script> tag.
The HTML parser runs first, and it doesn't care about your quotes or your string literals. If it sees </script>, that script is over.
Version 0.6.1 had three spots with this kind of problem:
// 1. title went straight into the HTML
return c.html(`... <title>${title}</title> ...`)
// 2. string options got wrapped in single quotes
return `${key}: '${v}'`
// 3. spec was stringified into an inline <script>
return `${key}: ${JSON.stringify(v)}`// 1. title went straight into the HTML
return c.html(`... <title>${title}</title> ...`)
// 2. string options got wrapped in single quotes
return `${key}: '${v}'`
// 3. spec was stringified into an inline <script>
return `${key}: ${JSON.stringify(v)}`The proof
I built a tiny app that did something very normal: put a request value in the docs page title.
app.get('/docs', (c) =>
swaggerUI({ url: '/openapi.json', title: c.req.query('app') ?? 'API Docs' })(c, async () => {})
)app.get('/docs', (c) =>
swaggerUI({ url: '/openapi.json', title: c.req.query('app') ?? 'API Docs' })(c, async () => {})
)Then I asked it nicely:
$ curl -s 'http://127.0.0.1:8799/docs?app=%3C%2Ftitle%3E%3Cscript%3Ealert(document.domain)%3C%2Fscript%3E' | grep -n '<title>'
8: <title></title><script>alert(document.domain)</script></title>$ curl -s 'http://127.0.0.1:8799/docs?app=%3C%2Ftitle%3E%3Cscript%3Ealert(document.domain)%3C%2Fscript%3E' | grep -n '<title>'
8: <title></title><script>alert(document.domain)</script></title>
My </title> closed the real title, and my <script> became part of the page.
Seeing a payload in a response doesn't prove it runs, so I opened it in a real browser too.
"Failed to load API definition" only appears because my test app had no /openapi.json. The red banner is the part that matters, because my injected script put it there.
Who was actually affected
I want to be fair, because overselling a bug is how you lose the respect of maintainers.
If your app only passes a static url that you wrote yourself, you were fine. You needed to feed untrusted data into title, spec or a string option like url. That could be a tenant name in the title, or a user-written description inside an inline spec.
A library should still handle that safely. But this was never "every Hono app is on fire."
The fix
I reported it on October 27, 2026. The fix was merged the next day in honojs/middleware#2219, with credit to me. That's very fast, and maintainers who move like that deserve a thank you. Thank you.
The fix is short and worth stealing:
const toScriptLiteral = (value: unknown): string =>
JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')const toScriptLiteral = (value: unknown): string =>
JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')With < and > written as unicode escapes, the JavaScript value stays the same, but the HTML parser never sees a closing tag. The title now goes through Hono's escaping html tag too.
If you use this package, update to 0.6.2 or later.
If you've ever written <script>var data = ${JSON.stringify(x)}</script>, go check it. I'll wait.
What I learned
- Read other projects' advisories. The best question after any bug is who else made the same mistake.
- Boring pages count. Docs pages run in your app's origin too.
- Prove it in a real browser. A payload in a response is a hint. A running script is proof.
And there is no CVE for this one yet, so my collection stays empty for now.