Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below.

## [Unreleased]

### Fixed
- `:max_string_bytes` is now enforced by `table.concat`, `string.gsub`,
`string.format`, `string.pack` and `os.date`, which previously built results
past the ceiling in a single allocation. Each raises the same catchable
`"resulting string too large"` error as `string.rep` and `..`, before the
result is built (#425).

## [1.0.2] - 2026-07-28

### Changed
Expand Down
2 changes: 1 addition & 1 deletion guides/sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ These guards are always on and need no configuration. They cover:

| Operation | Guard | Error (catchable with `pcall`) |
| :-------- | :---- | :----------------------------- |
| `string.rep`, the `..` operator | result larger than the string ceiling (default ~256 MiB) | `resulting string too large` |
| `string.rep`, the `..` operator, `string.format`, `string.gsub`, `string.pack`, `table.concat`, `os.date` | result larger than the string ceiling (default ~256 MiB) | `resulting string too large` |
| `string.format` width/precision | field wider than 99 | `invalid conversion` |
| `table.unpack` | more than 10M results | `too many results to unpack` |
| `table.concat`, `table.move` | range wider than 10M | `range too large` |
Expand Down
3 changes: 2 additions & 1 deletion lib/lua.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ defmodule Lua do
true

* `:max_string_bytes` - (default 256 MiB) ceiling for any single string the VM will build,
whether via `..`, `string.rep`, or a `load` reader. An oversized result raises a catchable
whether via `..`, `string.rep`, `string.format`, `string.gsub`, `string.pack`,
`table.concat`, `os.date`, or a `load` reader. An oversized result raises a catchable
`"resulting string too large"` error — the size is computed *before* allocating, so the
bomb is refused rather than detected after the fact. Accepts a positive integer or
`:infinity` for no limit (matching `:max_call_depth` and `:max_instructions`).
Expand Down
18 changes: 11 additions & 7 deletions lib/lua/vm/limits.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Lua.VM.Limits do
@moduledoc """
Practical resource ceilings for stdlib operations whose output size is a
function of a numeric argument.
Practical resource ceilings for stdlib operations whose output can be far
larger than their arguments.

These guard against denial-of-service via a single oversized allocation
(e.g. `string.rep("x", 1e15)`, `table.unpack(t, 1, 1e12)`). Each call
Expand Down Expand Up @@ -45,18 +45,22 @@ defmodule Lua.VM.Limits do
given ceiling (a state's `max_string_bytes`, defaulting to the practical
bound here). Raises a catchable "resulting string too large" runtime
error otherwise.

Pass `state` when Lua code ran between the native call's entry and this
check (a `string.gsub` callback, a `table.concat` `__index`): the raise
then carries it, so a protected call keeps that code's heap effects.
"""
# `max` may be `:infinity` (from `Lua.new(max_string_bytes: :infinity)`).
# Erlang term ordering places every number below every atom, so an integer
# `bytes <= :infinity` is always true and the check passes unconditionally —
# no separate clause is needed.
@spec check_string_size!(integer(), pos_integer() | :infinity) :: :ok
def check_string_size!(bytes, max \\ @max_string_bytes)
@spec check_string_size!(integer(), pos_integer() | :infinity, Lua.VM.State.t() | nil) :: :ok
def check_string_size!(bytes, max \\ @max_string_bytes, state \\ nil)

def check_string_size!(bytes, max) when is_integer(bytes) and bytes <= max, do: :ok
def check_string_size!(bytes, max, _state) when is_integer(bytes) and bytes <= max, do: :ok

def check_string_size!(_bytes, _max) do
raise RuntimeError, value: "resulting string too large"
def check_string_size!(_bytes, _max, state) do
raise RuntimeError, value: "resulting string too large", state: state
end

@doc """
Expand Down
4 changes: 2 additions & 2 deletions lib/lua/vm/state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ defmodule Lua.VM.State do
# means no limit. See `check_call_depth!/1`.
call_depth: 0,
max_call_depth: :infinity,
# Ceiling for any single string the VM will build (`..`,
# `string.rep`, `load` reader chunks). Defaults to the practical
# Ceiling for any single string the VM will build (`..` and the
# string-building stdlib functions). Defaults to the practical
# bound in `Lua.VM.Limits`. Embedders running the VM under a
# process heap cap (`:max_heap_size`) should set this below that
# cap so an allocation bomb is refused deterministically instead
Expand Down
26 changes: 21 additions & 5 deletions lib/lua/vm/stdlib/os.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule Lua.VM.Stdlib.Os do
@behaviour Lua.VM.Stdlib.Library

alias Lua.VM.ArgumentError
alias Lua.VM.Limits
alias Lua.VM.RuntimeError
alias Lua.VM.State
alias Lua.VM.Stdlib.Util
Expand Down Expand Up @@ -148,7 +149,7 @@ defmodule Lua.VM.Stdlib.Os do

case format do
"*t" -> date_table(dt, state)
_ -> {[strftime(format, dt)], state}
_ -> {[strftime(format, dt, state.max_string_bytes)], state}
end
end

Expand Down Expand Up @@ -250,10 +251,25 @@ defmodule Lua.VM.Stdlib.Os do
{[tref], state}
end

defp strftime(format, %DateTime{} = dt) do
Regex.replace(~r/%./, format, fn directive ->
directive_value(directive, dt)
end)
# A directive can expand well past its two format bytes (`%c` becomes 24),
# so a long format multiplies. The format is walked one directive at a
# time and the running size checked as each expands, so an oversized
# result is refused partway rather than built.
defp strftime(format, %DateTime{} = dt, max_bytes), do: expand_directives(format, dt, max_bytes, 0, [])

defp expand_directives(format, dt, max_bytes, size, acc) do
case :binary.split(format, "%") do
[literal, <<c, rest::binary>>] ->
value = directive_value(<<?%, c>>, dt)
size = size + byte_size(literal) + byte_size(value)
Limits.check_string_size!(size, max_bytes)
expand_directives(rest, dt, max_bytes, size, [acc, literal, value])

# No directive left: the rest, a trailing lone `%` included, is literal.
_no_directive ->
Limits.check_string_size!(size + byte_size(format), max_bytes)
IO.iodata_to_binary([acc, format])
end
end

defp directive_value("%Y", dt), do: Integer.to_string(dt.year)
Expand Down
112 changes: 79 additions & 33 deletions lib/lua/vm/stdlib/pattern.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ defmodule Lua.VM.Stdlib.Pattern do
- Escape: % + non-alphanumeric = literal
"""

alias Lua.VM.Limits
alias Lua.VM.RuntimeError
alias Lua.VM.State
alias Lua.VM.Stdlib.Util

@doc """
Expand Down Expand Up @@ -160,7 +162,7 @@ defmodule Lua.VM.Stdlib.Pattern do
# (Lua 5.3 §6.4.1), so gsub performs at most one replacement there and
# keeps the remainder untouched — PUC-Lua str_gsub's `anchor` flag makes
# its scan loop run exactly once. An unanchored pattern scans every
# position via gsub_from/10.
# position via gsub_from/11.

defp do_gsub(subject, true, _pattern, _repl, max_n, state) when max_n != nil and max_n <= 0 do
{subject, 0, state}
Expand All @@ -170,17 +172,17 @@ defmodule Lua.VM.Stdlib.Pattern do
case match_pattern(subject, 0, pattern, subject) do
{:match, end_pos, captures} ->
whole_match = binary_part(subject, 0, end_pos)
{replacement, state} = apply_replacement(repl, whole_match, captures, state)
{replacement, state} = apply_replacement(repl, whole_match, captures, 0, state)
rest = binary_part(subject, end_pos, byte_size(subject) - end_pos)
{IO.iodata_to_binary([replacement, rest]), 1, state}
finish_gsub([replacement], rest, byte_size(replacement), 1, state)

:nomatch ->
{subject, 0, state}
end
end

defp do_gsub(subject, false, pattern, repl, max_n, state) do
gsub_from(subject, 0, byte_size(subject), pattern, repl, max_n, 0, [], state, false)
gsub_from(subject, 0, byte_size(subject), pattern, repl, max_n, 0, [], 0, state, false)
end

# Lua 5.3.3+ semantics: an empty match that starts where the *previous*
Expand All @@ -192,35 +194,40 @@ defmodule Lua.VM.Stdlib.Pattern do
# The flag `skip_empty?` is set after every matched-and-applied replacement;
# the next iteration is allowed to fire an empty match only after we've
# advanced past that boundary.
#
# `size` is the byte count accumulated in `acc`. Replacements can grow the
# result without bound relative to the subject, so it is checked against
# the string ceiling as each one is produced (see apply_replacement/5) and
# once more, with the unmatched remainder, before the result is built.

defp gsub_from(subject, pos, len, _pattern, _repl, max_n, count, acc, state, _skip_empty?)
defp gsub_from(subject, pos, len, _pattern, _repl, max_n, count, acc, size, state, _skip_empty?)
when pos > len or (max_n != nil and count >= max_n) do
# Append remaining subject (clamp pos so we never read past end_of_string)
remaining =
if pos < len, do: binary_part(subject, pos, len - pos), else: ""

{IO.iodata_to_binary([Enum.reverse(acc), remaining]), count, state}
finish_gsub(acc, remaining, size, count, state)
end

defp gsub_from(subject, pos, len, pattern, repl, max_n, count, acc, state, skip_empty?) do
defp gsub_from(subject, pos, len, pattern, repl, max_n, count, acc, size, state, skip_empty?) do
case match_pattern(subject, pos, pattern, subject) do
{:match, end_pos, _captures} when skip_empty? and end_pos == pos ->
# Empty match immediately after a previous match — skip without
# replacing, advance by one byte (or terminate at end of subject).
if pos < len do
char = <<:binary.at(subject, pos)>>
gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], state, false)
gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], size + 1, state, false)
else
{IO.iodata_to_binary(Enum.reverse(acc)), count, state}
finish_gsub(acc, "", size, count, state)
end

{:match, end_pos, captures} ->
if max_n != nil and count >= max_n do
remaining = binary_part(subject, pos, len - pos)
{IO.iodata_to_binary([Enum.reverse(acc), remaining]), count, state}
finish_gsub(acc, remaining, size, count, state)
else
whole_match = binary_part(subject, pos, max(end_pos - pos, 0))
{replacement, state} = apply_replacement(repl, whole_match, captures, state)
{replacement, state} = apply_replacement(repl, whole_match, captures, size, state)
empty_match? = end_pos == pos
next_pos = if empty_match?, do: pos + 1, else: end_pos
prefix = if empty_match? and pos < len, do: <<:binary.at(subject, pos)>>, else: ""
Expand All @@ -239,6 +246,7 @@ defmodule Lua.VM.Stdlib.Pattern do
max_n,
count + 1,
[prefix, replacement | acc],
size + byte_size(replacement) + byte_size(prefix),
state,
not empty_match?
)
Expand All @@ -247,19 +255,46 @@ defmodule Lua.VM.Stdlib.Pattern do
:nomatch ->
if pos < len do
char = <<:binary.at(subject, pos)>>
gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], state, false)
gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], size + 1, state, false)
else
{IO.iodata_to_binary(Enum.reverse(acc)), count, state}
finish_gsub(acc, "", size, count, state)
end
end
end

defp apply_replacement(repl, whole_match, captures, state) when is_binary(repl) do
# Replace %0 with whole match, %1-%9 with captures
{replace_captures(repl, whole_match, captures), state}
defp finish_gsub(acc, remaining, size, count, state) do
check_result_size!(size + byte_size(remaining), state)
{IO.iodata_to_binary([Enum.reverse(acc), remaining]), count, state}
end

# The non-stateful `gsub/4` entry calls with `state == nil` and falls back
# to the default ceiling.
defp check_result_size!(size, %State{max_string_bytes: max}) when size <= max, do: :ok

defp check_result_size!(size, %State{max_string_bytes: max} = state) do
Limits.check_string_size!(size, max, state)
end

defp check_result_size!(size, nil), do: Limits.check_string_size!(size)

# Produces the replacement for one match, refusing it when it would take
# the result — `size` bytes so far — past the string ceiling.
defp apply_replacement(repl, whole_match, captures, size, state) when is_binary(repl) do
# Replace %0 with whole match, %1-%9 with captures. A replacement with
# no `%` comes back as the same binary; otherwise the expansion is
# iodata, sized and checked before it is flattened.
case replace_captures(repl, whole_match, captures) do
literal when is_binary(literal) ->
check_result_size!(size + byte_size(literal), state)
{literal, state}

expansion ->
check_result_size!(size + IO.iodata_length(expansion), state)
{IO.iodata_to_binary(expansion), state}
end
end

defp apply_replacement(repl, whole_match, captures, state) when is_function(repl, 2) do
defp apply_replacement(repl, whole_match, captures, size, state) when is_function(repl, 2) do
args = if captures == [], do: [whole_match], else: captures
{result, state} = repl.(args, state)

Expand All @@ -272,27 +307,46 @@ defmodule Lua.VM.Stdlib.Pattern do
other -> raise_invalid_replacement(other, state)
end

check_result_size!(size + byte_size(replacement), state)
{replacement, state}
end

defp apply_replacement(_repl, whole_match, _captures, state), do: {whole_match, state}
defp apply_replacement(_repl, whole_match, _captures, size, state) do
check_result_size!(size + byte_size(whole_match), state)
{whole_match, state}
end

# The callback may have made heap mutations (global/table/upvalue/metatable
# writes) that thread back through `state` before returning an invalid value.
# Ferry the freshest `state` out on the raise so a protected unwind keeps
# those effects (Lua 5.3 §2.3). The non-stateful `gsub/4` entry calls with
# `state == nil`, so only attach when a real `%State{}` is in scope.
defp raise_invalid_replacement(other, %Lua.VM.State{} = state) do
defp raise_invalid_replacement(other, %State{} = state) do
raise RuntimeError, value: "invalid replacement value (a #{Util.typeof(other)})", state: state
end

defp raise_invalid_replacement(other, _state) do
raise RuntimeError, value: "invalid replacement value (a #{Util.typeof(other)})"
end

defp replace_captures("", _whole, _captures), do: ""
# Builds iodata rather than a binary: literal runs and captures are
# referenced, not copied, so a replacement that repeats `%0` many times
# stays the size of the replacement string until its expanded size has
# been checked. A replacement with no `%` comes back as-is.
defp replace_captures(repl, whole, captures), do: literal_run(repl, repl, 0, whole, captures)

# Scans to the next `%`; the `len` bytes of `run` before it are one piece.
defp literal_run(<<?%, rest::binary>>, run, len, whole, captures) do
[binary_part(run, 0, len), replace_directive(rest, whole, captures)]
end

defp literal_run(<<_byte, rest::binary>>, run, len, whole, captures) do
literal_run(rest, run, len + 1, whole, captures)
end

defp literal_run(<<>>, run, _len, _whole, _captures), do: run

defp replace_captures("%" <> <<c, rest::binary>>, whole, captures) when c in ?0..?9 do
defp replace_directive(<<c, rest::binary>>, whole, captures) when c in ?0..?9 do
idx = c - ?0

value =
Expand All @@ -313,25 +367,17 @@ defmodule Lua.VM.Stdlib.Pattern do
raise RuntimeError, value: "invalid capture index %#{idx} in replacement string"
end

capture_to_binary(value) <> replace_captures(rest, whole, captures)
[capture_to_binary(value), replace_captures(rest, whole, captures)]
end

defp replace_captures("%%" <> rest, whole, captures) do
"%" <> replace_captures(rest, whole, captures)
defp replace_directive("%" <> rest, whole, captures) do
["%", replace_captures(rest, whole, captures)]
end

defp replace_captures("%" <> <<_c, _rest::binary>>, _whole, _captures) do
defp replace_directive(_other, _whole, _captures) do
raise RuntimeError, value: "invalid use of '%' in replacement string"
end

defp replace_captures("%", _whole, _captures) do
raise RuntimeError, value: "invalid use of '%' in replacement string"
end

defp replace_captures(<<c, rest::binary>>, whole, captures) do
<<c>> <> replace_captures(rest, whole, captures)
end

# Captures from `()` (position captures) are integers; everything else is
# already a binary. Coerce to a binary so iodata-flattening downstream
# doesn't reinterpret integers as raw bytes.
Expand Down
Loading