Repository navigation
Conversation
The text of an image description is the value of the `alt` attribute.
`HtmlRenderer::text` escapes every other text type there, but it wrote a
raw inline HTML chunk (`TextType::Html`) as it is. The first `"` of the
raw tag ended the attribute. The rest of the tag became attributes of the
`<img>` and text behind it.
The `Html` arm now escapes the chunk while an image is open. It checks
that before the tag filter, so the `<` of the filter is not escaped
again. Raw HTML outside an image does not change.
md4c made the same change to this arm in its commit 65c6c9d ("md_html:
Escape raw HTML in image alt attribute").
Co-authored-by: Ilia Alshanetsky <ilia@ilia.ws>
|
Status: the fix is pushed and is ready for review. The Buildkite build passed (#129684). The one red check on this commit comes from a review workflow that failed on every pull request and is disabled since 2026-10-21. It did not look at this diff. How I reproduced it. With bun 1.4.3-canary.1 (367d939), and with a release build of this branch that has the one source hunk reverted: $ bun -e 'console.log(Bun.markdown.html("\n"))'
<p><img src="i.png" alt="x <a href="u">y</a> z" /></p>With this branch the same command prints: <p><img src="i.png" alt="x <a href="u">y</a> z" /></p>The 20 new tests in |
|
Updated 8:05 PM PT - Oct 20th, 2026
✅ @robobun, your commit 3bd955679b175419caac500bc3965e04a3fdf777 passed in 🧪 To try this PR locally: bunx bun-pr 46249That installs a local version of the PR into your bun-46249 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughHTML text inside image descriptions is now escaped before it is written to the ChangesImage alt text escaping
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to
|
There was a problem hiding this comment.
LGTM — a three-line branch in the TextType::Html arm that routes raw HTML through the existing write_html_escaped while an image is open, plus a thorough test matrix.
What was reviewed:
image_nesting_levelbookkeeping inenter_span/leave_spanis unchanged and correct for nested images and images inside links;text()is the only writer between thealtquotes.write_html_escapedescapes& < > "and is the same helper already used for Normal text, so the.unwrap()at html_renderer.rs:473 is not a newly reachable user-input path;track_textstill skipsTextType::Html, so heading ids are unaffected.- Ordering of the
in_imagecheck beforetag_filterprevents double-escaping; the tests assert identical output with and withouttagFilter, andreact()/render()/ansi()are untouched as they do not share this renderer.
Extended reasoning...
The diff adds a three-line conditional in src/md/html_renderer.rs so raw inline HTML emitted inside an image description is HTML-escaped instead of written verbatim into the img alt attribute, closing an attribute-breakout where a " in the tag ended alt and leaked the rest as markup. It touches HTML output escaping, which is injection-adjacent, but the change strictly tightens escaping at the output layer that owns the invariant and reuses the existing escaper. The remaining hunk is 105 lines of tests covering every raw-HTML kind, every image form, tagFilter on/off, heading ids, the other renderers, and the .md module loader and bundler via subprocess with pipes drained concurrently. The change is small and self-contained, the bug hunt ran dry with no findings, no CODEOWNER covers the files, and no third-party objections are outstanding.
Problem
Bun.markdown.html()and.mdimports write raw HTML inside an image description verbatim intoalt.givesalt="x <a href="u">y</a> z". The first"of the tag endsalt, andy z" />shows as text.TextType::Htmlarm ofHtmlRenderer::text(src/md/html_renderer.rs:373) ignoresin_image. No issue reports this.Fix
alt="x <a href="u">y</a> z", as GitHub prints it. The check runs before the tag filter.text()is the only writer between the quotes ofalt. md4c, the origin of this renderer, made the same change in md_html: escape raw HTML in image alt attribute mity/md4c#376.test/js/bun/md/md-edge-cases.test.ts(20 new tests, all fail without the fix), and the other files intest/js/bun/md/.Background
TextType::Htmlis one chunk of raw inline HTML, for example a tag.altvalue.image_nesting_levelcounts the open images.text(): every text event pays a branch.Downsides
"in an image description.no longer putswidthon the image.html()still passes raw HTML such as<img onerror>.Notes
Origin. There is no GitHub issue and no user report. A read of
HtmlRenderer::textfound it. The write is in the code sinceBun.markdownwas added (#26440, first release v1.3.8). md4c fixed the same arm later (mity/md4c@65c6c9d, in v0.6.0).What changes. Only the output of
html(), of.mdimports and of bundled.md, and only for raw inline HTML inside an image description: a tag, a comment, a processing instruction, a CDATA section or a declaration. All three use the oneHtmlRenderer.": before,altends at the first", the rest of the tag becomes attributes of the<img>, and the tail shows as text. Foran HTML parser readsalt="<img src=", a junk attributea.jpg",width="100", and the text" />. Now the whole tag is the value ofalt."(): only the bytes change. The parsedaltisa <b>x</b> cbefore and after.tagFilter: truethe result is the same as without it. Before, the filter escaped only the<of its nine tag names, sogavealt="<script title="x" onerror="alert(1)">".noHtmlSpans: truealready printed for the same input.What does not change.
tagFilter.HeadingIdTracker::track_textstill skips the tag.react(),render()andansi()receive, and the events of the parser. The parser still reportsTextType::Htmlinside an image description, as md4c does.html()is not a sanitizer.<img src=x onerror="alert(1)">passes through with default options and withtagFilter, and[x](javascript:alert(1))becomes a link, before and after.References, for
.POST /markdown, modegfm):alt="x <a href="u">y</a> z". The same bytes as this PR.src/html.c, thestate->plain != NULLbranch):CMARK_NODE_HTML_INLINEgoes throughescape_html.MD_TEXT_HTMLgoes throughrender_html_escapedwhileimage_nesting_levelis above 0. md4c's escaper also writes'as'(mity/md4c@16a8df7). Bun escapes& < > ", as cmark does, sogivesalt="a <i c='d'> b". One test row pins that.allowDangerousHtmlprint the tag verbatim, as Bun did.Left alone on purpose.
write_entity(self.write(entity_text)) still writes raw. No input reaches it:find_entityaccepts a numeric reference, whichparse_entity_codepointalways decodes, or a name thatentity::lookuphas. An entity also holds no".test/js/bun/md/regressions.txt. md4c's example for this fix is not copied.test/cli/lint/conformance/markdown-trees.zstrecords a tree for every example of that directory, andtest/cli/lint/js-parsers.test.tspins the count. The rows inmd-edge-cases.test.tspin the behaviour.test/js/bun/md/. This PR copies no md4c text. The notice for the files that are already there is a decision for a maintainer.ansi()assertion. It is an exact match, like the otheransi()assertions in that file.Not fixed here. Each is the same before and after.
altholds still differs per renderer.hasalt="a b"inhtml()andalt: "ab"inreact().Bun.markdown.render()gives callbacks plain text and plain meta (language,href,src,title). A callback that writes HTML must escape both.docs/runtime/markdown.mdxdoes not say thathtml()does not sanitize.Related pull requests. #45219 drops spans inside image alt text in the parser. #46069 and #45508 edit other hunks of
src/md/html_renderer.rs. #39493 edits theCodearm below this one. None of them touches theHtmlarm.Measurements. Two release builds (LTO, linux-x64) of this branch, one hunk apart, made before the last rebase. Counts are for one call of
HtmlRenderer::text, inside its own symbol (gdbnexti). perf, valgrind and bloaty are not installed here.tagFilter. The new jump is not taken.small,mediumandlargeofbench/snippets/markdown.mjs, 56 of 509 forREADME.md.HtmlRenderer::text3,292 B to 2,987 B,write_html_escaped1,928 B both (llvm-nm -S). Text segment of the stripped binary 107,098,974 B to 107,098,718 B (size). File size 107,276,000 B both.render_to_html_with_options(mi_malloc/mi_realloc/mi_free, gdb hit counts):large12/29/39 both, 1,000 described images 1,008/26/1,032 both. 1,000 copies of the repro line: 8/26/32 to 8/27/33, because the output is longer.write_html_escapedcalls per image, one per raw chunk.html()output of the 827 examples intest/js/bun/md/*.txtand of 4,979.mdand.markdownfiles on disk in a built checkout (tracked files,node_modules,vendor/) is the same before and after, with default options,tagFilterandheadings(SHA-256 per output).Tests run on the debug ASAN build. The 204 tests of
md-edge-casespass. So do the 1,043 tests ofmd-spec,gfm-compat,md-heading-ids,md-react,md-render-callbackandtest/cli/run/markdown-entrypoint.test.ts. With bun 1.4.3-canary.1 (367d939) the 20 new tests fail. On the release build without the hunk, each table row fails both of its assertions, so the order of thein_imageandtag_filterchecks is covered.