Security

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. Ruby gives the programmer a great deal of power: any string can be turned into code, any method can be called by name, any class can be reopened, and the standard library can deserialize whole object graphs from bytes. Every one of those features is a security problem the moment the string, the name or the bytes come from outside the program. This page is a field guide to those problems and to the safe forms that replace them: code injection (eval, send, constant lookup by name, Kernel#open, symbols), unsafe deserialization (Marshal, YAML, JSON additions), command injection, regular expression pitfalls (line anchors and ReDoS), input validation and mass assignment, output escaping, paths and file permissions, randomness, constant-time comparison and password storage, secrets handling, TLS verification, resource exhaustion, dependency auditing, the RuboCop Security cops, and how to follow Ruby’s own security releases. It follows the official security guides on docs.ruby-lang.org and the policy pages on ruby-lang.org, and it is framework independent: web-framework protections (CSRF tokens, strong parameters, ORM query builders) are out of scope, although the same principles apply to them.

Several neighbouring pages go deeper on one corner of the topic. Parsing and dumping data formats is in Serialization: JSON, YAML, CSV and Marshal; processes, Open3 and signals are in Processes, Signals and Shell; files and Pathname are in IO, Files and Directories; sockets and Net::HTTP are in Networking and HTTP; regular expression syntax is in Regular Expressions; gems, lockfiles and checksums are in RubyGems and Bundler and Creating and Publishing Gems, and Rake; and linting is in Style, Linting and Static Analysis. For the same topic in another language see Go crypto and security.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The output shown in # => comments and in the output blocks is the real output. Files that need the shelf scenario require "shelf"; run them with the gem’s lib directory on the load path (ruby -I lib file.rb; see the shelf gem source). Two examples keep one RuboCop offense on purpose because they demonstrate the dangerous call itself: an inline rubocop:disable comment marks each of them (Security/MarshalLoad and Security/Open), and the deliberately unsafe file in the RuboCop section is not clean by design. All secrets and credentials in the examples are obvious placeholders.

The threat model

Security work starts with two questions: what is untrusted and where does it enter? Untrusted data is anything the program did not write itself: request parameters, form fields, file names uploaded or listed in a directory, the content of uploaded files, environment variables in some deployments, command-line arguments of a setuid or privileged program, rows in a database filled by users, messages from other services and anything read from the network. A trust boundary is the line where such data enters the program. The rule of this page is simple to state: at a trust boundary, convert the data into a plain value of a known type with strict validation, and never let it select code, a class, a method, a command, a path or a format-specific object graph.

Ruby adds one fact that surprises programmers coming from Java or the browser. There is no sandbox. Until Ruby 2.x the global $SAFE level and the "tainted" flag were an attempt to mark untrusted strings and to restrict what code could do with them. They were never a dependable boundary, and the mechanism was dismantled: since Ruby 3.0 $SAFE is an ordinary global variable with no special behavior, and Ruby 3.2 removed Object#taint, untaint, tainted?, trust, untrust and untrusted?. Code that runs in your process can do anything your process can do. Isolation therefore comes from the operating system (a separate user, container or virtual machine) and not from the language. Ractors (see Ractors) isolate state between threads of execution, not privileges, and they are not a security boundary either.

# frozen_string_literal: true

# Ruby 3.0 turned $SAFE into an ordinary global; 3.2 removed the taint methods.
$SAFE = 1
p $SAFE # => 1 (no effect at all)

begin
  "untrusted".taint
rescue NoMethodError => e
  puts e.message # => undefined method 'taint' for an instance of String
end

p "untrusted".respond_to?(:tainted?) # => false
Output
1
undefined method 'taint' for an instance of String
false

The table below is the map for the rest of the page: the place where untrusted data does harm (the sink), what goes wrong, and the safe form. Each row has a section.

Sink What goes wrong Safe form

eval, instance_eval, class_eval, binding.eval, ERB.new(text).result

Arbitrary code runs with the privileges of the process.

Never evaluate input. Parse a restricted format yourself, or use a data format.

send, public_send, method, const_get, Class#new with a name from input

The caller picks any method or class, including private ones and ones that exit the process.

Look the name up in a frozen table of allowed values.

Marshal.load, YAML.unsafe_load, JSON.unsafe_load, JSON.parse with create_additions: true

Objects of attacker-chosen classes are built and their methods run during loading.

JSON.parse, YAML.safe_load with an explicit allowlist, then your own constructors.

system, backticks, exec, spawn, IO.popen with one string

Shell metacharacters in the input run additional commands.

Pass an argument array so that no shell is involved.

Regular expressions with ^ and $, nested quantifiers

Validation bypass with an embedded newline; catastrophic backtracking.

\A and \z; Regexp.timeout; linear-time patterns.

File names and paths from input

Directory traversal, symlink escapes, world-readable results.

Resolve with File.realpath, check the prefix, create files with explicit permissions.

rand, Random, == on secrets

Predictable tokens; timing differences leak secrets.

SecureRandom, OpenSSL.fixed_length_secure_compare.

HTML, URLs and shell text built by interpolation

Cross-site scripting and injection in the target language.

Escape for the output context, use parameterized interfaces.

Compressed or nested input, unbounded reads

Memory and CPU exhaustion.

Size, depth and time limits.

Gems and Ruby itself

Known vulnerabilities and malicious releases.

Lockfiles with checksums, auditing, cooldown, security updates.

Code injection

eval and its relatives

Never pass untrusted or user-controlled input to eval. The official security guide is blunt about it: unless you are implementing a REPL such as IRB, eval is almost certainly not what you want, and filtering the input first does not make it safe, because Ruby syntax is large and flexible enough that a filter is nearly always bypassable. The same warning covers every method that compiles a string into code: instance_eval, class_eval and module_eval with a string argument, Binding#eval, Kernel#load and require with a path that comes from outside, and templating engines that compile a template to Ruby (ERB is the best-known one; see Output escaping). The block forms of instance_eval and class_eval are different: a block is code written by the programmer, not data (see Hooks, eval and DSLs for the safe, legitimate uses of metaprogramming).

When a feature seems to need eval, one of three substitutes almost always fits. If the input is a value, use a data format (JSON, a strict Integer() or Float() conversion). If the input chooses one of a few behaviors, map names to behaviors in a frozen hash (below). If the input is a small expression language, write a small parser, as the calculator does: a strict pattern that accepts only the grammar you intend, an allowlist of operators, and numeric conversion with Integer().

# frozen_string_literal: true

# A calculator that never evaluates its input: a strict pattern, an
# allowlist of operators, and Integer() for the numbers.
OPERATORS = %i[+ - *].freeze
EXPRESSION = /\A\s*(?<left>\d{1,9})\s*(?<op>[-+*])\s*(?<right>\d{1,9})\s*\z/

def calculate(text)
  match = EXPRESSION.match(text) or raise ArgumentError, "not an expression: #{text.inspect}"
  op = match[:op].to_sym
  raise ArgumentError, "operator not allowed: #{op}" unless OPERATORS.include?(op)

  Integer(match[:left], 10).public_send(op, Integer(match[:right], 10))
end

p calculate("6 * 7") # => 42
begin
  calculate("1; system('id')")
rescue ArgumentError => e
  puts e.message # => not an expression: "1; system('id')"
end
Output
42
not an expression: "1; system('id')"

send, public_send and dynamic dispatch

Global functions such as puts and exit are private instance methods of Object, and send ignores visibility, so 1.send(:puts, "hi") works. Two consequences follow, both from the official guide. Calling send with a name taken from input lets an attacker call exit!, which is a denial of service; when the attacker also controls the first argument that follows, foo.send("eval", "...") is remote code execution. And because send is itself a public method, public_send("send", "eval", "...") has the same effect as the call it appears to restrict. A method name that comes from input must therefore never reach send or public_send directly. Check it against an allowlist, and take the method symbol from the allowlist, not from the input. public_send is still the right call after that check, since it respects visibility and keeps an allowed name from reaching a private helper.

# frozen_string_literal: true

require "shelf"

# A tiny command console. The command name arrives from outside, so it is only
# ever used as a key into a table, never as a method name.
class Console
  COMMANDS = { "count" => :count, "titles" => :titles }.freeze

  def initialize(catalog) = @catalog = catalog

  def run(name)
    command = COMMANDS.fetch(name) { raise ArgumentError, "unknown command: #{name.inspect}" }
    public_send(command)
  end

  def count = @catalog.count

  def titles = @catalog.map(&:title)
end

author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(Shelf::Isbn.new("978-0-441-47812-5"), "The Dispossessed", author)
console = Console.new(Shelf::Catalog.new.add(book))

p console.run("count")  # => 1
p console.run("titles") # => ["The Dispossessed"]

begin
  console.run("exit!")
rescue ArgumentError => e
  puts e.message # => unknown command: "exit!"
end

# send ignores visibility, public_send does not.
1.send(:puts, "send reaches private Kernel methods")
name = :puts
begin
  1.public_send(name, "never printed")
rescue NoMethodError => e
  puts e.message # => private method 'puts' called for an instance of Integer
end
Output
1
["The Dispossessed"]
unknown command: "exit!"
send reaches private Kernel methods
private method 'puts' called for an instance of Integer

The related reflective calls need the same discipline: method(name), instance_variable_get, instance_variable_set, define_method(name) and respond_to? followed by a call. See Dynamic Dispatch and Method Definition for how these calls work.

Classes and constants chosen by name

Turning a string into a class (Object.const_get(name), Module#const_get, Kernel#const_get in older code, name.constantize in Rails, Class.new with a superclass picked by name) hands the caller every constant in the process: File, Kernel, Process, and any class whose new has side effects. safe_constantize (an ActiveSupport method, not part of core Ruby; it returns nil rather than raising) is a convenience, not a guard. Two defenses work. Passing false as the second argument of const_get stops the search from climbing through ancestors and top-level constants, so a lookup inside Shelf::Store cannot reach File. A frozen table of the allowed classes is stronger, because it also stops the caller from naming any class inside the namespace that you did not intend to expose. Plugins and adapters registered at load time (a registry hash filled by an inherited hook) are the same idea in dynamic form.

# frozen_string_literal: true

require "shelf"

# Looking a class up by a name that came from outside reaches every constant.
p Object.const_get("File")                                # => File
p Object.const_get("Shelf::Store::Memory")                # => Shelf::Store::Memory

# inherit = false stops the search at the named module...
begin
  Shelf::Store.const_get("File", false)
rescue NameError => e
  puts e.message # => uninitialized constant Shelf::Store::File
end

# ...but a table of allowed classes is the safe way to pick a class.
STORES = { "memory" => Shelf::Store::Memory }.freeze

def build_store(name)
  STORES.fetch(name) { raise ArgumentError, "unknown store: #{name.inspect}" }.new
end

p build_store("memory").class # => Shelf::Store::Memory
begin
  build_store("File")
rescue ArgumentError => e
  puts e.message # => unknown store: "File"
end
Output
File
Shelf::Store::Memory
uninitialized constant Shelf::Store::File
Shelf::Store::Memory
unknown store: "File"

Kernel#open, load and require

Kernel#open is the oldest trap of this family. Up to Ruby 3.2, a call such as open("|command") started the command in a shell, and open with a string that began with a pipe character therefore turned any file-name parameter into a command-execution vulnerability. Ruby 3.3 deprecated process creation through Kernel#open, URI.open, IO.read, IO.write, IO.binread, IO.foreach and IO.readlines (and the warning text says so at run time), and Ruby 4.0 removed it: a leading | is now just a character in a file name. Code that must run for Ruby 3.2 and older still needs File.open, File.read and the other File class methods, which never had the pipe form. open-uri adds URI.open, which since Ruby 3.0 no longer redefines Kernel#open; it can fetch any URL the process can reach, so a URL from input is also a server-side request forgery risk (see Network and TLS). The RuboCop cop Security/Open reports open calls with dynamic data.

# frozen_string_literal: true

require "tmpdir"

# Kernel#open treated a leading pipe as a command until Ruby 3.3 deprecated it
# and Ruby 4.0 removed it. In 4.0 the string is simply a file name.
begin
  open("|echo not a command") # rubocop:disable Security/Open
rescue Errno::ENOENT => e
  puts e.message # => No such file or directory @ rb_sysopen - |echo not a command
end

# Say what you mean: File.open / File.read for files, an array for commands.
Dir.mktmpdir do |dir|
  path = File.join(dir, "note.txt")
  File.write(path, "hello\n")
  p File.read(path) # => "hello\n"
end
IO.popen(["echo", "from an array"]) { |io| p io.read } # => "from an array\n"
Output
No such file or directory @ rb_sysopen - |echo not a command
"hello\n"
"from an array\n"

require, require_relative and load run the file they find. A path built from input (require params[:plugin]) loads and executes any Ruby file the process can reach, including one an attacker managed to upload. Plugin systems should map names to a fixed list of require calls. Also remember that the program’s load path is influenced by the environment (RUBYLIB, RUBYOPT, GEM_HOME, GEM_PATH and BUNDLE_GEMFILE), which is why a privileged program should clear the environment before it starts Ruby; see Runtime and environment.

Symbols and denial of service

The security guide explains why symbols matter. MRI uses symbols for method, variable and constant names. Most symbols are mortal (they can be garbage collected, which includes the ones created by String#to_sym), and these are safe to create from input. Immortal symbols are never collected: they are created when code is modified, by defining a method (define_method), setting an instance variable (instance_variable_set) or defining a constant (const_set), and by C extensions that have not been updated and still call SYM2ID. Do not create immortal symbols from user input, because a stream of unique names grows memory without bound until the process dies. The old advice that to_sym, respond_to?, method, instance_variable_get and const_get leak symbols is outdated: those calls no longer create immortal symbols. The example shows both behaviors with the real symbol table.

# frozen_string_literal: true

def symbol_count = Symbol.all_symbols.size

def collect
  3.times { GC.start }
end

collect
baseline = symbol_count

# String#to_sym creates a dynamic symbol that the garbage collector can reclaim.
20_000.times do |i|
  name = "mortal_#{i}"
  name.to_sym
end
collect
reclaimed = symbol_count
p reclaimed - baseline < 1_000 # => true (the 20,000 symbols did not stay)

# Symbols created by defining methods, variables or constants stay for good.
holder = Class.new
20_000.times { |i| holder.define_method("immortal_#{i}") { i } }
collect
kept = symbol_count
p kept - reclaimed >= 19_000 # => true (every defined name is still in the table)

# Allowlist what a caller may name instead of defining or setting by input.
ALLOWED = %w[title author].freeze
def attribute(name) = ALLOWED.include?(name) ? name.to_sym : raise(ArgumentError, "not allowed: #{name.inspect}")

p attribute("title") # => :title
Output
true
true
:title

Unsafe deserialization

Loading bytes into objects is the second large family. A format that can describe any Ruby object lets whoever writes the bytes choose the class and the instance variables, and Ruby then calls methods on the result: marshal_load, init_with, hash, ==, to_s, finalizers. A chain of such calls in classes that are loaded in the process is called a gadget chain, and public gadget chains for Ruby exist. The only reliable defense is to use a format that cannot describe objects.

Marshal

Marshal is Ruby’s binary serialization format. The official documentation states the rule without hedging: by design Marshal.load can deserialize almost any class loaded in the process, which in many cases leads to remote code execution when the data comes from an untrusted source, so it is not a general purpose format and you should never unmarshal user input. Its safe uses are narrow: copying an object graph inside one process (Marshal.load(Marshal.dump(obj)) as a deep copy), and data written and read by the same trusted program, such as a cache that no one else can write. Even there, it is brittle across Ruby versions and across changes to the classes involved.

The example shows three things without needing an exploit. A class that defines marshal_load runs its own code in the middle of loading. And Marshal.load rebuilds objects without calling initialize, so the validation in Shelf::Isbn (which rejects a bad check digit) is skipped: a forged byte string yields an invalid Isbn that your constructor would never have created. Finally, the safe path: parse JSON, which can only produce hashes, arrays, strings, numbers, booleans and nil, and build the value objects with their own constructors, so their validation runs.

# frozen_string_literal: true

require "shelf"
require "json"

# 1. Marshal.load runs class-defined code while it reads the bytes.
class Noisy
  def marshal_dump = [1]

  def marshal_load(state)
    puts "marshal_load ran with #{state.inspect}"
  end
end
Marshal.load(Marshal.dump(Noisy.new)) # => prints: marshal_load ran with [1]

# 2. Marshal.load does not call initialize, so validation is skipped.
good = Shelf::Isbn.new("978-0-441-47812-5")
bytes = Marshal.dump(good)
forged = bytes.sub("9780441478125", "1234567890123")
bad = Marshal.load(forged) # rubocop:disable Security/MarshalLoad
p bad # => #<data Shelf::Isbn digits="1234567890123">
begin
  Shelf::Isbn.new("1234567890123")
rescue ArgumentError => e
  puts e.message # => invalid ISBN: 1234567890123
end

# 3. The safe path for untrusted data: a data-only format plus your own constructor.
payload = '{"isbn": "978-0-441-47812-5", "title": "The Dispossessed", "author": "Ursula K. Le Guin"}'
data = JSON.parse(payload, symbolize_names: true)
book = Shelf::Book.new(Shelf::Isbn.new(data.fetch(:isbn)), data.fetch(:title), Shelf::Author.new(data.fetch(:author)))
puts book # => The Dispossessed by Ursula K. Le Guin
Output
marshal_load ran with [1]
#<data Shelf::Isbn digits="1234567890123">
invalid ISBN: 1234567890123
The Dispossessed by Ursula K. Le Guin

Marshal.load accepts a proc that is called on each deserialized object, and a freeze: true option. Neither is a security measure: the proc runs after an object has been built, and freeze only freezes the result. Do not rely on them to make untrusted data safe. The same holds for the tools that wrap it: PStore (and the pstore bundled gem) stores Marshal data, so a writable store file is an object-injection vector, and any cache built on Marshal inherits the problem. Reading a store file that another user can write is the same risk as Marshal.load on their input.

YAML

Psych, the YAML library, can also build arbitrary objects through tags such as !ruby/object:ClassName. The security guide’s example creates an ERB object from YAML so that loading it runs a shell command. Until 2021 YAML.load did that by default, which is why a decade of advisories and old tutorials say "use safe_load`". Since Psych 4.0 (Ruby 3.1) `YAML.load is safe_load with a small default allowlist: it refuses arbitrary objects and aliases, and permits only Symbol beyond the plain types. The unsafe behavior is still available under an unmistakable name, YAML.unsafe_load (and unsafe_load_file), which the documentation says must not be used on untrusted documents. In practice:

  • YAML.load and YAML.safe_load for any document you did not write; list the classes you accept with permitted_classes: (for example [Date], [Symbol]), and set aliases: true only when the document needs anchors and you control its size, since aliases can expand into a very large structure (the "billion laughs" attack).

  • symbolize_names: true and freeze: true are available on both.

  • YAML.load_file and safe_load_file read a path; use safe_load_file.

  • The errors are Psych::DisallowedClass ("Tried to load unspecified class: Object") and Psych::AliasesNotEnabled; both are Psych::Exception descendants you can rescue to report a malformed file.

JSON

JSON.parse is the safe default: it builds hashes, arrays, strings, numbers, booleans and nil and nothing else. Two switches change that. First, JSON additions (json/add/range, json/add/core, json/add/struct and others) teach classes to read a json_class key and rebuild themselves. JSON.parse ignores json_class unless you pass create_additions: true. Second, JSON.load is for trusted data only and historically enabled additions implicitly; since json 2.8, JSON.load emits a deprecation warning ("JSON.load implicit support for create_additions: true is deprecated and will be removed in 3.0, use JSON.unsafe_load or explicitly pass create_additions: true`") when it deserializes a non-native type, and json 3.0 will switch additions off. `JSON.unsafe_load makes the old behavior explicit. The practical rule: call JSON.parse, never JSON.load or unsafe_load on input, and never require json/add/* in a process that parses untrusted JSON with additions enabled. RuboCop reports JSON.load with Security/JSONLoad.

Also bound the size of the document: JSON.parse has a default max_nesting of 100 levels and raises JSON::NestingError beyond it (the exact boundary of the check differs by json version, so do not depend on the number). Pass a lower max_nesting: for input that cannot legitimately nest. Limit the byte size before parsing; see Resource limits.

# frozen_string_literal: true

require "date"
require "yaml"
require "json"

def attempt
  p yield
rescue StandardError => e
  puts "#{e.class}: #{e.message}"
end

# YAML.load is a safe load since Psych 4 (Ruby 3.1).
attempt { YAML.load("title: The Dispossessed\ncopies: 3") }
attempt { YAML.load("--- !ruby/object:Object {}") }
attempt { YAML.load("due: 2026-10-18") }
attempt { YAML.safe_load("due: 2026-10-18", permitted_classes: [Date]) }
attempt { YAML.load("a: &x [1]\nb: *x") }
attempt { YAML.safe_load("a: &x [1]\nb: *x", aliases: true) }
attempt { YAML.safe_load("title: x", symbolize_names: true) }
attempt { YAML.unsafe_load("--- !ruby/object:Object {}").class }

# JSON.parse only builds Hash, Array, String, Integer, Float, true, false, nil.
attempt { JSON.parse('{"json_class": "Range", "a": [1, 2, false]}') }
attempt { JSON.parse("[[[1]]]", max_nesting: 2) }

# create_additions: true turns json_class keys into objects, once json/add/* is loaded.
require "json/add/range"
attempt { JSON.parse('{"json_class": "Range", "a": [1, 2, false]}', create_additions: true) }

# JSON.load used to switch additions on implicitly; JSON.unsafe_load (json 2.8) says so in its name.
attempt { JSON.unsafe_load('{"json_class": "Range", "a": [1, 2, false]}') }
Output
{"title" => "The Dispossessed", "copies" => 3}
Psych::DisallowedClass: Tried to load unspecified class: Object
Psych::DisallowedClass: Tried to load unspecified class: Date
{"due" => #<Date: 2026-10-18 ((2461332j,0s,0n),+0s,-Infj)>}
Psych::AliasesNotEnabled: Alias parsing was not enabled. To enable it, pass `aliases: true` to `Psych::load` or `Psych::safe_load`.
{"a" => [1], "b" => [1]}
{title: "x"}
Object
{"json_class" => "Range", "a" => [1, 2, false]}
JSON::NestingError: nesting of 3 is too deep
1..2
1..2

Other formats follow the same rule. CSV (a bundled gem) and Marshal-free formats such as MessagePack or Protocol Buffers produce data only. XML libraries are another case: the rexml bundled gem has had several denial-of-service advisories for specially crafted documents, listed on the ruby-lang.org security page, which is a reason to keep bundled gems updated and to cap document size before parsing. The full tour of formats is in Serialization: JSON, YAML, CSV and Marshal.

Command injection

Some core methods accept a string that may be executed as a system command. The official security guide lists them: Kernel.exec, Kernel.spawn, Kernel.system, the backtick method (also called by %x[command]) and IO.popen (when called with anything other than "-"). They must not be called with unknown or unsanitized strings.

The mechanism is the shell. When these methods receive one string that contains shell metacharacters (a semicolon, &&, a pipe, a dollar sign, backticks, redirection, a glob), they hand the string to /bin/sh -c, and the shell interprets it. When they receive the program and its arguments as separate arguments (or IO.popen an array), Ruby starts the program directly with execve: there is no shell, no interpretation, and a value containing ; is just text in ARGV. The rule therefore is: pass an argument array whenever any piece comes from input. Open3 (capture2, capture2e, capture3, popen3) accepts the same array form and also returns the output and the exit status. If a shell really is needed (for a pipeline), Shellwords.escape (and Shellwords.join) quote each untrusted piece for a POSIX shell; they are not for Windows cmd.exe.

Two subtler cases remain even without a shell. Option injection: a value that begins with - is taken by the program as an option (rm -rf, git --upload-pack=..., ruby -v). Put -- before the untrusted arguments when the program supports it, validate that file names do not begin with a dash, or prefix them with ./. Environment: the child inherits the parent’s environment, which may contain secrets (API tokens) it does not need and variables such as RUBYOPT or LD_PRELOAD that change how it loads code. The environment hash as the first argument adds or removes variables (a nil value unsets one), and unsetenv_others: true starts the child with nothing but what you pass.

# frozen_string_literal: true

require "open3"
require "shellwords"

# Pretend this came from a form field. The text after the semicolon is harmless
# on purpose: it only prints a word.
title = "The Dispossessed; echo INJECTED"

puts "-- one string: the shell parses it"
system("echo Looking up #{title}")

puts "-- separate arguments: no shell, one argument"
system("echo", "Looking up", title)

puts "-- Open3 with an argument array"
out, status = Open3.capture2("echo", title)
p out # => "The Dispossessed; echo INJECTED\n"
p status.success? # => true

puts "-- when a shell is unavoidable, escape every piece"
system("echo #{Shellwords.escape(title)}")
p Shellwords.escape(title) # => "The\\ Dispossessed\;\\ echo\\ INJECTED"

puts "-- option injection: a value that looks like a flag"
value = "-v"
out, = Open3.capture2(RbConfig.ruby, "-e", "p ARGV", value)
p out.split.first(2) # => ["ruby", "4.0.7"] (ruby saw -v as its own option)
out, = Open3.capture2(RbConfig.ruby, "-e", "p ARGV", "--", value)
p out # => "[\"-v\"]\n" (after -- it is plain data)

puts "-- a clean environment for the child"
out, = Open3.capture2({ "SHELF_API_TOKEN" => "placeholder-token" }, "sh", "-c", 'echo "${SHELF_API_TOKEN:-unset}"')
p out # => "placeholder-token\n"
out, = Open3.capture2({}, "sh", "-c", 'echo "${SHELF_API_TOKEN:-unset}"', unsetenv_others: true)
p out # => "unset\n"
Output
-- one string: the shell parses it
Looking up The Dispossessed
INJECTED
-- separate arguments: no shell, one argument
Looking up The Dispossessed; echo INJECTED
-- Open3 with an argument array
"The Dispossessed; echo INJECTED\n"
true
-- when a shell is unavoidable, escape every piece
The Dispossessed; echo INJECTED
"The\\ Dispossessed\\;\\ echo\\ INJECTED"
-- option injection: a value that looks like a flag
["ruby", "4.0.7"]
"[\"-v\"]\n"
-- a clean environment for the child
"placeholder-token\n"
"unset\n"

More notes. Prefer a Ruby method over a command when one exists (FileUtils.cp, File.rename, Dir.children, Zlib instead of gzip): it has no shell and no argument parsing to attack. system returns nil when the command could not start, false when it exited non-zero, and true on success; pass exception: true to raise (Errno::ENOENT or RuntimeError) instead of returning a value that is easy to ignore. The Process and signal details are in Processes, Signals and Shell. SQL and other command-like languages follow the same principle: use the library’s parameterized (placeholder) interface and never interpolate values into the query text. Ruby’s standard library has no SQL client, so the rule belongs to the gem you use (sqlite3, pg, mysql2, Sequel, ActiveRecord), but the mistake is always the same string interpolation.

Regular expressions

Line anchors versus string anchors

In Ruby, ^ and $ match the start and end of a line, not of the string. The security guide gives the attack: a validation such as /^[a-z]+$/ accepts "abc\n<script>", because the first line matches. An attacker who passes a letter, a newline and anything else bypasses the check. Use \A (start of string) and \z (end of string) for validation. \Z allows a trailing newline and is rarely what you want.

Two details matter for the character classes. \d matches only ASCII digits in Ruby (unlike or \p{Nd}, which match Unicode digits), which is the safer behavior for validation of identifiers. And a match against a string with invalid bytes raises ArgumentError ("invalid byte sequence in UTF-8"), so check valid_encoding? first (see Input validation).

ReDoS and Regexp.timeout

A regular expression with nested quantifiers ((a+)+) or overlapping alternatives can take exponential time on input crafted to fail, which is called a regular expression denial of service (ReDoS). Ruby 3.2 attacked the problem twice. It added a cache-based optimization that makes many (not all) matches run in linear time, and Ruby 3.3 widened it to lookarounds and atomic groups, as long as they contain no captures and are not nested. The optimization does not apply to patterns that use backreferences or subexpression calls. Regexp.linear_time?(regexp) tells you whether a pattern qualifies; you can use it in a test or when compiling a pattern from configuration. Second, Ruby 3.2 added a time limit: Regexp.timeout= sets a process-wide limit in seconds (nil, the default, means none), and Regexp.new(source, timeout: seconds) sets one for a single regexp, which takes priority. When the limit passes, the match raises Regexp::TimeoutError ("regexp match timeout"), a subclass of RegexpError.

The practical guidance is: set Regexp.timeout once at program start (a second is generous for validation), use per-regexp timeouts for the few patterns that must be expensive, and never build a regexp from input without Regexp.escape (or Regexp.union). A pattern assembled from untrusted text is both a ReDoS and an injection vector, since .* and lookarounds can be supplied by the attacker.

# frozen_string_literal: true

# Line anchors versus string anchors.
input = "isbn\n<script>"
p input.match?(/^[a-z]+$/)   # => true
p input.match?(/\A[a-z]+\z/) # => false

# Unicode digits.
p "123".match?(/\A\d+\z/) # => true
p "\u0661\u0662\u0663".match?(/\A\d+\z/) # => false
p "\u0661\u0662\u0663".match?(/\A[[:digit:]]+\z/) # => true

# Regexp.timeout: a process-wide default; nil means no limit.
p Regexp.timeout # => nil

# Since Ruby 3.2 most patterns match in linear time.
p Regexp.linear_time?(/\A(a+)+\z/)    # => true
p Regexp.linear_time?(/\A(a+)+\1\z/)  # => false (a backreference defeats it)

subject = "#{'a' * 40}b"
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
p(/\A(a+)+\z/.match?(subject)) # => false, immediately
puts format("%.2f seconds", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)

risky = Regexp.new('\A(a+)+\1\z', timeout: 0.2)
begin
  risky.match?(subject)
rescue Regexp::TimeoutError => e
  puts e.message # => regexp match timeout
end

Regexp.timeout = 1.0
p Regexp.timeout # => 1.0
Regexp.timeout = nil
Output
true
false
true
false
true
nil
true
false
false
0.00 seconds
regexp match timeout
1.0

Input validation and mass assignment

Validate at the boundary, strictly, and convert to a typed value early. A few Ruby specifics:

  • String#to_i and to_f are lenient: "12abc".to_i is 12, and "abc".to_i is 0. At a trust boundary use Integer(text, 10) and Float(text), which raise ArgumentError on trailing garbage, hexadecimal or underscores surprises, and TypeError on nil. Passing exception: false returns nil instead of raising. Pass the base explicitly: Integer("0x1f") is 31 and Integer("010") is 8 (octal), which surprises validation code that expects decimal.

  • Check the encoding: valid_encoding?, scrub (replace invalid bytes), and unicode_normalize (which raises on invalid bytes). Compare and look up user-provided text only after normalizing it to one form, otherwise two spellings of one name pass different checks.

  • NaN and Infinity are valid Float values: Float("NaN") raises but a computation can still produce them, so check finite? where it matters.

  • Use \A...\z and -style classes, or an allowlist of values, and bound lengths. Reject rather than strip: silently removing dangerous characters is a classic bypass (removing ../ from ....//).

  • Use value objects with validating constructors (Data.define with an initialize check, as Shelf::Isbn does) so the invariant is enforced in one place. See Struct, Data and Value Objects and Conversions and Coercion.

# frozen_string_literal: true

def try
  p yield
rescue StandardError => e
  puts "#{e.class}: #{e.message}"
end

# String#to_i is lenient; Integer() is strict.
try { "12abc".to_i }                      # => 12
try { Integer("12abc", 10) }              # => ArgumentError
try { Integer("0x1f", 10) }               # => ArgumentError
try { Integer(nil) }                      # => TypeError
try { Integer("", exception: false) }     # => nil
try { Float("12.5abc") } # => ArgumentError
try { Float("NaN") } # => ArgumentError

# Invalid bytes: a regexp match raises, valid_encoding? and scrub are the checks.
raw = "caf\xC3(".dup.force_encoding(Encoding::UTF_8)
try { raw.valid_encoding? }               # => false
try { raw.match?(/caf/) }                 # => ArgumentError
try { raw.scrub("?") }                    # => "caf?("
try { raw.unicode_normalize }             # => ArgumentError

# Control characters and newlines are valid text, so allow only what you expect.
try { "title\nSet-Cookie: x=1".match?(/\A[[:print:]]+\z/) } # => false
try { "ok title".match?(/\A[[:print:]]+\z/) }               # => true
Output
12
ArgumentError: invalid value for Integer(): "12abc"
ArgumentError: invalid value for Integer(): "0x1f"
TypeError: can't convert nil into Integer
nil
ArgumentError: invalid value for Float(): "12.5abc"
ArgumentError: invalid value for Float(): "NaN"
false
ArgumentError: invalid byte sequence in UTF-8
"caf?("
ArgumentError: invalid byte sequence in UTF-8
false
true

Mass assignment is a validation failure of its own kind: copying every key of a hash into attributes lets the caller set fields you never meant to expose (an admin flag, an owner id). The Ruby form is each { |k, v| obj.public_send("#{k}=", v) }, which is also a dispatch-by-input problem. Allow keys explicitly with Hash#slice and a frozen list. Data.new(**params) and methods with keyword parameters already reject unknown keys with ArgumentError, which is a good reason to prefer explicit keyword arguments to loose option hashes.

# frozen_string_literal: true

Member = Struct.new(:name, :admin)

params = { name: "Ada", admin: true } # parsed from a request body

# Unsafe: every key becomes a setter call.
member = Member.new
params.each { |key, value| member.public_send("#{key}=", value) }
p member # => #<struct Member name="Ada", admin=true>

# Safe: an explicit allowlist of keys, everything else is dropped.
PERMITTED = %i[name].freeze
member = Member.new
params.slice(*PERMITTED).each { |key, value| member.public_send("#{key}=", value) }
p member # => #<struct Member name="Ada", admin=nil>

# Data.new and keyword arguments refuse unknown keys on their own.
Profile = Data.define(:name)
begin
  Profile.new(**params)
rescue ArgumentError => e
  puts e.message # => unknown keyword: :admin
end
Output
#<struct Member name="Ada", admin=true>
#<struct Member name="Ada", admin=nil>
unknown keyword: :admin

Source: Hash#slice and Data

Output escaping

Injection into a target language is prevented where the data is output, with the escaping rule of that language. HTML text, HTML attributes, URLs, JavaScript, CSS, SQL and shell each have different rules, and a value escaped for one context is not safe in another. Ruby’s standard library supplies the first two:

  • ERB::Util.html_escape (alias h, available after include ERB::Util) and CGI.escapeHTML (from cgi/escape, which remains available after the rest of CGI was removed from the default gems in Ruby 4.0) escape &, <, >, " and '. They are correct for text and for quoted attribute values.

  • ERB::Util.url_encode and URI.encode_www_form_component encode a value for a URL component or form value. They are different from html_escape: a URL inside an href still has to be checked for its scheme (javascript: URLs run script) and then HTML-escaped as an attribute.

  • There is no built-in escape for JavaScript or CSS contexts: do not interpolate input into inline script or style blocks; pass data as JSON in a data- attribute or a type="application/json" block, escaped for HTML.

ERB does not escape anything by default; <%= value %> writes the string as is. Web frameworks wrap ERB with auto-escaping and mark safe strings; in plain Ruby you escape every dynamic value explicitly, as the view class below does. And a template from untrusted text is code: ERB.new(text).result compiles text into Ruby and runs it, so <%= 6 * 7 %> prints 42, and <%= system("...") %> runs a command. Treat templates as part of the program, loaded from files you control, never from users. The ruby-lang.org security page lists a 2026 advisory (CVE-2026-41316) in which ERB’s deserialization guard could be bypassed through def_module, def_method or def_class, a reminder that keeping the erb gem current matters.

# frozen_string_literal: true

require "erb"
require "cgi/escape"
require "uri"
require "shelf"

# Data goes through an escaping helper, in the context where it lands.
class BookView
  include ERB::Util

  TEMPLATE = ERB.new(<<~HTML, trim_mode: "-")
    <li class="book"><a href="/search?q=<%= url_encode(@book.title) %>"><%= h(@book.title) %></a></li>
  HTML

  def initialize(book) = @book = book

  def render = TEMPLATE.result(binding)
end

hostile = Shelf::Book.new(Shelf::Isbn.new("978-0-441-47812-5"), %(<i>Dune & 'Co'</i>), Shelf::Author.new("A"))
puts BookView.new(hostile).render
# => <li class="book"><a href="/search?q=%3Ci%3EDune%20%26%20%27Co%27%3C%2Fi%3E">
# =>   &lt;i&gt;Dune &amp; &#39;Co&#39;&lt;/i&gt;</a></li>   (one line in reality)

p CGI.escapeHTML("<a>&") # => "&lt;a&gt;&amp;"
p URI.encode_www_form_component("a b&c=d") # => "a+b%26c%3Dd"

# A template that comes from outside is code: ERB compiles it to Ruby.
user_template = "<%= 6 * 7 %>"
puts ERB.new(user_template).result # => 42
Output
<li class="book"><a href="/search?q=%3Ci%3EDune%20%26%20%27Co%27%3C%2Fi%3E">&lt;i&gt;Dune &amp; &#39;Co&#39;&lt;/i&gt;</a></li>
"&lt;a&gt;&amp;"
"a+b%26c%3Dd"
42

Source: ERB, ERB::Util and CGI

Files and paths

Path traversal

A file name from input can contain .., an absolute path, a symbolic link, a null byte, or characters that mean something to the operating system. File.join(base, name) does not protect anything: File.join("covers", "../secrets") is "covers/../secrets". The robust pattern has three steps. Expand the name against the base directory (File.expand_path(name, base) collapses .. and replaces an absolute name), resolve symbolic links with File.realpath (which also checks that the file exists), and then check that the result is inside the base directory by testing for the base path followed by a separator. Testing start_with?(base) without the separator is a classic bug, since /data/covers-secret starts with /data/covers. A null byte in a path makes Ruby raise ArgumentError ("path name contains null byte"), so the truncation tricks of C-string languages do not work, but input should still be checked before it reaches a lower layer (a C extension, an external program).

Another way is to avoid file names in the first place: store files under generated names (SecureRandom.uuid, a database id) and keep the user’s name as data. Dir.glob and Dir[] interpret *, ?, [] and {} in the string, so they must never receive an unescaped input (list the directory with Dir.children and compare names instead). Pathname#join has the same absolute-path behavior as File.join.

# frozen_string_literal: true

require "tmpdir"
require "fileutils"

# Resolve a user-supplied relative name inside a base directory, or refuse.
def resolve_inside(base, name)
  root = File.realpath(base)
  target = File.expand_path(name, root)
  target = File.realpath(target) if File.exist?(target)
  return target if target.start_with?(File.join(root, ""))

  raise ArgumentError, "path escapes the base directory: #{name.inspect}"
end

Dir.mktmpdir("shelf-") do |dir|
  base = File.join(dir, "covers")
  FileUtils.mkdir_p(base)
  File.write(File.join(base, "dispossessed.txt"), "cover")
  File.write(File.join(dir, "covers-secret.txt"), "secret")
  File.symlink(File.join(dir, "covers-secret.txt"), File.join(base, "link.txt"))

  puts File.basename(resolve_inside(base, "dispossessed.txt")) # => dispossessed.txt

  ["../covers-secret.txt", "/etc/passwd", "link.txt"].each do |name|
    resolve_inside(base, name)
  rescue ArgumentError => e
    puts e.message.sub(dir, "<tmp>")
  end

  # The naive check is fooled by a sibling with the same prefix.
  naive = File.expand_path("../covers-secret.txt", base)
  p naive.start_with?(base) # => true, although the file is outside

  begin
    File.read("#{base}/dispossessed.txt\0.png")
  rescue ArgumentError => e
    puts e.message # => path name contains null byte
  end
end
Output
dispossessed.txt
path escapes the base directory: "../covers-secret.txt"
path escapes the base directory: "/etc/passwd"
path escapes the base directory: "link.txt"
true
path name contains null byte

Permissions and temporary files

A file created with the default permissions is readable by whoever the umask allows (commonly the group and other users). For secrets, create the file private from the first byte: pass the permissions to the creating call (File.open(path, flags, 0o600), File.write(path, data, perm: 0o600)) and add File::EXCL to File::CREAT so that creation fails if the path already exists, which defeats a symbolic link that an attacker planted at a predictable name. File.chmod after the fact leaves a window in which the file is readable. The permission argument of File.write and File.open applies only when the call creates the file, and the umask still removes bits.

For temporary data use Tempfile.create (a block removes the file when it finishes), Tempfile.new or Dir.mktmpdir: they pick an unpredictable name and create the file with mode 0600 and the directory with mode 0700. Never build "/tmp/shelf-#{Process.pid}" yourself, because the name is guessable and the file can be pre-created or linked by another user. Time-of-check to time-of-use races (File.exist? followed by File.open) have the same cure: do the operation and handle the exception.

# frozen_string_literal: true

require "tempfile"
require "tmpdir"

Dir.mktmpdir("shelf-") do |dir|
  path = File.join(dir, "token.txt")

  # Create the file private from the first byte; do not chmod afterwards.
  File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |f| f.write("placeholder-token\n") }
  puts format("%o", File.stat(path).mode & 0o777) # => 600

  begin
    File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600)
  rescue Errno::EEXIST => e
    puts e.message.sub(dir, "<tmp>") # => File exists @ rb_sysopen - <tmp>/token.txt
  end

  # File.write takes perm: for files it creates; it does not change an existing file.
  File.write(File.join(dir, "export.csv"), "isbn,title\n", perm: 0o640)
  puts format("%o", File.stat(File.join(dir, "export.csv")).mode & 0o777) # => 640 (with the common umask 022)
end

# Tempfile and Dir.mktmpdir create private, unpredictably named entries.
Tempfile.create("shelf") do |f|
  puts format("%o", f.stat.mode & 0o777) # => 600
  p File.basename(f.path).start_with?("shelf") # => true
end
Dir.mktmpdir { |d| puts format("%o", File.stat(d).mode & 0o777) } # => 700
Output
600
File exists @ rb_sysopen - <tmp>/token.txt
640
600
true
700

Randomness, comparison and passwords

SecureRandom versus rand

Kernel#rand, Random.rand and Random.new(seed) are Mersenne Twister generators designed for simulations: fast, uniformly distributed, and entirely determined by the seed. Anyone who learns the seed or enough output can predict every later value, so they must never produce tokens, session identifiers, password reset links, nonces or keys. srand makes the sequence repeatable by design. SecureRandom (from securerandom, a default gem) reads the operating system’s cryptographically secure generator and is the right source: hex, base64, urlsafe_base64, alphanumeric, random_bytes, random_number, uuid and, since Ruby 3.3, uuid_v4 and uuid_v7. Random.urandom(n) is the raw OS source. Use at least 16 random bytes (128 bits) for a token, and compare them in constant time, as described next. Array#shuffle and sample accept a random: argument, so shuffle(random: SecureRandom) works when the shuffle itself is security relevant.

# frozen_string_literal: true

require "securerandom"

# Random and Kernel#rand are fast, statistical generators. Their output is
# fully determined by the seed, so anyone who learns the seed predicts it.
a = Random.new(42)
b = Random.new(42)
p Array.new(3) { a.rand(1000) } == Array.new(3) { b.rand(1000) } # => true

# SecureRandom reads the operating system's cryptographic generator.
token = SecureRandom.hex(16)
p token.size                                  # => 32
p token.match?(/\A\h{32}\z/)                  # => true
p SecureRandom.urlsafe_base64(16).size        # => 22
p SecureRandom.base64(16).size                # => 24
p SecureRandom.alphanumeric(12).size          # => 12
p SecureRandom.random_bytes(8).bytesize       # => 8
p SecureRandom.uuid.match?(/\A\h{8}(-\h{4}){3}-\h{12}\z/) # => true
p SecureRandom.random_number(100).between?(0, 99) # => true
p SecureRandom.uuid_v7.size # => 36

# Random.urandom is the same OS source, without the SecureRandom conveniences.
p Random.urandom(4).bytesize # => 4
Output
true
32
true
22
24
12
8
true
true
36
4

Source: SecureRandom and Random

Constant-time comparison, HMAC and password hashing

== on strings returns as soon as it finds the first differing byte, so the time it takes leaks how many leading bytes were right. Over a network that is a measurable timing attack on tokens, API keys and signatures. Compare secrets with OpenSSL.fixed_length_secure_compare(a, b), which takes the same time for any content but raises ArgumentError if the lengths differ (the length of the secret is not hidden), or with OpenSSL.secure_compare(a, b), which hashes both sides first and so accepts any lengths. Both are in the openssl default gem and are available on Ruby 3.2 and later. Rack and ActiveSupport have equivalent helpers in their ecosystems (Rack::Utils.secure_compare, ActiveSupport::SecurityUtils), which are community gems.

To make sure a message was not altered, sign it with an HMAC: OpenSSL::HMAC.hexdigest("SHA256", key, message). The key is a long random secret (32 bytes), kept outside the source code. For passwords, never store a plain hash (Digest::SHA256 and, worse, Digest::MD5 are fast, which is the opposite of what a password hash needs) and never encrypt them reversibly. Use a slow, salted key-derivation function: OpenSSL::KDF.pbkdf2_hmac, OpenSSL::KDF.scrypt (when the linked OpenSSL supports it) or the community bcrypt and argon2 gems (argon2id is the current recommendation of the OWASP Password Storage Cheat Sheet; PBKDF2-HMAC-SHA256 with at least 600,000 iterations is its fallback when FIPS compliance is required). Store the algorithm, the parameters, the salt and the hash together so the work factor can be raised later. The example derives and verifies a key with placeholder passwords. Building a whole authentication system is out of scope here: prefer a maintained library to hand-written code.

# frozen_string_literal: true

require "openssl"
require "securerandom"

# 1. Compare secrets in constant time.
expected = SecureRandom.hex(16)
p OpenSSL.fixed_length_secure_compare(expected, expected.dup) # => true
p OpenSSL.fixed_length_secure_compare(expected, "0" * 32)     # => false
begin
  OpenSSL.fixed_length_secure_compare(expected, "short")
rescue ArgumentError => e
  puts e.message # => inputs must be of equal length
end
p OpenSSL.secure_compare(expected, "short") # => false (hashes both sides first)

# 2. Sign a message so that tampering is detected.
key = SecureRandom.random_bytes(32) # in an application: loaded from a secret store
message = "loan=42&state=returned"
signature = OpenSSL::HMAC.hexdigest("SHA256", key, message)
p signature.size # => 64
check = OpenSSL::HMAC.hexdigest("SHA256", key, "loan=42&state=lent")
p OpenSSL.fixed_length_secure_compare(signature, check) # => false

# 3. Store passwords with a slow, salted key-derivation function.
password = "correct horse battery staple (placeholder)"
salt = SecureRandom.random_bytes(16)
hash = OpenSSL::KDF.pbkdf2_hmac(password, salt:, iterations: 600_000, length: 32, hash: "SHA256")
p hash.bytesize # => 32
again = OpenSSL::KDF.pbkdf2_hmac(password, salt:, iterations: 600_000, length: 32, hash: "SHA256")
p OpenSSL.fixed_length_secure_compare(hash, again) # => true
Output
true
false
inputs must be of equal length
false
64
false
32
true

The page deliberately says little about encryption algorithms: the OpenSSL::Cipher API is a thin binding to OpenSSL’s, with all its low-level choices (mode, IV, tag). If you need authenticated encryption, use an AEAD mode (aes-256-gcm) with a fresh random IV for every message, verify the authentication tag before using the plaintext, and prefer a vetted library over composing primitives. Go’s standard library offers a good comparative tour of the same primitives in Go crypto and security.

Secrets

Keep secrets out of the source, the repository, the logs and the error messages. The Ruby specifics:

  • Read them from the environment or a secret store at start-up, and use ENV.fetch("NAME") so that a missing secret raises KeyError at once instead of becoming nil and failing later (or worse, silently using an empty key). ENV.fetch with a default is for non-secret settings only.

  • Commit nothing real: configuration files in the repository contain placeholders, .env-style files are ignored by version control, and a secret scanner runs in CI (detect-secrets, gitleaks). A secret that was committed once is compromised: rotate it, since removing it from the history does not unpublish it. The same is true of a gem pushed to RubyGems.org with a secret inside; gem yank removes the version, but the content may already have been downloaded.

  • Keep them out of inspect. The default inspect prints every instance variable, Struct and Data print every member, and exceptions and loggers print what you give them. Override inspect and to_s on classes that hold credentials (a Secret wrapper, as below), and filter parameters before logging. Ruby 4.0 can also restrict which instance variables inspect shows (instance_variables_to_inspect, covered in Classes and Objects).

  • Do not rely on clearing memory. Strings are copied, interned and kept by the garbage collector, so a secret cannot be reliably wiped. Keep its lifetime short and keep it out of core dumps and crash reports in production.

  • A program that runs a child process should pass it only the environment it needs (see Command injection).

# frozen_string_literal: true

require "logger"
require "stringio"

# Read secrets from the environment and fail loudly when one is missing.
begin
  ENV.fetch("SHELF_API_TOKEN")
rescue KeyError => e
  puts e.message # => key not found: "SHELF_API_TOKEN"
end

# A value object that does not leak: inspect and to_s are redacted.
class Secret
  def initialize(value) = @value = value

  def reveal = @value

  def inspect = "#<Secret [REDACTED]>"
  alias to_s inspect
end

Credentials = Data.define(:user, :token) do
  def inspect = "#<Credentials user=#{user.inspect} token=[REDACTED]>"
end

token = Secret.new("placeholder-token-0000")
p token                       # => #<Secret [REDACTED]>
puts "token is #{token}"      # => token is #<Secret [REDACTED]>
p token.reveal                # => "placeholder-token-0000"

creds = Credentials.new("librarian", token)
p creds # => #<Credentials user="librarian" token=[REDACTED]>

log = StringIO.new
logger = Logger.new(log)
logger.formatter = proc { |severity, _time, _name, message| "#{severity}: #{message}\n" }
logger.info("connecting with #{creds.inspect}")
puts log.string # => INFO: connecting with #<Credentials user="librarian" token=[REDACTED]>

# Without the override, the default inspect shows the instance variable.
Plain = Struct.new(:user, :token)
p Plain.new("librarian", "placeholder-token-0000")
Output
key not found: "SHELF_API_TOKEN"
#<Secret [REDACTED]>
token is #<Secret [REDACTED]>
"placeholder-token-0000"
#<Credentials user="librarian" token=[REDACTED]>
INFO: connecting with #<Credentials user="librarian" token=[REDACTED]>
#<struct Plain user="librarian", token="placeholder-token-0000">

Network and TLS

Net::HTTP, the openssl library and everything built on them verify TLS certificates by default when use_ssl = true: OpenSSL::SSL::VERIFY_PEER is the mode, the system’s certificate store is the trust anchor, and the host name is checked against the certificate (verify_hostname). The mistakes are all about turning that off:

  • http.verify_mode = OpenSSL::SSL::VERIFY_NONE (a recurring answer on question sites, "to make the error go away") disables authentication completely and makes the encrypted channel worth nothing against an active attacker. RuboCop has no default cop for it; grep for VERIFY_NONE in review.

  • The correct fix for a private certificate authority or a self-signed certificate is to trust that certificate: set ca_file, ca_path or cert_store, or the SSL_CERT_FILE environment variable. The example below does exactly this against a throwaway server on localhost.

  • Do not downgrade the protocol (ssl_version, min_version), and keep the openssl gem and the system library current.

  • Always set timeouts (open_timeout, read_timeout, write_timeout) on clients, since the default read timeout is 60 seconds and a stalled peer otherwise ties up a thread. Limit redirects and the size of bodies you read.

  • Server-side request forgery: a URL taken from input (URI.open(url), Net::HTTP.get(URI(url))) lets the caller make your server fetch internal addresses such as the cloud metadata service. Allow only http and https, resolve the host and refuse private, loopback and link-local addresses after name resolution, and do not follow redirects blindly. URI.parse and URI::HTTP check syntax, not safety, and credential-in-URL tricks (a trusted host name placed in the user-info part, before the real host) fool string checks; the ruby-lang.org page lists a 2025 URI advisory (CVE-2025-61594, "URI Credential Leakage Bypass previous fixes") that shows this surface moves.

# frozen_string_literal: true

require "net/http"
require "openssl"
require "socket"

# Default verification settings.
context = OpenSSL::SSL::SSLContext.new
context.set_params
p context.verify_mode == OpenSSL::SSL::VERIFY_PEER # => true
p context.verify_hostname                          # => true

# A throwaway CA-less server on localhost, with a self-signed certificate.
key = OpenSSL::PKey::RSA.new(2048)
name = OpenSSL::X509::Name.parse("/CN=localhost")
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = cert.issuer = name
cert.public_key = key.public_key
cert.not_before = Time.now - 60
cert.not_after = Time.now + 3600
extensions = OpenSSL::X509::ExtensionFactory.new(cert, cert)
cert.add_extension(extensions.create_extension("subjectAltName", "DNS:localhost,IP:127.0.0.1"))
cert.sign(key, OpenSSL::Digest.new("SHA256"))

server_context = OpenSSL::SSL::SSLContext.new
server_context.cert = cert
server_context.key = key
tcp = TCPServer.new("127.0.0.1", 0)
port = tcp.addr[1]
server = OpenSSL::SSL::SSLServer.new(tcp, server_context)

worker = Thread.new do
  2.times do
    client = server.accept
    client.readpartial(4096)
    client.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
    client.close
  rescue OpenSSL::SSL::SSLError, SystemCallError
    nil # the first client hangs up when it rejects the certificate
  end
end

# 1. The client does not trust the certificate, so the handshake fails.
http = Net::HTTP.new("localhost", port)
http.use_ssl = true
begin
  http.get("/")
rescue OpenSSL::SSL::SSLError => e
  puts e.class                                   # => OpenSSL::SSL::SSLError
  puts e.message[/certificate verify failed.*/]  # => certificate verify failed (self-signed certificate)
end

# 2. Trust exactly this certificate, and verification succeeds.
store = OpenSSL::X509::Store.new
store.add_cert(cert)
trusted = Net::HTTP.new("localhost", port)
trusted.use_ssl = true
trusted.cert_store = store
trusted.open_timeout = trusted.read_timeout = 5
p trusted.get("/").body # => "ok"

worker.join(5)
server.close
Output
true
true
OpenSSL::SSL::SSLError
certificate verify failed (self-signed certificate)
"ok"

Source: Net::HTTP, OpenSSL::SSL::SSLContext and OpenSSL::X509::Store; the HTTP client is covered in Networking and HTTP.

Resource limits

Denial of service in Ruby usually means memory or CPU. The defenses are limits, applied before the expensive operation:

  • Input size: read with a length (IO#read(n), IO#gets(limit), readpartial) rather than slurping an unbounded stream, and check Content-Length or File.size before reading. Kernel#gets and readlines with no receiver read the files named in ARGV (through ARGF), which surprises command-line tools that expected standard input: use $stdin.gets or ARGF deliberately.

  • Decompression: a few kilobytes can expand to gigabytes (Zlib, gzip, zip bombs). Inflate in chunks and count the output, as below; Zlib::Inflate#inflate with a block yields the output in pieces.

  • Structure depth and count: JSON.parse max_nesting, a cap on array and hash sizes, YAML aliases off by default. Psych and JSON also reject or truncate nothing for you on size, so cap the bytes first.

  • Time: Regexp.timeout, Timeout.timeout (with the caveats in Threads and the GVL), and network timeouts.

  • Memory: symbols (above), String#*, Array.new(n) and Integer#** with an attacker-controlled exponent allocate whatever is asked. "x" * n and 2**n need an upper bound on n.

  • Concurrency: bound queues and thread pools (SizedQueue) so a burst of work does not create unbounded threads.

# frozen_string_literal: true

require "zlib"

# A decompression bomb: ten megabytes of zeros compress to a few kilobytes.
bomb = Zlib.deflate("\0" * 10_000_000)
puts "compressed: #{bomb.bytesize} bytes" # => compressed: about 10 KB

class TooLarge < StandardError; end

# Inflate in chunks and stop as soon as the output passes the limit.
def inflate_limited(data, limit:)
  total = 0
  inflater = Zlib::Inflate.new
  inflater.inflate(data) do |chunk|
    total += chunk.bytesize
    raise TooLarge, "more than #{limit} bytes after inflating" if total > limit
  end
  total
ensure
  inflater&.close
end

p inflate_limited(Zlib.deflate("a" * 1000), limit: 1_000_000) # => 1000
begin
  inflate_limited(bomb, limit: 1_000_000)
rescue TooLarge => e
  puts e.message # => more than 1000000 bytes after inflating
end

# The same idea for plain input: never read an unbounded amount.
require "stringio"
io = StringIO.new("x" * 5000)
p io.read(1024).bytesize # => 1024
p io.gets(nil, 100).bytesize # => 100
Output
compressed: 9738 bytes
1000
more than 1000000 bytes after inflating
1024
100

Source: Zlib::Inflate and IO#gets

Advisories in the standard library often have this shape. The ruby-lang.org security page lists recent examples: denial of service in REXML and net-imap, a buffer overflow in Zlib::GzipReader (CVE-2026-27820) and a use-after-free in the thread-based getaddrinfo timeout handler (CVE-2026-46727). They are fixed by upgrading, which is why the last sections are about keeping the toolchain current.

Runtime and environment

Ruby reads several environment variables that change which code runs, and a privileged program must not trust them. RUBYOPT adds command-line options (including -r to require a file), RUBYLIB adds directories to the load path, GEM_HOME and GEM_PATH choose where gems come from, and BUNDLE_GEMFILE and other BUNDLE_* variables choose the dependency set. The safe pattern is not to run Ruby with elevated rights at all and not to count on the interpreter ignoring these variables: use a dedicated unprivileged account, a container, sudo with env_reset, and a read-only file system where possible. The full list of variables is in Ruby Command Line and Environment.

Other habits that reduce the attack surface:

  • Freeze constants and configuration (.freeze, Ractor.make_shareable, # frozen_string_literal: true) so that a bug cannot mutate shared state; see Object Lifecycle, Freezing and Copying.

  • Run with warnings on in tests (ruby -w, -W:deprecated) so that deprecation warnings (such as the Kernel#open pipe or JSON.load ones above) surface before they become removals.

  • Limit what a Rake task, gem install hook or extconf.rb can do: building a gem with native extensions executes the gem’s code at install time. Review new dependencies.

  • Handle exceptions without leaking: do not render e.message or backtraces to end users, since messages can contain paths, SQL and values; log the details and show a generic message (see Exceptions and Error Handling). Never rescue Exception: it swallows SystemExit and NoMemoryError and hides faults.

Static analysis for security

Automated checks catch the mechanical mistakes in review. In the Ruby ecosystem:

  • RuboCop ships a Security department with these cops: Security/Eval, Security/MarshalLoad, Security/JSONLoad, Security/Open, Security/YAMLLoad, Security/IoMethods and Security/CompoundHash. They are enabled by default (the last two are pending and need NewCops: enable, which the shelf configuration sets). Security/YAMLLoad applies only to Ruby 3.0 and earlier targets, since YAML.load became safe in Ruby 3.1, so it is silent for Ruby 4.0. Because every cop reports a pattern, not a vulnerability, treat a report as a prompt to look, and disable it inline, with a comment explaining why, only for the reviewed exception (as two examples of this page do).

  • Brakeman is a static analyzer for Ruby on Rails applications. It reads the application source (no running server) and reports injection, XSS, mass assignment and unsafe redirects. It targets Rails and is not useful for plain Ruby gems. Rails itself is outside this reference.

  • bundler-audit checks the dependencies recorded in Gemfile.lock against the ruby-advisory-db database. Contrary to a common misdescription, it reads the lockfile, not the Gemfile, so an application without a committed Gemfile.lock cannot be audited meaningfully. See the next section.

  • Reek, Flay and RubyCritic measure design smells and duplication rather than security; they help code review but do not replace the tools above.

  • detect-secrets, gitleaks and the secret scanning of a hosting platform look for committed credentials.

# frozen_string_literal: true

# Deliberately unsafe: every line below is a pattern RuboCop's Security cops report.
require "json"
require "yaml"

def unsafe(params, text, path)
  eval(text)
  Marshal.load(text)
  YAML.load(text)
  JSON.load(text)
  open(path)
  params[:model].constantize
end

Running the Security department on that file prints the following. Note that YAML.load and the constantize call are not reported: the first is safe at the target version, the second is not a core Ruby method and RuboCop does not know it. A cop is not a substitute for knowing the rules above.

Output
e06_rubocop_bad.rb:8:3: W: Security/Eval: The use of `eval` is a serious security risk.
e06_rubocop_bad.rb:9:11: W: Security/MarshalLoad: Avoid using `Marshal.load`.
e06_rubocop_bad.rb:11:8: W: [Correctable] Security/JSONLoad: Prefer `JSON.parse` over `JSON.load`.
e06_rubocop_bad.rb:12:3: W: Security/Open: The use of `Kernel#open` is a serious security risk.

Dependencies and the supply chain

A Ruby application mostly runs other people’s code: gems, their dependencies, and native extensions compiled at install time. The risks are a known vulnerability in a version you use, a malicious release (an account takeover, a typo-squatted name, a compromised build), and drift between what you tested and what you deploy. The tools, which RubyGems and Bundler explains in detail, line up with those risks:

Control What it gives you

Commit Gemfile.lock, deploy with BUNDLE_DEPLOYMENT=true (frozen)

Every environment installs exactly the versions you tested; a changed Gemfile that does not match the lockfile stops the install.

Lockfile CHECKSUMS section (new lockfiles in Bundler 4; bundle lock --add-checksums for older ones)

Bundler verifies the SHA-256 of each downloaded gem, so a gem substituted after the lockfile was written fails to install.

bundle-audit check --update (bundler-audit, community)

Checks the locked versions against known advisories and flags insecure plain-HTTP and git protocol sources. Run it in CI, on a schedule, since new advisories appear without any change in your code.

bundle outdated, dependency bots with a cooldown

Shows new versions. Bundler 4.0.13 added an opt-in cooldown that keeps resolution away from versions younger than N days, because malicious releases are usually found and yanked quickly. A cooldown also delays security fixes, so it has a per-run escape hatch.

One trusted source per gem, source blocks, private-registry scoping

Prevents dependency confusion, where a public gem with the name of a private one is installed instead.

rubygems_mfa_required in the gemspec, and trusted publishing from CI

For authors: multi-factor authentication on every owner who pushes, and short-lived credentials instead of long-lived API keys. See Creating and Publishing Gems, and Rake.

gem install --trust-policy HighSecurity and signed gems

Verifies gem signatures where the author signs them. Few gems are signed, so this is rarely usable as a general policy; the lockfile checksum is the practical control.

A typical CI step audits the lockfile and fails the build on findings (the commands are shown, not run here; the gem is installed once or placed in the development group):

$ bundle-audit check --update
$ bundle exec rubocop --only Security

Review dependency changes in pull requests the way you review code: a diff of Gemfile.lock shows exactly which versions and sources changed. Prefer fewer, well-maintained gems; each is code you run with your privileges. Default gems and bundled gems (openssl, psych, json, uri, erb, net-http, rexml and others) ship with Ruby but can be updated independently through RubyGems or the Gemfile, which matters when a security fix is released as a gem version before the next Ruby patch release. The JVM world has the same problems and a similar toolbox; see supply chain security for the JVM build tools for a comparison.

Following Ruby security releases

The Ruby core team publishes vulnerabilities on ruby-lang.org/en/security, a page that lists every CVE with a date and a link to the announcement (recent entries at the time of writing include the ERB, URI, Zlib::GzipReader and getaddrinfo advisories mentioned above). Vulnerabilities in Ruby or its default gems are reported through the Ruby program on HackerOne or by email to security@ruby-lang.org, a private mailing list; issues in a specific community gem go through that gem’s policy and RubyGems.org, and issues in RubyGems, Bundler or rubygems.org go to security@rubygems.org. Never report a vulnerability in a public issue. Reported problems are published after the fix.

To stay current:

  • Subscribe to the news feed of ruby-lang.org (its news section has an RSS feed), the RubyGems blog, and the advisories of the gems you depend on (GitHub security advisories, the ruby-advisory-db repository that bundler-audit uses).

  • Run a supported branch. Ruby 4.0 is the current stable line; 3.4 is in normal maintenance; 3.3 is in security maintenance until 2027-03-31; Ruby 3.2 and older receive no fixes at all, so a known vulnerability in them stays open. Ruby releases a new minor version each 25 December, and patch releases several times a year. The policy is on the ruby-lang.org "Maintenance Branches" page and in Versions, Releases and Compatibility.

  • Pin the patch version of Ruby in your deployment (a .ruby-version file, the Docker tag, the Gemfile ruby line) and rebuild regularly to pick up patch releases, since a pinned old patch has all the fixes made since.

  • Make the version visible in CI. RUBY_VERSION, RUBY_PATCHLEVEL and RUBY_RELEASE_DATE describe the interpreter, and Gem::Specification.find_by_name returns the version of a default gem. A script that fails when the runtime is older than the patch level that contains a fix is cheap insurance; the example uses a placeholder minimum.

# frozen_string_literal: true

# A CI guard: fail the build when the runtime is older than the patch level
# that contains the security fixes you depend on (placeholder number here).
MINIMUM = Gem::Version.new("4.0.0")
current = Gem::Version.new(RUBY_VERSION)

puts "ruby #{RUBY_VERSION} (patch #{RUBY_PATCHLEVEL}), released #{RUBY_RELEASE_DATE}"
abort "Ruby #{current} is older than #{MINIMUM}" if current < MINIMUM

# Versions of default gems that are security-relevant, as the running Ruby has them.
%w[openssl psych json net-http uri erb].each do |name|
  spec = Gem::Specification.find_by_name(name)
  puts "#{name} #{spec.version}"
end
Output
ruby 4.0.7 (patch 0), released 2026-09-15
openssl 4.0.2
psych 5.3.1
json 2.18.0
net-http 0.9.1
uri 1.1.1
erb 6.0.7

(The versions shown are those of the Ruby 4.0.7 installation used for the page and differ with the patch level.)

Pitfalls and a checklist

Mistake Better

eval, instance_eval(string) or ERB.new(user_text) on input

Parse a restricted format; keep templates in files you control.

obj.send(params[:action]) or Object.const_get(params[:type])

Look the name up in a frozen hash, then public_send the symbol from the hash.

Marshal.load or YAML.unsafe_load on data from a user, a cache anyone can write, or a file upload

JSON.parse or YAML.safe_load, then construct your own objects.

system("convert #{name}"), a backtick command with interpolation, IO.popen("cmd #{x}")

system("convert", name), Open3.capture2e("ls", "--", dir); Shellwords.escape if a shell is unavoidable.

/^...$/ for validation

\A...\z.

Catastrophic regexps, regexps built from input

Regexp.timeout, Regexp.linear_time?, Regexp.escape.

rand, Random.new, Time.now.to_i as a token

SecureRandom.hex(16) or longer.

token == expected

OpenSSL.fixed_length_secure_compare(token, expected).

Digest::SHA256.hexdigest(password) or a reversible encryption

OpenSSL::KDF.pbkdf2_hmac, scrypt, bcrypt or argon2 with a salt and a work factor.

File.join(base, params[:name]), File.open(user_path)

File.realpath plus a prefix check with the separator, or generated names.

File.open(path, "w") then chmod, or /tmp/name-#{pid}

File.open(path, flags, 0o600) with File::EXCL, Tempfile, Dir.mktmpdir.

verify_mode = VERIFY_NONE

Trust the certificate with ca_file or cert_store.

Interpolating input into HTML, URLs or SQL

Escape for the context; parameterized queries.

Secrets in the repository, in inspect output or in logs

ENV.fetch, a secret store, a redacting wrapper, a secret scanner in CI.

Unbounded reads, decompression and nesting

Limits on bytes, depth and time.

No lockfile, no audit, an end-of-life Ruby

Commit Gemfile.lock, run bundle-audit in CI, track the security page, upgrade.

rescue Exception and showing messages to users

Rescue StandardError where you can act, log the detail, show a generic message.

Where older material is outdated

Books, blog posts and answers from the Ruby 1.9 to 2.x era (and many written for early Rails) describe a different security landscape. Translate as follows.

Older claim Why it is misleading Ruby 4.0

“YAML.load` is dangerous, always use safe_load” (and the reverse: "`YAML.load is fine")

True before Psych 4.

YAML.load is a safe load since Ruby 3.1; YAML.unsafe_load is the dangerous one. Still pass an allowlist of classes to safe_load.

$SAFE levels and tainted strings

Removed.

$SAFE is an ordinary variable (3.0), taint methods are gone (3.2). There is no in-language sandbox.

A file name starting with a pipe character is a command in open, so "sanitize the name"

Process creation through Kernel#open is gone.

Removed in 4.0 (deprecated in 3.3). Use File.open for files and arrays for commands.

JSON.load is the normal way to parse JSON

It built objects from json_class keys.

JSON.parse for input; JSON.load warns since json 2.8 and JSON.unsafe_load is explicit.

ReDoS is unavoidable and needs a hand-written guard

Before 3.2 true.

Linear-time matching for most patterns (3.2, wider in 3.3) and Regexp.timeout (3.2).

to_sym on input leaks memory forever

True before Ruby 2.2.

Dynamic symbols are collected; only symbols created by defining methods, variables and constants are immortal.

rand or srand are fine for tokens "when seeded from the time"

Predictable.

SecureRandom, which also has uuid_v4 and uuid_v7 since 3.3.

safe_constantize or constantize as the way to find a class from a string

An ActiveSupport method, not core Ruby; it makes an arbitrary-class lookup look harmless.

Use a frozen registry; in plain Ruby, const_get(name, false) inside a namespace.

bundler-audit audits the Gemfile

It reads Gemfile.lock.

Unchanged: commit the lockfile. Lockfiles also carry checksums in Bundler 4.

NotImplementedError for abstract methods; rescue Exception to catch everything

NotImplementedError is a ScriptError that bare rescue does not catch; rescuing Exception swallows exit requests and out-of-memory errors.

Raise NoMethodError or a custom error; rescue StandardError.

Version notes

Version Change

3.0

$SAFE and $KCODE are ordinary global variables. open-uri no longer redefines Kernel#open; use URI.open.

3.1

Psych 4: YAML.load is safe_load by default.

3.2

Object#taint and the trust methods removed. Regexp.timeout=, the timeout: keyword of Regexp.new, linear-time matching and Regexp.linear_time?.

3.3

Linear-time matching extended to lookarounds and atomic groups. Process creation by Kernel#open, URI.open, IO.read and related methods with a leading pipe deprecated. SecureRandom.uuid_v4 and uuid_v7.

3.4

No security-specific language change. The warning text for Kernel#open with a leading pipe says it will be removed in 4.0.

4.0

Process creation by Kernel#open with a leading pipe removed. Object#inspect honors a private instance_variables_to_inspect method (hide secrets from inspect). The CGI library is removed from the default gems; only cgi/escape remains.

An upcoming 4.1 (expected on 2026-12-25) will be covered by the page on what is new; json 3.0 is expected to disable create_additions for JSON.load.