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_static_file(path) ⇒ StaticFile?

Resolve a request path to a canonical, contained, servable file.

Parameters:

  • path (String, nil)

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

Returns:

  • (StaticFile, nil)

    the validated file, or nil when unsafe



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/otto/core/file_safety.rb', line 43

def resolve_static_file(path)
  return nil if option[:public].nil? || option[:public].empty?
  return nil if path.nil? || path.empty?

  public_dir = canonical_public_dir
  return nil if public_dir.nil?

  # 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 public directory.
  candidate = File.join(public_dir, clean_path)
  real_path = safe_realpath(candidate)
  return nil if real_path.nil?

  return nil unless contained?(real_path, public_dir)

  # 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(public_dir, real_path, real_path.delete_prefix(public_dir + File::SEPARATOR))
end

#safe_dir?(path) ⇒ Boolean

Returns:

  • (Boolean)


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/otto/core/file_safety.rb', line 78

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)


74
75
76
# File 'lib/otto/core/file_safety.rb', line 74

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