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
.svgcomparison. The two are not the same thing, and the difference was the bug: identical bytes carrying anonpointerdownattribute were refused asx.svgand stored asx.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 xin a .txt parses to abelement with anonattribute -- 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
-
#cama_trusted_for_unfiltered_upload? ⇒ Boolean
Whether the current uploader may skip the malicious-content scan.
-
#content_unsafe?(content, filename: nil) ⇒ Boolean
Scans in-memory content.
-
#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?.
- #svg_upload?(uploaded_io) ⇒ Boolean
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.
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.
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?.
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
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 |