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
module Sanitizable
extend ActiveSupport::Concern
included do
class_attribute :sanitizable_attributes, instance_writer: false, default: []
before_validation :sanitize_attributes!
end
class_methods do
def sanitizes(*attrs)
self.sanitizable_attributes = (sanitizable_attributes + attrs.map(&:to_sym)).uniq
end
end
private
def sanitize_attributes!
sanitizable_attributes.each do |attr|
value = self[attr]
next if value.blank?
self[attr] = HtmlSanitizer.call(value)
end
end
end
class Comment < ApplicationRecord
include Sanitizable
belongs_to :post
belongs_to :author, class_name: "User"
sanitizes :body
validates :body, presence: true, length: { maximum: 10_000 }
def excerpt(limit = 140)
HtmlSanitizer.plain(body).truncate(limit)
end
end
class CommentsController < ApplicationController
before_action :authenticate_user!
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
@comment.author = current_user
if @comment.save
redirect_to @post, notice: "Comment posted."
else
render turbo_stream: turbo_stream.replace(
"comment_form",
partial: "comments/form",
locals: { post: @post, comment: @comment }
), status: :unprocessable_entity
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
<!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
Share this code
Here's the card — post it anywhere.