Module: CamaleonCms::UnsafeMarkup

Defined in:
lib/camaleon_cms/unsafe_markup.rb

Overview

Scan-and-reject gate for authored rich text (audit findings M17 and the post-content policy).

The security model is rejection, not transformation: an untrusted author's content is either stored exactly as written or refused with an error naming the problem — it is never silently rewritten. Stored content therefore always equals authored content, and the frontend may render it verbatim, which is what the templates do (raw post content, raw editor field values).

The detector mirrors the gate cama_contact_form's admin controller applies to its authored markup positions (keep the two in parity): parse once, compare what the safe-list scrubber would remove against the parse's own reserialization, so only a genuine removal registers — never a spelling difference (Tom & Jerry, <br/>, quote style, tag case). Three structural guards cover what that comparison cannot see: markup the parser drops instead of scrubbing, a tag left open at the end of the value, and a translation marker sitting inside a tag (the renderer deletes markers, so one inside a tag splices markup the gate never saw).

Defined Under Namespace

Classes: PermissiveDataAttrScrubber

Constant Summary collapse

MAX_GATED_VALUE_BYTES =

Every gated value costs a parse, and both the number of values and their size are chosen by the caller — bound the size so a pathological multi-megabyte value cannot drive a giant Loofah parse. The ceiling is generous (an ordinary long article is well under it); callers surface an over-size refusal with its own message via too_large?, and unsafe_html? keeps the check as a fail-closed backstop for callers that do not pre-check.

2 * 1024 * 1024
SANITIZER_SIGNIFICANT =

Nothing below can find anything in a string holding none of these: the scrubber and the serializer are both the identity function on it. & counts because entity decoding is a rewrite, the private-use sentinels count because the translation-marker shield deletes any supplied raw, and the control characters count because the HTML parser rewrites them.

/[<>&\u{E000}\u{E001}\u0000-\u0008\u000B\u000C\u000E-\u001F\u{FFFE}\u{FFFF}]/
TRANSLATION_MARKER =

A well-formed translation marker (<!--:-->, <!--:en-->). Any other <!-- is refused: the sanitizer strips comments, so a stray comment would register as a removal anyway, but an unterminated one additionally swallows everything to the next --> when emitted.

/<!--:[\w|-]{0,5}-->/
TAG_OPEN =

< only opens a tag when a name, a solidus or a markup declaration follows it — this is what keeps Age < 18 and 5 > 3 out of the tag scanners below.

%r{<[a-zA-Z/!?]}
START_TAG_NAME =
%r{<([a-zA-Z][a-zA-Z0-9]*)(?=[\s/>]|\z)}
TAG_SPAN =

One whole tag, quoted attribute values included, so a > inside an attribute does not look like the end of the tag; an unterminated quote runs to the end of the value.

%r{<[a-zA-Z/!?][^"'>]*(?:(?:"[^"]*"?|'[^']*'?)[^"'>]*)*>?}
OPEN_ATTR_NAME =

data-*/aria-* are admitted by shape: the set is open, they carry no behaviour of their own (a data- attribute is inert without script; an aria- attribute only annotates), and without them ordinary pasted Bootstrap-style markup would be refused for attributes no rejection message names.

/\A(?:data|aria)-[a-zA-Z][-a-zA-Z0-9_.]*\z/
PERMIT_SCRUBBER_BASE =

rails-html-sanitizer >= 1.6 exposes the scrubber under Rails::HTML (all caps); older releases (valid with Rails 6.1/7.0, which this gem supports) expose only Rails::Html. Resolve whichever the host bundled so the engine loads across the whole supported range.

defined?(Rails::HTML::PermitScrubber) ? Rails::HTML::PermitScrubber : Rails::Html::PermitScrubber

Class Method Summary collapse

Class Method Details

.dangerous_uri?(value) ⇒ Boolean

True when value carries a script-capable URI scheme (javascript:, vbscript:, or a non-raster data: URI), tolerant of the gap characters and entity encodings a browser strips. For values a renderer emits into a URL position (href/src).

Returns:

  • (Boolean)


106
107
108
# File 'lib/camaleon_cms/unsafe_markup.rb', line 106

def dangerous_uri?(value)
  CamaleonCms::ContentSecurity.blocked_scheme?(value.to_s)
end

.too_large?(value) ⇒ Boolean

True when the value exceeds the parse-cost ceiling. Callers refuse it with a size-specific message rather than the markup message — an over-size value may be perfectly clean.

Returns:

  • (Boolean)


99
100
101
# File 'lib/camaleon_cms/unsafe_markup.rb', line 99

def too_large?(value)
  value.to_s.bytesize > MAX_GATED_VALUE_BYTES
end

.unsafe_html?(value, tags:, attributes:) ⇒ Boolean

True when value contains markup outside the given allowlist (or one of the structural shapes above). Callers reject the save when this is true for an untrusted author.

Returns:

  • (Boolean)


72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/camaleon_cms/unsafe_markup.rb', line 72

def unsafe_html?(value, tags:, attributes:)
  string = scannable_string(value)
  return false unless string.match?(SANITIZER_SIGNIFICANT)
  return true if string.bytesize > MAX_GATED_VALUE_BYTES
  return true if disallowed_comment?(string)
  return true if marker_inside_tag?(string)
  return true if unterminated_tag?(string)

  shielded = string.gsub(CamaleonRecord::TRANSLATION_TAG_HIDE_REGEX, CamaleonRecord::TRANSLATION_TAG_HIDE_MAP)
  fragment = parse(shielded)
  # The css scrubber reformats whitespace while it scrubs, so a benign `text-align: center`
  # would register as a removal in the comparison below. Check style values separately
  # (whitespace-insensitively) and normalize them on the tree first, so the comparison sees
  # identical css on both sides and only element/attribute removals register.
  return true if dangerous_or_normalized_css!(fragment)
  return true if attribute_holds_markup?(fragment)

  baseline = fragment.to_s
  return true if markup_dropped?(shielded, fragment)

  # One parse, not two: scrub the fragment already built, so both sides of the comparison
  # share the same parser and serializer and only a genuine removal differs.
  fragment.scrub!(scrubber_for(tags, attributes)).to_s != baseline
end