skip to content

In Ruby 4.0, what changed about Kernel#open with a leading pipe, and why still prefer File.open or a checked URI.open for request data?

level: middleimportance: must knowfreq 48%

answer

  1. "|cmd" used to fork a subprocess
  2. deprecated in 3.3, removed in 4.0
  3. now a plain file name: Errno::ENOENT
  4. path traversal still works
  5. URI.open falls back to local files

basics

~20 s

Ruby 4.0 removed process creation from Kernel#open and the IO class methods, so "|cmd" is now just a file name. A request path can still escape its directory, and URI.open still fetches any URL or reads a local file.

solid answer

~40 s

Before Ruby 4.0, `open("|cmd")`, and likewise `IO.read`, `IO.readlines`, `IO.write` and friends, started a subprocess for a string beginning with `|`, so a user-supplied file name was command execution. Ruby 3.3 deprecated that and 4.0 removed it: `open("|ls")` now looks for a file literally named `|ls` and usually raises `Errno::ENOENT`. Two problems remain. `Kernel#open` on a request path still opens any file the process can read (`../../config/secrets.yml`), so confine paths or map ids to file names, and prefer `File.open`, which says 'a file' and nothing else. `URI.open` from open-uri fetches `http`, `https` and `ftp` URLs, following redirects by default, and for any other string falls back to `Kernel#open` and reads a local file. Parse with `URI.parse`, check scheme and host, and pass `redirect: false`.

code

ruby · 12 lines
ruby
# Ruby 3.2: runs `id` and returns its output
# Ruby 3.3/3.4: the same, but deprecated
# Ruby 4.0: looks for a file literally named "|id"
open("|id", &:read)  # 4.0 => Errno::ENOENT (unless such a file exists)

BASE = File.expand_path("reports", __dir__)

def read_report(name)
  path = File.expand_path(name, BASE)
  raise ArgumentError, "bad path" unless path.start_with?(BASE + File::SEPARATOR)
  File.read(path)
end

go deeper

for a junior

Recall that open("|cmd") used to run a command, that Ruby 4.0 removed it, and that File.open is the method to use for files.

for a middle

Explain the 3.3 deprecation and 4.0 removal, the remaining traversal risk, and how URI.open decides between fetching a URL and opening a local file.

for a senior

Harden file and URL access from requests: map ids to paths or prefix-check expanded paths, and fetch URLs with scheme, host and redirect checks.

for a principal

Plan the 4.0 upgrade by finding code that relied on the pipe, and set a rule that request data never names a path or URL directly.

## The removed behaviour For most of Ruby's history, `Kernel#open` had a second personality: when its string argument began with a pipe character, it did not open a file but **started a subprocess** running the rest of the string and returned an IO connected to it. The `IO` class methods `IO.read`, `IO.write`, `IO.binread`, `IO.foreach` and `IO.readlines` did the same. Any code that passed a request-supplied file name to them, `open(params[:file])`, was a command-execution sink. | Ruby version | `open("\|id")` | |---|---| | up to 3.2 | runs `id` in a subprocess and returns an IO on its output | | 3.3 and 3.4 | same, but the behaviour is **deprecated** | | 4.0 | treated as a file name; usually raises `Errno::ENOENT` | `URI.open` inherited the problem because it hands non-URL strings to `Kernel#open`, and was part of the same 3.3 deprecation. Ruby 4.0 removed process creation from `Kernel#open` and the `IO` class methods entirely. Code that really wants a subprocess must now say so with `IO.popen` or the process APIs, which are a separate topic. ## What is still dangerous about Kernel#open - **Path traversal.** `open(params[:file])` opens any file the process can read. `"../../config/secrets.yml"`, an absolute path, or `/proc/self/environ` on Linux are all just paths. - **Duck-typed dispatch.** `Kernel#open` first checks whether its argument responds to `to_open` and, if so, calls that instead of opening a file. Request strings never have it, but the method does more than its name suggests. - **Code that runs on several Rubies.** A gem or service that still supports 3.x keeps the old pipe behaviour on those versions. `File.open` has never treated a leading `|` as a command. It expresses "open this file" and nothing else, which is why reviewers and linters steer file reads towards it. It does not solve traversal on its own, so confine the path as well: 1. Prefer **mapping ids to file names** you control, so the request never supplies a path. 2. Otherwise resolve with `File.expand_path(name, base_dir)` and require the result to start with `base_dir` plus `File::SEPARATOR`. 3. If symlinks inside the directory are possible, compare `File.realpath` results instead, since a link can point outside. ## URI.open is its own sink `require "open-uri"` adds `URI.open`, a convenience that makes a URL behave like a file. Since Ruby 3.0, requiring open-uri no longer redefines `Kernel#open`, so plain `open` never fetches URLs; you must call `URI.open` or `uri.open`. Its rules: - A string that starts like `scheme://` and parses to a `URI::HTTP`, `URI::HTTPS` or `URI::FTP` is **fetched** over the network. With a request-supplied URL that is server-side request forgery: internal hosts, admin ports and cloud metadata endpoints become reachable. - **Anything else falls back to `Kernel#open`**, so `URI.open("/etc/passwd")` reads the local file. - **Redirects are followed by default** (`redirect: true`, at most 64). open-uri refuses a redirect to an unrelated scheme such as `file`, and from `https` down to `http`, but an `https` URL that redirects to another `https` host is followed, so a host check done only on the original URL can be bypassed. The safe shape is to parse first with `URI.parse`, require `URI::HTTPS` and a host from an allowlist, and call `uri.open(redirect: false)` so a redirect raises `OpenURI::HTTPRedirect` instead of being followed. ## Choosing the call | Call | Accepts | Use for request data? | |---|---|---| | `Kernel#open` | a path, or any object with `to_open` | avoid: intent is unclear | | `File.open` / `File.read` | a file path only | yes, after confining the path | | `URI.open` | a URL, or falls back to `Kernel#open` | no: the caller cannot tell which happened | | `URI.parse(url)` then `uri.open` | only the parsed URL object | yes, after scheme, host and redirect checks | The pattern across the rows is the same: pick the call whose accepted inputs match what you meant, then check the input against that narrower meaning. ## Review checklist - `open(`, `IO.read(` or `URI.open(` whose argument contains request data. - File reads with no base directory, or a base check done with `include?` instead of a prefix check on the expanded path. - URL fetches without a scheme and host check, or with redirects left on. - Old comments or tests that rely on `"|cmd"` still spawning a process; on 4.0 they now open a file.

  • Why is checking the host of a URL before URI.open not enough on its own?
    open-uri follows redirects by default, up to 64 of them, and only refuses some scheme changes, such as to `file` or from `https` to `http`. An allowed `https` host that redirects to an internal `https` address is followed. Pass `redirect: false`, which makes a redirect raise `OpenURI::HTTPRedirect`, or re-check every hop yourself.
  • After upgrading to Ruby 4.0, what happens to code that relied on open("|cmd") on purpose?
    It no longer starts a process; it tries to open a file named after the whole string and usually raises `Errno::ENOENT`. Rewrite it with `IO.popen` or the process APIs, preferably passing the command as separate arguments rather than one shell string.
  • Does require "open-uri" make plain open fetch URLs?
    Not since Ruby 3.0. Requiring open-uri defines `URI.open` and `URI::HTTP#open`, but it no longer redefines `Kernel#open`, so a URL passed to `open` is treated as a file name.

saying these in an interview costs you the question

  • Since Ruby 4.0, Kernel#open is safe to call on any user-supplied path.
  • File.open also runs a command when the name starts with a pipe.
  • URI.open only ever makes HTTP requests, so it cannot read local files.
  • Checking the URL's host once is enough even with redirects enabled.
  • require "open-uri" makes plain open fetch URLs in current Ruby.