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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,13 @@ guards against allocation-bomb denial-of-service by refusing oversized
before they allocate.

```elixir
# os.exit is sandboxed by default — calling it raises (catchable)
iex> {[false, message], _} = Lua.eval!(Lua.new(), "return pcall(os.exit)")
# os.execute is sandboxed by default — calling it raises (catchable)
iex> {[false, message], _} = Lua.eval!(Lua.new(), "return pcall(os.execute, \"echo hi\")")
iex> message =~ "sandboxed"
true
```

Capability sandboxing (`:sandboxed`, `:exclude`, `Lua.sandbox/2`),
Virtual-by-default sandboxing (`sandbox: true | false`, `env:`),
recursion limits (`:max_call_depth`), the built-in allocation guards, and
the host-level pattern for bounding CPU time and total memory are all
covered in the [Security and sandboxing](guides/sandboxing.md) guide.
Expand Down
42 changes: 23 additions & 19 deletions guides/examples/sandboxing.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,48 @@ Mix.install([
])
```

## The default sandbox
## Virtual by default

`Lua.new/0` sandboxes a curated list of functions (`os.execute`, `os.exit`,
`os.getenv`, `require`, `loadfile`, ...). Calling a sandboxed function
raises, which `pcall` turns into a `false, message` pair so the script can
recover in-band.
`Lua.new/0` is virtual and sandboxed: filesystem operations run against an
in-memory virtual filesystem, `os.getenv` reads only injected values, and
process execution is denied. A sandboxed script never touches the real
machine. Calling a denied operation like `os.execute` raises, which `pcall`
turns into a `false, message` pair so the script can recover in-band.

```elixir
{[ok?, message], _lua} =
Lua.eval!(Lua.new(), ~S[return pcall(function() return os.getenv("HOME") end)])
Lua.eval!(Lua.new(), ~S[return pcall(function() return os.execute("echo hi") end)])

{ok?, message}
```

<!-- livebook:{"output":true} -->

```
{false, "Lua runtime error: os.getenv(_) is sandboxed"}
{false, "Lua runtime error: os.execute(_) is sandboxed"}
```

## Allowing specific operations
## Injecting environment values

`Lua.new(exclude: [...])` lifts the sandbox for specific paths while keeping
everything else locked down. Here we allow `os.getenv` only.
Sandboxed scripts cannot read the host environment. Pass the values they may
see with `Lua.new(env: [...])`; `os.getenv` reads from there (an unset name
is `nil`).

```elixir
allowed = Lua.new(exclude: [[:os, :getenv]])
allowed = Lua.new(env: [{"HOME", "/app"}])

{[kind], _lua} = Lua.eval!(allowed, ~S[return type(os.getenv("HOME"))])
kind
{[home], _lua} = Lua.eval!(allowed, ~S[return os.getenv("HOME")])
home
```

<!-- livebook:{"output":true} -->

```
"string"
"/app"
```

`os.execute` is still blocked on the `allowed` VM — we only lifted `getenv`.
Process execution is still denied on this VM — injecting env does not open
host access.

```elixir
{[exec_ok?, _message], _lua} =
Expand All @@ -61,11 +64,12 @@ exec_ok?
false
```

## Replacing or disabling the sandbox
## Enabling host access

`Lua.new(sandboxed: [...])` replaces the whole sandbox list, and
`Lua.new(sandboxed: [])` disables sandboxing entirely. Reach for these only
when you fully trust the script you are running.
`Lua.new(sandbox: false)` installs the host implementations, so `os`/`io`
and the file loaders behave like normal non-sandboxed Lua against the real
filesystem, environment, and shell. Reach for this only when you fully trust
the script you are running.

## Bounding CPU work

Expand Down
184 changes: 110 additions & 74 deletions lib/lua.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,64 +27,39 @@ defmodule Lua do

defstruct [:state, debug: false]

@default_sandbox [
[:io, :stdin],
[:io, :stdout],
[:io, :stderr],
[:io, :read],
[:io, :write],
[:io, :open],
[:io, :close],
[:io, :lines],
[:io, :popen],
[:io, :tmpfile],
[:io, :output],
[:io, :input],
[:io, :flush],
[:io, :type],
[:file],
[:os, :execute],
[:os, :exit],
[:os, :getenv],
[:os, :remove],
[:os, :rename],
[:os, :tmpname],
[:package],
[:load],
[:loadfile],
[:require],
[:dofile],
[:loadstring]
]

defimpl Inspect do
def inspect(_lua, _opts) do
"#Lua<>"
end
end

@doc """
Initializes a Lua VM sandbox

iex> Lua.new()
Initializes a Lua VM.

By default, the following Lua functions are sandboxed.
By default the VM is **virtual and sandboxed**: filesystem operations
(`require`, `loadfile`/`dofile`, `io`, the file-oriented `os` functions) run
against an in-memory virtual filesystem, `os.getenv` reads only injected
values, and process execution is denied. A sandboxed script never touches the
real machine.

#{Enum.map_join(@default_sandbox, "\n", fn func -> "* `#{inspect(func)}`" end)}

To disable, use the `sandboxed` option, passing an empty list
iex> Lua.new()

iex> Lua.new(sandboxed: [])
Seed the virtual filesystem and inject environment variables up front:

Alternatively, you can pass your own list of functions to sandbox. This is equivalent to calling
`Lua.sandbox/2`.
iex> Lua.new(env: [{"HOME", "/app"}])
...> |> Lua.write_file("/main.lua", "return 1")

iex> Lua.new(sandboxed: [[:os, :exit]])
Opt out into the host implementations (real filesystem, environment, and
process execution — behaves like normal non-sandboxed Lua):

iex> Lua.new(sandbox: false)

## Options
* `:sandboxed` - list of paths to be sandboxed, e.g. `sandboxed: [[:require], [:os, :exit]]`
* `:exclude` - list of paths to exclude from the sandbox, e.g. `exclude: [[:require], [:package]]`
* `:sandbox` - (default `true`) when `true`, install the virtual `os`/`io`/
loader implementations; when `false`, install the host implementations.
* `:env` - environment variables exposed to the sandboxed `os.getenv`, as a
list of `{name, value}` tuples or a map, e.g. `env: [{"HOME", "/app"}]`.
Ignored when `sandbox: false` (the real host environment is read instead).
* `:debug` - (default `false`) when `true`, internal Lua VM frames are preserved in stack traces
instead of being pruned. Useful when debugging library bugs.
* `:max_call_depth` - (default `:infinity`) caps the depth of nested function calls. When a
Expand Down Expand Up @@ -133,34 +108,69 @@ defmodule Lua do
@spec new(keyword()) :: t()
def new(opts \\ []) do
opts =
Keyword.validate!(opts,
sandboxed: @default_sandbox,
exclude: [],
opts
|> normalize_legacy_opts()
|> Keyword.validate!(
sandbox: true,
env: [],
debug: false,
max_call_depth: :infinity,
max_string_bytes: Lua.VM.Limits.max_string_bytes(),
max_instructions: :infinity
)

exclude = Keyword.fetch!(opts, :exclude)
sandbox? = Keyword.fetch!(opts, :sandbox)
env = normalize_env(Keyword.fetch!(opts, :env))
debug = Keyword.fetch!(opts, :debug)
max_call_depth = validate_max_call_depth!(Keyword.fetch!(opts, :max_call_depth))
max_string_bytes = validate_max_string_bytes!(Keyword.fetch!(opts, :max_string_bytes))
max_instructions = validate_max_instructions!(Keyword.fetch!(opts, :max_instructions))

state = %{
Lua.VM.Stdlib.install(State.new())
| max_call_depth: max_call_depth,
max_string_bytes: max_string_bytes,
max_instructions: max_instructions
}
# `sandboxed?`/`env` are set before installing the stdlib so the install
# step picks the virtual or host implementations accordingly.
state =
Lua.VM.Stdlib.install(%{
State.new()
| sandboxed?: sandbox?,
env: env,
max_call_depth: max_call_depth,
max_string_bytes: max_string_bytes,
max_instructions: max_instructions
})

%__MODULE__{state: state, debug: debug}
end

# Bridge the removed `:sandboxed`/`:exclude` per-path options to the single
# `:sandbox` toggle for one minor release. An empty `:sandboxed` list (the
# historic "unrestricted" idiom) or any non-empty `:exclude` maps to
# `sandbox: false`; anything else stays sandboxed.
defp normalize_legacy_opts(opts) do
{legacy, opts} = Keyword.split(opts, [:sandboxed, :exclude])

if legacy == [] do
opts
else
IO.warn(
"Lua.new/1 :sandboxed and :exclude are deprecated and will be removed. " <>
"Use `sandbox: true | false` and `env:` instead."
)

Keyword.put_new(opts, :sandbox, legacy_sandbox?(legacy))
end
end

opts
|> Keyword.fetch!(:sandboxed)
|> Enum.reject(fn path -> path in exclude end)
|> Enum.reduce(%__MODULE__{state: state, debug: debug}, &sandbox(&2, &1))
defp legacy_sandbox?(legacy) do
cond do
Keyword.get(legacy, :exclude, []) != [] -> false
Keyword.get(legacy, :sandboxed, [:_]) == [] -> false
true -> true
end
end

defp normalize_env(env) when is_map(env), do: env
defp normalize_env(env) when is_list(env), do: Map.new(env)

defp validate_max_call_depth!(:infinity), do: :infinity
defp validate_max_call_depth!(depth) when is_integer(depth) and depth > 0, do: depth

Expand Down Expand Up @@ -225,37 +235,63 @@ defmodule Lua do
end

@doc """
Sandboxes the given path, swapping out the implementation with
a function that raises when called
Writes a file into the VM's virtual filesystem.

Sandboxed scripts read this via `require`, `loadfile`/`dofile`, and the `io`
library; the path must be absolute.

iex> lua = Lua.new(sandboxed: [])
iex> Lua.sandbox(lua, [:os, :exit])
iex> Lua.new() |> Lua.write_file("/main.lua", "return 1")

"""
@spec write_file(t(), String.t(), String.t()) :: t()
def write_file(%__MODULE__{state: state} = lua, path, contents) when is_binary(path) and is_binary(contents) do
case State.vfs_write(state, path, contents) do
{:ok, state} -> %{lua | state: state}
{:error, reason, _state} -> raise ArgumentError, "could not write #{path}: #{reason}"
end
end

@doc """
Seeds a Lua dependency into the virtual filesystem under `/lua/deps`.

A convenience over `write_file/3` for the common case of making a module
loadable by `require`. Dotted names nest (`"a.b"` -> `/lua/deps/a/b.lua`).

iex> lua = Lua.new() |> Lua.put_dep("greet", "return 'hi'")
iex> {["hi"], _} = Lua.eval!(lua, "return require('greet')")

"""
@spec put_dep(t(), String.t(), String.t()) :: t()
def put_dep(%__MODULE__{} = lua, modname, source) when is_binary(modname) and is_binary(source) do
path = "/lua/deps/" <> String.replace(modname, ".", "/") <> ".lua"
write_file(lua, path, source)
end

@doc """
Reads a file back out of the VM's virtual filesystem.

Returns `{:ok, contents}` or `{:error, reason}`. Useful for harvesting what
sandboxed code wrote (e.g. via `io.write`).
"""
@spec sandbox(t(), [atom() | String.t()]) :: t()
def sandbox(lua, path) do
set!(lua, path, fn args ->
raise Lua.RuntimeException,
"#{Util.format_function(path, Enum.count(args))} is sandboxed"
end)
@spec read_file(t(), String.t()) :: {:ok, String.t()} | {:error, atom()}
def read_file(%__MODULE__{state: state}, path) when is_binary(path) do
State.vfs_read(state, path)
end

@doc """
Sets the path patterns that the VM will look in when requiring Lua scripts. For example,
if you store Lua files in your application's priv directory:

#iex> lua = Lua.new(exclude: [[:package], [:require]])
#iex> lua = Lua.new(sandbox: false)
#iex> Lua.set_lua_paths(lua, ["myapp/priv/lua/?.lua", "myapp/lua/?/init.lua"])

Now you can use the [Lua require](https://www.lua.org/pil/8.1.html) function to import
these scripts

> #### Warning {: .warning}
> In order to use `Lua.set_lua_paths/2`, the following functions cannot be sandboxed:
> * `[:package]`
> * `[:require]`
>
> By default these are sandboxed, see the `:exclude` option in `Lua.new/1` to allow them.
> #### Note {: .info}
> The host search path is only consulted under `Lua.new(sandbox: false)`. A
> sandboxed VM resolves `require` against the virtual filesystem (see
> `put_dep/3` and `write_file/3`), never the host disk.
"""
@spec set_lua_paths(t(), [String.t()] | String.t()) :: t()
def set_lua_paths(%__MODULE__{} = lua, paths) when is_list(paths) do
Expand Down
16 changes: 14 additions & 2 deletions lib/lua/vm/state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ defmodule Lua.VM.State do
# the file-oriented `os` functions) reads/writes here instead of the
# host disk, so a sandboxed script never reaches the real machine.
# Seeded by `new/0`; threaded via the `vfs_*` helpers below.
vfs: nil
vfs: nil,
# Whether the VM is sandboxed (virtual) — the default. When `true`,
# `os`/`io`/loader stdlib touch only the VFS and injected `env`;
# when `false` the host implementations are installed instead. The
# choice is made once at install time (the installed function *is*
# the right one); this flag records it for introspection and for
# the file-loader source fetch.
sandboxed?: true,
# Environment variables the host injects via `Lua.new(env: ...)`.
# The sandboxed `os.getenv` reads from here (never the host env).
env: %{}

@type t :: %__MODULE__{
call_stack: list(),
Expand All @@ -82,7 +92,9 @@ defmodule Lua.VM.State do
multi_return_count: non_neg_integer(),
g_ref: nil | {:tref, non_neg_integer()},
format_cache: %{optional(binary()) => list()},
vfs: nil | VFS.t()
vfs: nil | VFS.t(),
sandboxed?: boolean(),
env: %{optional(binary()) => binary()}
}

@doc """
Expand Down
Loading
Loading