Module: CamaleonCms::UploaderPathSecurity
- Included in:
- RuntimeUploaderConcern, UploaderHelper
- Defined in:
- lib/camaleon_cms/uploader_path_security.rb
Overview
Shared helpers that keep file-upload path handling in a single place so the canonicalization guard and same-site URL detection cannot drift between RuntimeUploaderConcern and UploaderHelper (which both include this module).
Instance Method Summary collapse
-
#cama_allowed_upload_roots(extra_roots = []) ⇒ Object
The default roots plus any caller-supplied ones, and the private-media directory while the uploader is in private mode (so private files can be cropped without widening the roots for anything else).
-
#cama_base64_decoded_size(payload) ⇒ Object
Upper bound on the decoded byte size of a base64 payload, computed without decoding it, so an oversized upload can be rejected before it is allocated.
-
#cama_canonical_upload_path(path, extra_roots: []) ⇒ Object
Canonicalizes a string path and verifies it stays within the allowed upload roots (the Rails public dir or the system tmp dir).
-
#cama_extra_upload_roots(options) ⇒ Object
Reads a caller-supplied root list off an options hash, accepting either key form.
- #cama_private_upload_mode? ⇒ Boolean
- #cama_private_upload_root ⇒ Object
-
#cama_purge_staged_file(path, root) ⇒ Object
Removes a staged upload file, but only after confirming it canonicalizes inside the given staging root.
-
#cama_upload_failure(error, uploaded_io, settings) ⇒ Object
Passes an upload error through untouched, first removing the staging file when this upload owns it (remove_source), so a rejected upload leaves nothing behind in the web-served public/tmp directory.
-
#path_within?(path, root) ⇒ Boolean
True when the canonicalized path stays strictly inside the given root directory.
-
#same_host?(host_a, host_b) ⇒ Boolean
Case-insensitive host comparison that also ignores a single trailing dot, so a fully-qualified form ("site.com.") still matches the site host ("site.com") instead of being classified as a remote host — which would trigger a needless (and re-validated) outbound fetch to the site itself.
- #same_site_url?(url, site) ⇒ Boolean
- #site_url_path(url, site) ⇒ Object
-
#strip_locale_prefix(path, site) ⇒ Object
Removes a leading locale segment (e.g. "/es") on multi-language sites.
Instance Method Details
#cama_allowed_upload_roots(extra_roots = []) ⇒ Object
The default roots plus any caller-supplied ones, and the private-media directory while the uploader is in private mode (so private files can be cropped without widening the roots for anything else).
33 34 35 36 37 38 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 33 def cama_allowed_upload_roots(extra_roots = []) roots = [Rails.public_path.to_s, Dir.tmpdir] roots << cama_private_upload_root if cama_private_upload_mode? roots.concat(Array.wrap(extra_roots).compact_blank.map { |r| File.(r.to_s) }) roots.compact_blank end |
#cama_base64_decoded_size(payload) ⇒ Object
Upper bound on the decoded byte size of a base64 payload, computed without decoding it, so an oversized upload can be rejected before it is allocated. Overestimates by at most two bytes (padding), erring toward rejecting early.
74 75 76 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 74 def cama_base64_decoded_size(payload) payload.to_s.bytesize * 3 / 4 end |
#cama_canonical_upload_path(path, extra_roots: []) ⇒ Object
Canonicalizes a string path and verifies it stays within the allowed upload roots (the Rails public dir or the system tmp dir). Returns the expanded path when valid, or nil when the path escapes the roots or is otherwise hostile (null bytes, nil).
extra_roots widens the set for this call only, so trusted application code
(plugins, jobs, imports) can stage files elsewhere. It MUST come from
application code or operator configuration — never from a request parameter,
since Admin::MediaController#crop feeds params into this check and
the default roots are what stop it reading arbitrary files.
20 21 22 23 24 25 26 27 28 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 20 def cama_canonical_upload_path(path, extra_roots: []) = File.(path) roots = cama_allowed_upload_roots(extra_roots) return if roots.any? { |r| == r || .start_with?(r + File::SEPARATOR) } nil rescue ArgumentError, TypeError nil end |
#cama_extra_upload_roots(options) ⇒ Object
Reads a caller-supplied root list off an options hash, accepting either key form. Only application code populates this; see cama_canonical_upload_path.
52 53 54 55 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 52 def cama_extra_upload_roots() opts = .try(:to_h) || {} Array.wrap(opts[:allowed_roots] || opts['allowed_roots']) end |
#cama_private_upload_mode? ⇒ Boolean
44 45 46 47 48 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 44 def cama_private_upload_mode? respond_to?(:cama_uploader, true) && cama_uploader.try(:is_private_uploader?).present? rescue StandardError false end |
#cama_private_upload_root ⇒ Object
40 41 42 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 40 def cama_private_upload_root Rails.root.join(CamaleonCmsUploader::PRIVATE_DIRECTORY).to_s end |
#cama_purge_staged_file(path, root) ⇒ Object
Removes a staged upload file, but only after confirming it canonicalizes inside the given staging root. A bug in the calling code therefore cannot turn into a deletion elsewhere on the filesystem. Returns true when a file was removed.
61 62 63 64 65 66 67 68 69 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 61 def cama_purge_staged_file(path, root) return false if path.blank? || root.blank? return false unless path_within?(path, root) FileUtils.rm_f(path) true rescue ArgumentError, TypeError false end |
#cama_upload_failure(error, uploaded_io, settings) ⇒ Object
Passes an upload error through untouched, first removing the staging file when this upload owns it (remove_source), so a rejected upload leaves nothing behind in the web-served public/tmp directory. Shared so the cleanup rule cannot drift between RuntimeUploaderConcern and UploaderHelper.
82 83 84 85 86 87 88 89 90 91 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 82 def cama_upload_failure(error, uploaded_io, settings) # The first (malicious-content) rejection in upload_file runs before settings are # deep-symbolized, so honor a string-keyed remove_source too — otherwise a rejected upload # owning its staging file leaks it in the web-served public/tmp directory. remove_source = settings[:remove_source] || settings['remove_source'] return error unless remove_source cama_purge_staged_file(uploaded_io.try(:path), File.join(Rails.public_path, 'tmp').to_s) error end |
#path_within?(path, root) ⇒ Boolean
True when the canonicalized path stays strictly inside the given root directory. Used as a defense-in-depth check around write sinks.
95 96 97 98 99 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 95 def path_within?(path, root) File.(path).start_with?("#{File.(root)}#{File::SEPARATOR}") rescue ArgumentError, TypeError false end |
#same_host?(host_a, host_b) ⇒ Boolean
Case-insensitive host comparison that also ignores a single trailing dot, so a fully-qualified form ("site.com.") still matches the site host ("site.com") instead of being classified as a remote host — which would trigger a needless (and re-validated) outbound fetch to the site itself.
113 114 115 116 117 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 113 def same_host?(host_a, host_b) return false if host_a.blank? || host_b.blank? host_a.downcase.chomp('.') == host_b.downcase.chomp('.') end |
#same_site_url?(url, site) ⇒ Boolean
101 102 103 104 105 106 107 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 101 def same_site_url?(url, site) uri = Addressable::URI.parse(url) site_uri = Addressable::URI.parse(site.the_url(locale: nil)) same_host?(uri.host, site_uri.host) && uri.inferred_port == site_uri.inferred_port rescue Addressable::URI::InvalidURIError false end |
#site_url_path(url, site) ⇒ Object
119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 119 def site_url_path(url, site) uri = Addressable::URI.parse(url) path = uri.path.to_s # Strip the site's mount subpath (relative_url_root), if any, so that a # same-site URL under e.g. "http://host/blog/" maps to public/... and not # public/blog/... base = Addressable::URI.parse(site.the_url(locale: nil)).path.to_s.chomp('/') path = path.sub(%r{\A#{Regexp.escape(base)}(?=/|$)}, '') if base.present? strip_locale_prefix(path, site) rescue Addressable::URI::InvalidURIError url end |
#strip_locale_prefix(path, site) ⇒ Object
Removes a leading locale segment (e.g. "/es") on multi-language sites. To avoid mis-stripping a real first directory that merely shares a language code's name, only strip when the stripped path points at an existing file under the public dir; otherwise keep the path as-is.
136 137 138 139 140 141 142 143 144 |
# File 'lib/camaleon_cms/uploader_path_security.rb', line 136 def strip_locale_prefix(path, site) langs = site.get_languages return path unless langs.size > 1 stripped = path.sub(%r{\A/(?:#{Regexp.union(langs.map(&:to_s))})(?=/|$)}, '') return path if stripped == path File.exist?(File.(File.join(Rails.public_path, stripped))) ? stripped : path end |