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?, andunsafe_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 keepsAge < 18and5 > 3out 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
-
.dangerous_uri?(value) ⇒ Boolean
True when
valuecarries a script-capable URI scheme (javascript:, vbscript:, or a non-raster data: URI), tolerant of the gap characters and entity encodings a browser strips. -
.too_large?(value) ⇒ Boolean
True when the value exceeds the parse-cost ceiling.
-
.unsafe_html?(value, tags:, attributes:) ⇒ Boolean
True when
valuecontains markup outside the given allowlist (or one of the structural shapes above).
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).
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.
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.
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(, attributes)).to_s != baseline end |