Restore basic Markdown rendering to user bios
What does this MR do and why?
Restore basic Markdown rendering to user bios, but do it in a safe way. Closes Follow-up from "Support markdown in user's bio ... (#604504 - closed). This has customer validation, and even self-validation; we used to support it, but when security concerns arose, we simply removed it instead of solving them.
We add a pipeline to render Markdown in biographies, and it's extremely restrictive: do a Markdown pass, remove all tags except <em>, <strong>, and <code>, and then render emoji. We can consider adding to the permitted tag list later, if we want. I've left off <p> and <br> intentionally; this prevents users breaking the page layout with excessive vertical whitespace, or trying to mimic other page elements.
(Stripped tags are replaced with whitespace by our sanitiser, so we also remove any leading/trailing whitespace after the sanitise step — the Markdown renderer will always render the result in (at least one) <p> tag, and so every render will have surrounding whitespace by default, which is a bit ugly, so we fix that.)
We have an API/EntityExposureGrowth cop that prevents us from adding to entities that are exposed in many places, like User, so per the API style guide, we add a new feature-bounded entity, UserProfile, that adds bio_html to User, and have the regular user GET API return that for the popover's use.
Unlike most Markdown fields, we don't cache these renders in the database. The motivation to cache Markdown renders is that our full pipeline is costly both in terms of CPU (large issue/MR fields like the one I'm writing in lot can have a lot of text, and the full pipeline does a lot to it) and even more in terms of database IO: resolving references is expensive.
The user bio pipeline, however, is not expensive. It does no database calls, has a very restricted input (bio max length is 255 characters), and does almost nothing to the output of the Markdown→HTML process except remove things. We don't need to cache it, and our database will be much happier for it; user_details is currently a "small" (<10gb) table, and not unnecessarily adding the bio renders will keep it that way
Benchmarking 'cause who doesn't love numbers
Asked Claude to benchmark the pipeline. I think those first two are meant to be based on me -_-
# frozen_string_literal: true
require 'benchmark/ips'
BIOS = {
'short plain' =>
'Backend engineer at GitLab. Coffee enthusiast.',
'typical markdown' =>
'Backend engineer on the **Plan** stage at GitLab :fox_face:. ' \
'I work on `banzai` and Markdown rendering. _Opinions my own._',
'255ch plain' =>
('Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' * 5)[0, 255],
'255ch markdown-heavy' =>
('**bold** _emphasis_ `code` [link](https://example.com/a/b) ' * 5)[0, 255],
'255ch emoji-heavy' =>
('party :tada: time 🎉 fox :fox_face: heart ❤️ rocket :rocket: ' * 5)[0, 255]
}.freeze
BIOS.each { |name, bio| raise "#{name} too long (#{bio.length})" if bio.length > 255 }
# Warmup.
BIOS.each_value { |bio| Banzai::Pipeline::UserBioPipeline.to_html(bio, project: nil) }
report = Benchmark.ips do |x|
x.config(time: 5, warmup: 2)
BIOS.each do |name, bio|
x.report(name) do
Banzai::Pipeline::UserBioPipeline.to_html(bio, project: nil)
end
end
x.compare!
end
puts "\nAverage time per render:"
report.entries.each do |entry|
puts format(' %-22s %8.3f ms', entry.label, 1000.0 / entry.ips)
endruby 3.3.11 (2026-03-26 revision 1f2d15125a) [arm64-darwin24]
Warming up --------------------------------------
short plain 367.000 i/100ms
typical markdown 176.000 i/100ms
255ch plain 178.000 i/100ms
255ch markdown-heavy 77.000 i/100ms
255ch emoji-heavy 63.000 i/100ms
Calculating -------------------------------------
short plain 3.646k (± 4.8%) i/s (274.26 μs/i) - 18.350k in 5.044432s
typical markdown 1.791k (± 3.1%) i/s (558.44 μs/i) - 8.976k in 5.017327s
255ch plain 1.867k (± 2.5%) i/s (535.70 μs/i) - 9.434k in 5.056841s
255ch markdown-heavy 781.571 (± 9.7%) i/s (1.28 ms/i) - 3.850k in 5.007241s
255ch emoji-heavy 646.531 (± 3.7%) i/s (1.55 ms/i) - 3.276k in 5.073877s
Comparison:
short plain: 3646.1 i/s
255ch plain: 1866.7 i/s - 1.95x slower
typical markdown: 1790.7 i/s - 2.04x slower
255ch markdown-heavy: 781.6 i/s - 4.67x slower
255ch emoji-heavy: 646.5 i/s - 5.64x slower
Average time per render:
short plain 0.274 ms
typical markdown 0.558 ms
255ch plain 0.536 ms
255ch markdown-heavy 1.279 ms
255ch emoji-heavy 1.547 ms1.5ms worst-case is very fast. We are good to not cache
References
- Support markdown in user's bio attribute (!35604 - merged) — added support, but didn't pay too much attention to what went in it — used the full Markdown pipeline, called
sanitizeon the frontend in popovers, and rerendered the rendered HTML in the user profile view. This was not a very good way to do things. - Drop Markdown support in bio field (!68628 - merged) — removed support, instead of finding a secure solution. (I think "what GitHub/Twitter does" are not valid reasons here.)
- !68628 (comment 660870952) — security discussion noting that line-breaks can be used maliciously, and links should be disallowed.
- !68628 (comment 671438865) — previous Markdown DRI advocating for keeping Markdown in bio, particularly for emoji.
Screenshots or screen recordings
| Before | After |
|---|---|
![]() |
![]() |
![]() |
![]() |
How to set up and validate locally
- Give yourself a bio with some Markdown in it. Try to break it!
- Check out your profile page, and try a user popover (like in a work item or MR). It should all be displayed verbatim.
- Check out this branch; run migrations.
- Check out those puppies again; they should render nicely.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.




