ruby 109 lines · 4 tabs

Safer HTML Sanitization Pipeline

Shared by codesnips Jan 2026
4 tabs
require "loofah"

class HtmlSanitizer
  ALLOWED_TAGS  = %w[p br a strong em ul ol li blockquote code pre h2 h3].freeze
  ALLOWED_ATTRS = %w[href title].freeze
  SAFE_SCHEMES  = %w[http https mailto].freeze

  SCRUBBER = Loofah::Scrubber.new do |node|
    next Loofah::Scrubber::CONTINUE if node.text?

    unless ALLOWED_TAGS.include?(node.name)
      node.before(node.children)
      node.remove
      next Loofah::Scrubber::STOP
    end

    node.attribute_nodes.each do |attr|
      unless ALLOWED_ATTRS.include?(attr.name)
        attr.remove
        next
      end

      if %w[href src].include?(attr.name)
        scheme = URI.parse(attr.value).scheme rescue nil
        attr.remove if scheme && !SAFE_SCHEMES.include?(scheme.downcase)
      end
    end

    Loofah::Scrubber::CONTINUE
  end

  def self.call(html)
    return "" if html.blank?
    Loofah.fragment(html.to_s).scrub!(SCRUBBER).to_s
  end

  def self.plain(html)
    return "" if html.blank?
    Loofah.fragment(html.to_s).scrub!(:prune).text(encode_special_chars: false).strip
  end
end
4 files · ruby Explain with highlit

This snippet shows how to centralize HTML sanitization for user-generated content in a Rails app instead of scattering sanitize calls across views. The core problem is that rich text submitted by users can carry cross-site scripting payloads — <script> tags, javascript: URLs, on* event handlers — and calling Rails' default helpers ad hoc tends to produce inconsistent allow-lists and dangerous gaps.

In HtmlSanitizer, the pipeline is defined once as a pure service object built on top of Loofah, the same library Rails uses under the hood. The SCRUBBER is a custom Loofah::Scrubber that walks each node, drops elements not in ALLOWED_TAGS, strips attributes outside ALLOWED_ATTRS, and neutralizes href/src values whose scheme is not explicitly whitelisted. That last check matters because a naive tag allow-list still lets javascript: and data: URIs slip through an anchor. The .call method returns a scrubbed fragment serialized back to a string, and .plain offers a text-only variant for previews and search indexing.

The Sanitizable concern wires this into the model layer so sanitization happens at write time, not render time. sanitizes is a small DSL that registers attributes and installs a before_validation hook; sanitize_attributes! runs each configured field through HtmlSanitizer.call. Doing this on the way in means the database only ever stores clean markup, so a forgotten raw or html_safe in a template can no longer leak an exploit. The trade-off is that the original, unsanitized input is discarded — acceptable here because the app treats stored HTML as the source of truth.

In CommentsController, the controller stays thin: it assigns permitted params and relies on the model to scrub before persistence. Because body was already cleaned, the view can call raw comment.body safely. The main pitfall to watch is double-sanitization corrupting entities and the need to re-run the pipeline if the allow-list changes, which a data migration would handle. This layered approach — one canonical scrubber, enforced at the model boundary — is the pattern to reach for when many models render user HTML.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
payload = {
  sub: user.id,
  iss: 'https://auth.example.com',
  aud: 'codesnips-api',
  exp: 15.minutes.from_now.to_i,
  iat: Time.now.to_i,

JWT issuance and verification without common footguns

jwt authentication api
by Kai Nakamura 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs

Share this code

Here's the card — post it anywhere.

Safer HTML Sanitization Pipeline — share card
Link copied