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
1
undefined method 'taint' for an instance of String
false
Source: Ruby 3.0.0 NEWS and Ruby 3.2.0 NEWS
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 |
|---|---|---|
|
Arbitrary code runs with the privileges of the process. |
Never evaluate input. Parse a restricted format yourself, or use a data format. |
|
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. |
|
Objects of attacker-chosen classes are built and their methods run during loading. |
|
|
Shell metacharacters in the input run additional commands. |
Pass an argument array so that no shell is involved. |
Regular expressions with |
Validation bypass with an embedded newline; catastrophic backtracking. |
|
File names and paths from input |
Directory traversal, symlink escapes, world-readable results. |
Resolve with |
|
Predictable tokens; timing differences leak secrets. |
|
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
42
not an expression: "1; system('id')"
Source: Ruby 4.0 security guide: eval and Kernel#Integer
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
1
["The Dispossessed"]
unknown command: "exit!"
send reaches private Kernel methods
private method 'puts' called for an instance of Integer
Source: Object#public_send and Ruby 4.0 security guide: send
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
File
Shelf::Store::Memory
uninitialized constant Shelf::Store::File
Shelf::Store::Memory
unknown store: "File"
Source: Module#const_get
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"
No such file or directory @ rb_sysopen - |echo not a command
"hello\n"
"from an array\n"
Source: Ruby 4.0 NEWS (removed: process creation by Kernel#open with a leading pipe) and Ruby 3.3.0 NEWS (deprecation)
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
true
true
:title
Source: Ruby 4.0 security guide: Symbols and Symbol.all_symbols; see also Symbols.
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
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.loadandYAML.safe_loadfor any document you did not write; list the classes you accept withpermitted_classes:(for example[Date],[Symbol]), and setaliases: trueonly 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: trueandfreeze: trueare available on both. -
YAML.load_fileandsafe_load_fileread a path; usesafe_load_file. -
The errors are
Psych::DisallowedClass("Tried to load unspecified class: Object") andPsych::AliasesNotEnabled; both arePsych::Exceptiondescendants 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]}') }
{"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
Source: Psych (safe_load, unsafe_load), YAML and JSON
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"
-- 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"
Source: Ruby 4.0 security guide: command injection, Open3 and Shellwords
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
true
false
true
false
true
nil
true
false
false
0.00 seconds
regexp match timeout
1.0
Source: Regexp (linear_time?, timeout) and Ruby 4.0 security guide: regular expressions; syntax details in Regular Expressions.
Input validation and mass assignment
Validate at the boundary, strictly, and convert to a typed value early. A few Ruby specifics:
-
String#to_iandto_fare lenient:"12abc".to_iis12, and"abc".to_iis0. At a trust boundary useInteger(text, 10)andFloat(text), which raiseArgumentErroron trailing garbage, hexadecimal or underscores surprises, andTypeErroronnil. Passingexception: falsereturnsnilinstead of raising. Pass the base explicitly:Integer("0x1f")is 31 andInteger("010")is 8 (octal), which surprises validation code that expects decimal. -
Check the encoding:
valid_encoding?,scrub(replace invalid bytes), andunicode_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. -
NaNandInfinityare validFloatvalues:Float("NaN")raises but a computation can still produce them, so checkfinite?where it matters. -
Use
\A...\zand-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.definewith aninitializecheck, asShelf::Isbndoes) 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
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
Source: Kernel#Integer, String#valid_encoding? and String#scrub
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
#<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(aliash, available afterinclude ERB::Util) andCGI.escapeHTML(fromcgi/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_encodeandURI.encode_www_form_componentencode a value for a URL component or form value. They are different fromhtml_escape: a URL inside anhrefstill 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
scriptorstyleblocks; pass data as JSON in adata-attribute or atype="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">
# => <i>Dune & 'Co'</i></a></li> (one line in reality)
p CGI.escapeHTML("<a>&") # => "<a>&"
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
<li class="book"><a href="/search?q=%3Ci%3EDune%20%26%20%27Co%27%3C%2Fi%3E"><i>Dune & 'Co'</i></a></li>
"<a>&"
"a+b%26c%3Dd"
42
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
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
Source: File.realpath, File.expand_path and Pathname; the file API is in IO, Files and Directories.
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
600
File exists @ rb_sysopen - <tmp>/token.txt
640
600
true
700
Source: File.open, Tempfile and Dir.mktmpdir
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
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
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 raisesKeyErrorat once instead of becomingniland failing later (or worse, silently using an empty key).ENV.fetchwith 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 yankremoves the version, but the content may already have been downloaded. -
Keep them out of
inspect. The defaultinspectprints every instance variable,StructandDataprint every member, and exceptions and loggers print what you give them. Overrideinspectandto_son classes that hold credentials (aSecretwrapper, as below), and filter parameters before logging. Ruby 4.0 can also restrict which instance variablesinspectshows (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")
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">
Source: ENV.fetch and Object#inspect
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 forVERIFY_NONEin review. -
The correct fix for a private certificate authority or a self-signed certificate is to trust that certificate: set
ca_file,ca_pathorcert_store, or theSSL_CERT_FILEenvironment variable. The example below does exactly this against a throwaway server on localhost. -
Do not downgrade the protocol (
ssl_version,min_version), and keep theopensslgem 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 onlyhttpandhttps, resolve the host and refuse private, loopback and link-local addresses after name resolution, and do not follow redirects blindly.URI.parseandURI::HTTPcheck 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
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 checkContent-LengthorFile.sizebefore reading.Kernel#getsandreadlineswith no receiver read the files named inARGV(throughARGF), which surprises command-line tools that expected standard input: use$stdin.getsorARGFdeliberately. -
Decompression: a few kilobytes can expand to gigabytes (
Zlib, gzip, zip bombs). Inflate in chunks and count the output, as below;Zlib::Inflate#inflatewith a block yields the output in pieces. -
Structure depth and count:
JSON.parsemax_nesting, a cap on array and hash sizes, YAML aliases off by default.PsychandJSONalso 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)andInteger#**with an attacker-controlled exponent allocate whatever is asked."x" * nand2**nneed an upper bound onn. -
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
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 theKernel#openpipe orJSON.loadones above) surface before they become removals. -
Limit what a
Raketask, gem install hook orextconf.rbcan 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.messageor 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). Neverrescue Exception: it swallowsSystemExitandNoMemoryErrorand hides faults.
Static analysis for security
Automated checks catch the mechanical mistakes in review. In the Ruby ecosystem:
-
RuboCop ships a
Securitydepartment with these cops:Security/Eval,Security/MarshalLoad,Security/JSONLoad,Security/Open,Security/YAMLLoad,Security/IoMethodsandSecurity/CompoundHash. They are enabled by default (the last two arependingand needNewCops: enable, which the shelf configuration sets).Security/YAMLLoadapplies only to Ruby 3.0 and earlier targets, sinceYAML.loadbecame 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.lockagainst theruby-advisory-dbdatabase. Contrary to a common misdescription, it reads the lockfile, not the Gemfile, so an application without a committedGemfile.lockcannot 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
Source: RuboCop Security cops
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.
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.
Source: RuboCop Security cops and Ruby 4.0 security guide; the linter is introduced in Style, Linting and Static Analysis.
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 |
Every environment installs exactly the versions you tested; a changed Gemfile that does not match the lockfile stops the install. |
Lockfile |
Bundler verifies the SHA-256 of each downloaded gem, so a gem substituted after the lockfile was written fails to install. |
|
Checks the locked versions against known advisories and flags insecure plain-HTTP and |
|
Shows new versions. Bundler 4.0.13 added an opt-in |
One trusted source per gem, |
Prevents dependency confusion, where a public gem with the name of a private one is installed instead. |
|
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. |
|
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
Source: bundler-audit and RuboCop Security cops
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-dbrepository 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-versionfile, the Docker tag, the Gemfilerubyline) 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_PATCHLEVELandRUBY_RELEASE_DATEdescribe the interpreter, andGem::Specification.find_by_namereturns 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
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.)
Source: ruby-lang.org: Security and Gem::Version
Pitfalls and a checklist
| Mistake | Better |
|---|---|
|
Parse a restricted format; keep templates in files you control. |
|
Look the name up in a frozen hash, then |
|
|
|
|
|
|
Catastrophic regexps, regexps built from input |
|
|
|
|
|
|
|
|
|
|
|
|
Trust the certificate with |
Interpolating input into HTML, URLs or SQL |
Escape for the context; parameterized queries. |
Secrets in the repository, in |
|
Unbounded reads, decompression and nesting |
Limits on bytes, depth and time. |
No lockfile, no audit, an end-of-life Ruby |
Commit |
|
Rescue |
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 |
True before Psych 4. |
|
|
Removed. |
|
A file name starting with a pipe character is a command in |
Process creation through |
Removed in 4.0 (deprecated in 3.3). Use |
|
It built objects from |
|
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 |
|
True before Ruby 2.2. |
Dynamic symbols are collected; only symbols created by defining methods, variables and constants are immortal. |
|
Predictable. |
|
|
An ActiveSupport method, not core Ruby; it makes an arbitrary-class lookup look harmless. |
Use a frozen registry; in plain Ruby, |
|
It reads |
Unchanged: commit the lockfile. Lockfiles also carry checksums in Bundler 4. |
|
|
Raise |
Version notes
| Version | Change |
|---|---|
3.0 |
|
3.1 |
Psych 4: |
3.2 |
|
3.3 |
Linear-time matching extended to lookarounds and atomic groups. Process creation by |
3.4 |
No security-specific language change. The warning text for |
4.0 |
Process creation by |
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.
References
-
ruby-lang.org: Security (reporting, advisories, the security mailing list) and Maintenance Branches
-
SecureRandom, OpenSSL, Net::HTTP, ERB and Zlib::Inflate
-
Ruby 3.0.0 NEWS, 3.1.0, 3.2.0, 3.3.0 and 4.0.0
-
RuboCop Security cops, bundler-audit, ruby-advisory-db and Brakeman
-
OWASP Top 10, OWASP Cheat Sheet Series (Password Storage, Deserialization, Injection Prevention)