Module: ConcernsOnRails::Models::Sanitizable

Extended by:
ActiveSupport::Concern
Defined in:
lib/concerns_on_rails/models/sanitizable.rb

Overview

Opt-in HTML sanitization for string attributes — defense-in-depth, NOT a substitute for Rails’ default output escaping.

ESCAPE-FIRST: Rails already HTML-escapes ‘<%= record.body %>` via SafeBuffer, so for ordinary columns you need nothing here. Reach for this concern only for the rare column you render as trusted HTML (`raw` / `html_safe`) or that must be kept plain text. For full user-authored rich text, prefer Action Text.

class Article < ApplicationRecord
  include ConcernsOnRails::Models::Sanitizable

  # DEFAULT (on: :read) — non-destructive. The stored column stays raw;
  # a `sanitized_<field>` reader returns the cleaned value:
  sanitizable :body, with: :safe_list           # => article.sanitized_body
  sanitizable :summary, with: :strip            # => article.sanitized_summary
  sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }

  # EXPLICIT destructive opt-in — for plain-text-only columns only:
  sanitizable :title, with: :strip, on: :write  # overwrites in before_validation
end

WARNING: ‘on: :write` is lossy and irreversible — never use it on code, Markdown, math, or anything where `<` / `>` are legitimate. It is also bypassed by `update_column` / `update_all` / raw SQL, which skip callbacks. The non-destructive `on: :read` default is preferred.

Presets (the ‘with:` argument):

:strip      — remove all tags, keep inner text (the default)
:safe_list  — Rails' allow-list: keep formatting tags, drop <script> etc.
:no_links   — strip only <a> tags, keep their text
:none       — no-op (declare the field / reader without transforming)
Array       — custom tag allow-list, e.g. with: %w[b i a]
Hash        — { tags: [...], attributes: [...] } allow-list
Proc        — used as-is (the caller owns the non-String guard)

Constant Summary collapse

PRESETS =

Frozen, string-safe lambdas — non-String values pass through untouched, exactly like Normalizable::PRESETS. Each resolves its sanitizer through the shared Support helper (fully qualified, so there is no lexical-scope dependency and libgumbo is probed once, not per access) and always returns a plain String via #to_s, so a SafeBuffer is never persisted.

{
  strip: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.full.sanitize(v).to_s : v },
  safe_list: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.safe.sanitize(v).to_s : v },
  no_links: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.link.sanitize(v).to_s : v },
  none: ->(v) { v }
}.freeze

Instance Method Summary collapse

Instance Method Details

#apply_sanitizationsObject

Only fields declared with on: :write are mutated; on: :read fields keep their raw column value and are exposed through their sanitized_ reader.



136
137
138
139
140
141
142
143
144
145
# File 'lib/concerns_on_rails/models/sanitizable.rb', line 136

def apply_sanitizations
  self.class.sanitizable_rules.each do |field, rule|
    next unless rule[:on] == :write

    value = self[field]
    next if value.nil?

    self[field] = rule[:sanitizer].call(value) # plain String, never a SafeBuffer
  end
end