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)
end
ruby 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 ms

1.5ms worst-case is very fast. We are good to not cache

References

Screenshots or screen recordings

image

Before After
image image
image image

How to set up and validate locally

  1. Give yourself a bio with some Markdown in it. Try to break it!
  2. Check out your profile page, and try a user popover (like in a work item or MR). It should all be displayed verbatim.
  3. Check out this branch; run migrations.
  4. 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.

Edited by Asherah Connor

Merge request reports

Loading