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.

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

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.

Parameters:

  • root (String, nil)

    canonical directory

  • path (String, nil)

    root-relative path (may start with '/')

Returns:

  • (StaticFile, nil)

    the validated file, or nil when unsafe



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.

Parameters:

  • path (String, nil)

    request-relative path (may start with '/')

Returns:

  • (StaticFile, nil)

    the validated file, or nil when unsafe



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

Returns:

  • (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

Returns:

  • (Boolean)


90
91
92
# File 'lib/otto/core/file_safety.rb', line 90

def safe_file?(path)
  !resolve_static_file(path).nil?
end