Module: CamaleonCms::ContentSecurity

Defined in:
lib/camaleon_cms/content_security.rb

Overview

SVG content security is now handled by CamaleonCms::SvgContentChecker (Nokogiri XML parse-based detection). These patterns are retained for non-SVG file scanning as a defense-in-depth layer.

Constant Summary collapse

DECODE_PASSES =

Bounded like UserUrlValidator#validate_path_traversal, so multiply-encoded payloads are caught without a decoding bomb looping forever.

5
CONTROL_CHARS =

NUL and C0 controls are stripped before matching: browsers ignore them inside a URI scheme, so "java\0script:" is live markup while the raw bytes match nothing. TAB/LF/CR are deliberately kept -- they are legitimate in text, and the scheme patterns below tolerate them explicitly instead. The C1 range (\x80-\x9f) is deliberately NOT stripped: those bytes are UTF-8 continuation bytes, and removing them would mangle legitimate multibyte text.

/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
UNSAFE_EVENT_HANDLERS =

Event-handler attribute stems. Each is matched as <stem>\w*\s*=, so a stem covers its whole family (onmouse → onmousedown/onmouseover/...).

%w[
  onabort onafter onbefore onbegin onblur oncanplay onchange onclick oncontextmenu oncopy oncuechange oncut
  ondblclick ondrag ondrop ondurationchange onend onended onerror onfocus onhashchange oninvalid oninput onkey
  onload onmessage onmouse ononline onoffline onpagehide onpageshow onpage onpaste onpause onplay onpopstate
  onprogress onpropertychange onratechange onreadystatechange onrepeat onreset onresize onscroll onsearch onseek
  onselect onshow onstalled onstorage onsubmit onsuspend ontimeupdate ontoggle onunload onvolumechange onwaiting
  onwheel
].freeze
UNSAFE_EVENT_PATTERN =

One alternation over all stems instead of 58 separate scans of the full buffer — the same <stem>\w*\s*= match, folded into a single pass.

/(?:#{Regexp.union(UNSAFE_EVENT_HANDLERS).source})\w*\s*=/i
BLOCKED_ELEMENTS =

Elements able to navigate, exfiltrate, or load remote active content. Longest-first so "frameset" is preferred over "frame".

%w[
  script iframe object embed base meta style form applet frameset frame link template portal marquee math
].freeze
BLOCKED_ELEMENT_PATTERN =

"/" counts as a tag delimiter, so <script/src="..."> cannot evade the check.

%r{</?(#{Regexp.union(BLOCKED_ELEMENTS).source})[\s/>]}i
SCRIPT_SCHEMES =

Script schemes are dangerous in any URI position.

%w[javascript vbscript].freeze
DATA_URI_SAFE_MEDIA =

A browser interprets a data: URI by its media type, so data:image/png;base64,... is an inert bitmap (Inkscape/Figma embed rasters this way and such files must upload), while data:text/html and data:image/svg+xml carry active content. These raster media types are therefore allowed; every other data: URI is blocked.

%w[
  image/png image/gif image/jpeg image/jpg image/webp image/bmp image/avif
  image/x-icon image/vnd.microsoft.icon
].freeze
SCHEME_GAP_CHARS =

Characters the URL parser strips from inside a scheme ("javascript:" executes as javascript:). blocked_scheme_in? deletes these before matching, so the pattern itself stays a cheap contiguous alternation instead of an [\t\n\r]* between every character -- the latter backtracked into seconds of CPU on adversarial near-miss input. Deliberately not all of \s: a space is not stripped, so "Sample data : 42" stays prose, never a working URI.

"\t\n\r"
DATA_URI_SAFE_MEDIA_PATTERN =

An allowlisted raster media type ending at a data-URI delimiter (;/,), a markup boundary (whitespace/quote/>/)), or end of input -- so "image/pngx" is not mistaken for "image/png".

/(?:#{Regexp.union(DATA_URI_SAFE_MEDIA).source})(?=[;,\s"'>)]|\z)/i
BLOCKED_SCHEME_PATTERN =

javascript:/vbscript: are always blocked. data: is blocked only when it is a functional data URI -- a media type, or a bare ;/, delimiter -- that is NOT an allowlisted raster image; prose that merely contains "...data:" has no such continuation and is left alone. Gaps are deleted by blocked_scheme_in? before this matches, so the scheme and media type are written contiguously.

Regexp.new(
  "(?:#{Regexp.union(SCRIPT_SCHEMES).source}):" \
  "|data:(?!#{DATA_URI_SAFE_MEDIA_PATTERN.source})(?:[a-z0-9.+-]+/[a-z0-9.+-]+|[;,])",
  Regexp::IGNORECASE
)
SUSPICIOUS_PATTERNS =
[
  UNSAFE_EVENT_PATTERN,
  BLOCKED_ELEMENT_PATTERN
].freeze

Class Method Summary collapse

Class Method Details

.blocked_scheme?(content) ⇒ Boolean

True if content carries a blocked URI scheme. Normalizes (entity/control-char decoding) first -- use this when you hold raw bytes (the SVG scanner). The non-SVG ruleset normalizes once for all its patterns and calls blocked_scheme_in? directly to avoid a second full copy.

Returns:

  • (Boolean)


87
88
89
90
91
# File 'lib/camaleon_cms/content_security.rb', line 87

def self.blocked_scheme?(content)
  return false if content.nil?

  blocked_scheme_in?(normalize(content))
end

.blocked_scheme_in?(normalized) ⇒ Boolean

Same test against already-normalized content. The gap bytes a browser strips inside a scheme are removed here so BLOCKED_SCHEME_PATTERN can stay a cheap contiguous match rather than backtracking through an [\t\n\r]* between every character.

Returns:

  • (Boolean)


96
97
98
99
100
# File 'lib/camaleon_cms/content_security.rb', line 96

def self.blocked_scheme_in?(normalized)
  return false if normalized.nil?

  normalized.delete(SCHEME_GAP_CHARS).match?(BLOCKED_SCHEME_PATTERN)
end

.decode_entities(content) ⇒ Object

Repeatedly decodes HTML entities until stable, capped at DECODE_PASSES so a deliberately deep chain cannot spin. Falls back to the last good value if a decode produces bytes that cannot be handled.



146
147
148
149
150
151
152
153
154
155
156
# File 'lib/camaleon_cms/content_security.rb', line 146

def self.decode_entities(content)
  DECODE_PASSES.times do
    decoded = CGI.unescapeHTML(content).force_encoding(Encoding::BINARY)
    break if decoded == content

    content = decoded
  end
  content
rescue ArgumentError, Encoding::CompatibilityError
  content
end

.normalize(content) ⇒ Object

Canonicalizes content so encoded variants of a blocked pattern are detected. Returns a normalized copy; the stored file is never modified.

Deliberately entity-decoding only, NOT percent-decoding -- unlike UserUrlValidator, which percent-decodes because its input is a URL that gets parsed and resolved, where "%252e%252e" really does become "..". Here the input is file content a browser parses as markup: "a" really does become "a" in an attribute, but a percent-escape does not. Per the URL spec the scheme is matched literally, so "%6Aavascript:" and "javascript%3Aalert(1)" are not schemes at all and never execute. Percent-decoding here would buy no coverage and would reject legitimate files that merely contain encoded URLs (JSON exports, CSVs of links, access logs).



134
135
136
137
138
139
140
141
# File 'lib/camaleon_cms/content_security.rb', line 134

def self.normalize(content)
  return content if content.nil?

  normalized = content.dup.force_encoding(Encoding::BINARY)
  normalized = decode_entities(normalized) if normalized.include?('&')
  normalized = normalized.gsub(CONTROL_CHARS, '') if CONTROL_CHARS.match?(normalized)
  normalized
end

.suspicious_markup_bytes?(content) ⇒ Boolean

Byte-level backstop for markup, immune to the encoding a parser autodetects. A markup parser picks its encoding from in-band signals (a BOM, an XML declaration, a <meta charset>) that a browser can resolve differently -- WHATWG maps a utf-16/utf-32 <meta charset> back to UTF-8, so a pure-ASCII document declaring utf-16 fires its handlers in the browser while the parser re-decodes the bytes as utf-16 and sees none. Stripping the NUL/C0 padding collapses a UTF-16/UTF-32 (or spoofed-declaration) handler down to the ASCII bytes the browser will run, so the element and handler patterns match it regardless of the declared encoding.

Deliberately does NOT entity-decode, unlike normalize: character references in a tag or attribute NAME are never decoded by a browser (on&#99;lick is an inert attribute, not onclick), so decoding would buy no coverage while turning escaped markup (&lt;script&gt;, which a browser renders as text) into a false positive. The scheme check keeps using blocked_scheme?, which does normalize, because a scheme in an attribute VALUE is decoded.

Returns:

  • (Boolean)


115
116
117
118
119
120
# File 'lib/camaleon_cms/content_security.rb', line 115

def self.suspicious_markup_bytes?(content)
  return false if content.nil?

  stripped = content.dup.force_encoding(Encoding::BINARY).gsub(CONTROL_CHARS, '')
  UNSAFE_EVENT_PATTERN.match?(stripped) || BLOCKED_ELEMENT_PATTERN.match?(stripped)
end