Module: CamaleonCms::UploaderContentSecurity

Included in:
RuntimeUploaderConcern, UploaderHelper
Defined in:
lib/camaleon_cms/uploader_content_security.rb

Constant Summary collapse

MARKUP_EXTENSIONS =

Extensions a browser parses as markup. These select the parse-based checker; everything else keeps the generic pattern ruleset.

The routing key is how the stored file will be rendered, not a literal .svg comparison. The two are not the same thing, and the difference was the bug: identical bytes carrying an onpointerdown attribute were refused as x.svg and stored as x.html, because only the first name reached the checker that rejects handlers by shape.

Deliberately narrow. Everything listed here is served as markup and therefore parsed as markup by the browser; nothing else is added "just in case". Running a markup parser over content that is not markup invents attributes that were never written -- if a<b and on=1 then x in a .txt parses to a b element with an on attribute -- so a wider list would refuse ordinary text files.

%w[svg svgz svg.gz html htm xhtml xht shtml xml xsl xslt].freeze
HTML_MODE_EXTENSIONS =

Of those, the ones that are not well-formed XML and need the HTML parser (see SvgContentChecker: the two modes differ in whether a parse failure means anything).

%w[html htm shtml].freeze
COMPRESSED_MARKUP_EXTENSIONS =

Gzip-compressed markup. Compressed bytes are high-entropy: no pattern matches them and no parser reads them, so scanning them as they arrive is not weak scanning, it is no scanning.

%w[svgz svg.gz].freeze
SCRIPT_EXTENSIONS =

Executable script. Refused for untrusted uploaders rather than scanned, because no scan can reach a verdict on JavaScript: it has no safe subset, every dangerous capability is reachable through dynamic construction (window['fet'+'ch']), and legitimate uploaded script is arbitrary script -- indistinguishable from a payload by any static rule. A scanner that cannot decide has to fail closed, and a fail-closed scanner for these types is a permission gate.

The gate is the existing media_unfiltered_upload, not a new permission. Its holders skip scanning entirely, so they could already store these files; reusing it grants nothing new and changes nothing for an install that has already granted it, whereas a new permission would revoke the capability from those installs until they granted the second one too.

%w[js mjs cjs wasm swf].freeze
MAX_DECOMPRESSED_MARKUP_BYTES =

Ceiling on decompressed markup. Compression is the one case where the upload size limit stops bounding the work: a few-KB upload can expand to gigabytes, so the input limit says nothing about the memory the scan will use. Generous for a real compressed image and far below anything a bomb aims for; an upload that exceeds it is refused rather than expanded further.

10 * 1024 * 1024
GZIP_MAGIC =

Leading bytes of a gzip member. Used to decide whether trailing bytes after one member begin another, so every member is decompressed rather than only the first.

"\x1f\x8b".b.freeze

Instance Method Summary collapse

Instance Method Details

#cama_trusted_for_unfiltered_upload?Boolean

Whether the current uploader may skip the malicious-content scan. Mirrors Post#trusted_for_unfiltered_html?: read the request context, fail closed (scan) when either half is missing -- background jobs, rake tasks and the console have no request user, and an upload from there must be scanned rather than exempted.

Deliberately not memoized. The crop flow evaluates this twice, which costs two role-meta lookups on an operation already doing file I/O; an ivar memo would have to be invalidated whenever CurrentRequest changes, and the object it would hang on is sometimes a long-lived plugin helper rather than a per-request controller.

Returns:

  • (Boolean)


61
62
63
64
65
66
67
68
69
70
71
# File 'lib/camaleon_cms/uploader_content_security.rb', line 61

def cama_trusted_for_unfiltered_upload?
  user = CurrentRequest.user
  site = CurrentRequest.site
  return false if user.blank? || site.blank?

  CamaleonCms::Ability.new(user, site).can?(:manage, :media_unfiltered_upload)
rescue StandardError
  # Ability#initialize dereferences the site and reads role metas for non-admin users;
  # malformed meta must fail closed instead of aborting the upload with a 500.
  false
end

#content_unsafe?(content, filename: nil) ⇒ Boolean

Scans in-memory content. Callers holding decoded bytes (e.g. a base64 data: payload) use this to check before writing to a web-served staging path, so rejected content never reaches a servable location.

Returns a truthy value (the matched pattern, or true for SVG) when unsafe, nil when safe -- matching file_content_unsafe?'s contract.

Returns:

  • (Boolean)


88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/camaleon_cms/uploader_content_security.rb', line 88

def content_unsafe?(content, filename: nil)
  extension = cama_upload_extension(filename)

  # No permission check here. Every caller reaches this method only after
  # `cama_trusted_for_unfiltered_upload?` already answered false, so the uploader is known to be
  # untrusted; asking again would cost a second role-meta lookup and could disagree with the
  # check that already ran. It also means the fail-closed behaviour is inherited exactly --
  # no request user or no site means the scan runs, and the scan refuses these.
  if SCRIPT_EXTENSIONS.include?(extension)
    Rails.logger.info { 'Potentially malicious content found: executable script upload' }
    return 'script_upload'
  end

  if MARKUP_EXTENSIONS.include?(extension)
    return true if markup_unsafe?(content, extension)

    return nil
  end

  normalized = CamaleonCms::ContentSecurity.normalize(content)
  if CamaleonCms::ContentSecurity.blocked_scheme_in?(normalized)
    Rails.logger.info { 'Potentially malicious content found: blocked URI scheme' }
    return 'blocked_scheme'
  end
  CamaleonCms::ContentSecurity::SUSPICIOUS_PATTERNS.each do |pattern|
    next unless normalized&.match?(pattern)

    Rails.logger.info { "Potentially malicious content found: #{pattern.inspect}" }
    return pattern.inspect
  end
  nil
end

#file_content_unsafe?(uploaded_io) ⇒ Boolean

IO-based entry point: reads the handle, rewinds it so downstream consumers still see the full content, and delegates to content_unsafe?.

Returns:

  • (Boolean)


123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/camaleon_cms/uploader_content_security.rb', line 123

def file_content_unsafe?(uploaded_io)
  file = uploaded_io.is_a?(ActionDispatch::Http::UploadedFile) ? uploaded_io.tempfile : uploaded_io
  filename = if uploaded_io.is_a?(ActionDispatch::Http::UploadedFile)
               uploaded_io.original_filename
             else
               uploaded_io.path
             end
  # `.svg` is read as-is; every other extension is forced to BINARY (svgz needs the raw gzip
  # bytes, and the markup parser reads the encoding from the content, not the String tag). Reuse
  # the filename already computed rather than re-deriving the path through svg_upload?.
  if cama_upload_extension(filename) != 'svg' && file.respond_to?(:binmode) && file.respond_to?(:set_encoding)
    file.set_encoding(Encoding::BINARY)
  end
  content = file.read
  file.rewind if file.respond_to?(:rewind)

  content_unsafe?(content, filename: filename)
end

#svg_upload?(uploaded_io) ⇒ Boolean

Returns:

  • (Boolean)


73
74
75
76
77
78
79
80
# File 'lib/camaleon_cms/uploader_content_security.rb', line 73

def svg_upload?(uploaded_io)
  file_path = if uploaded_io.is_a?(ActionDispatch::Http::UploadedFile)
                uploaded_io.original_filename
              else
                uploaded_io.path
              end
  cama_upload_extension(file_path) == 'svg'
end