Module: Otto::Core::FileSafety
- Included in:
- Otto
- Defined in:
- lib/otto/core/file_safety.rb
Overview
File safety module providing secure file access validation and path traversal protection.
Symlink policy (issue #257)
Every candidate path is canonicalized with File.realpath before it is compared against the canonicalized public root. A symlink inside the public directory is therefore served ONLY when its fully resolved target (including every intermediate directory component) is still inside that root. Links that escape the root are rejected even when the target is owned by the same user or group -- the ownership check is a second gate, not a containment gate.
The root itself is canonicalized too, so a symlinked public directory
(the usual public -> releases/<n>/public deploy layout) keeps working.
Missing, unreadable, looping and non-directory-component paths all fail closed: realpath raises and the raise is treated as "unsafe".
Defined Under Namespace
Classes: StaticFile
Constant Summary collapse
- REALPATH_ERRORS =
Errors raised by File.realpath for paths that must never be served.
[ Errno::ENOENT, # missing target (dangling symlink) Errno::EACCES, # unreadable component Errno::ELOOP, # symlink loop Errno::ENOTDIR, # a path component is not a directory Errno::ENAMETOOLONG, # oversized path ].freeze
Instance Method Summary collapse
-
#resolve_file_under(root, path) ⇒ StaticFile?
Resolve
pathagainst an already-canonicalrootand return it only when it is a contained, readable, owned regular file. -
#resolve_static_file(path) ⇒ StaticFile?
Resolve a request path to a canonical, contained, servable file under the implicit
public:directory. - #safe_dir?(path) ⇒ Boolean
- #safe_file?(path) ⇒ Boolean
Instance Method Details
#resolve_file_under(root, path) ⇒ StaticFile?
Resolve path against an already-canonical root and return it only
when it is a contained, readable, owned regular file.
Shared by the implicit public directory and explicit static mounts
(Otto::Core::StaticMounts) so both apply one containment policy.
root must be a File.realpath result: containment compares canonical
strings on a separator boundary, so a non-canonical root would never
match the canonicalized candidate.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/otto/core/file_safety.rb', line 62 def resolve_file_under(root, path) return nil if root.nil? || root.empty? return nil if path.nil? || path.empty? # A NUL byte in a request path is never legitimate; it is a truncation # attack on downstream C string handling. Reject it rather than # repairing the path into something servable. return nil if path.include?("\0") clean_path = path.strip return nil if clean_path.empty? # Join, then canonicalize: realpath resolves '..', '.' AND every # symlink component, so the containment check below cannot be fooled # by a link that points outside the root. candidate = File.join(root, clean_path) real_path = safe_realpath(candidate) return nil if real_path.nil? return nil unless contained?(real_path, root) # Second gate: it must be a readable regular file we (or our group) own. return nil unless File.file?(real_path) && File.readable?(real_path) return nil unless File.owned?(real_path) || File.grpowned?(real_path) StaticFile.new(root, real_path, real_path.delete_prefix(root + File::SEPARATOR)) end |
#resolve_static_file(path) ⇒ StaticFile?
Resolve a request path to a canonical, contained, servable file under
the implicit public: directory.
44 45 46 47 48 |
# File 'lib/otto/core/file_safety.rb', line 44 def resolve_static_file(path) return nil if option[:public].nil? || option[:public].empty? resolve_file_under(canonical_public_dir, path) end |
#safe_dir?(path) ⇒ Boolean
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/otto/core/file_safety.rb', line 94 def safe_dir?(path) return false if path.nil? || path.empty? # Clean and expand the path clean_path = path.delete("\0").strip return false if clean_path.empty? real_path = safe_realpath(clean_path) return false if real_path.nil? # Check directory exists, is readable, and has proper ownership File.directory?(real_path) && File.readable?(real_path) && (File.owned?(real_path) || File.grpowned?(real_path)) end |
#safe_file?(path) ⇒ Boolean
90 91 92 |
# File 'lib/otto/core/file_safety.rb', line 90 def safe_file?(path) !resolve_static_file(path).nil? end |