Skip to content

Instantly share code, notes, and snippets.

@vhf
Last active July 24, 2024 21:02
Show Gist options
  • Save vhf/8a094905ecc0bfef51b4307a4521b4b2 to your computer and use it in GitHub Desktop.
Save vhf/8a094905ecc0bfef51b4307a4521b4b2 to your computer and use it in GitHub Desktop.
defmodule SecureSandbox do
@forbidden_modules [File, System, Code, Process, :erlang]
@forbidden_functions [:eval, :apply, :spawn, :send, :exit]
def new do
Agent.start_link(fn -> %{binding: [], env: __ENV__} end)
end
def eval(pid, code) do
Agent.get_and_update(pid, fn state ->
with {:ok, ast} <- Code.string_to_quoted(code),
:ok <- validate_ast(ast, state.env) do
try do
{result, new_binding, new_env} = safe_eval(ast, state.binding, state.env)
{{:ok, result}, %{state | binding: new_binding, env: new_env}}
rescue
error -> {{:error, "Runtime error: #{Exception.message(error)}"}, state}
end
else
{:error, message} -> {:error, message}
end
end)
end
def reset(pid) do
Agent.update(pid, fn _state -> %{binding: [], env: __ENV__} end)
end
defp safe_eval(ast, binding, env) do
ast = Macro.prewalk(ast, &remove_forbidden(&1, env))
Code.eval_quoted_with_env(ast, binding, env)
end
defp remove_forbidden({:., _, [{:__aliases__, _, [module]}, func]} = node, env)
when is_atom(func) do
module =
if is_elixir_module?(module) do
Module.concat(:"Elixir", module)
else
module
end
if module in @forbidden_modules or Keyword.get(env.aliases, module) in @forbidden_modules or
func in @forbidden_functions do
quote do: raise("Access to forbidden module or function")
else
node
end
end
defp remove_forbidden({func, _, _} = _node, _env) when func in @forbidden_functions do
quote do: raise("Access to forbidden function")
end
defp remove_forbidden(node, _env), do: node
defp validate_ast(ast, env) do
case Macro.prewalk(ast, true, &check_node(&1, &2, env)) do
{_, true} -> :ok
{_, false} -> {:error, "Forbidden operation detected"}
end
end
defp check_node({{:., _, [{:__aliases__, _, [module]}, func]}, _, _}, acc, env)
when is_atom(func) do
module =
if is_elixir_module?(module) do
Module.concat(:"Elixir", module)
else
module
end
if module in @forbidden_modules or Keyword.get(env.aliases, module) in @forbidden_modules or
func in @forbidden_functions do
{nil, false}
else
{nil, acc}
end
end
defp check_node({func, _, _}, _acc, _env) when func in @forbidden_functions do
{nil, false}
end
defp check_node(node, acc, _env) do
{node, acc}
end
def is_elixir_module?(atom) when is_atom(atom) do
atom
|> Atom.to_string()
|> String.first()
|> String.match?(~r/[A-Z]/)
end
end
# Usage
{:ok, sandbox} = SecureSandbox.new()
inputs = [
"x = 1",
"y = 2",
"x + y",
"z = 3",
"x + y + z",
"alias File, as: Foo",
"Foo.read!('/etc/passwd')"
]
IO.puts("First run:")
Enum.each(inputs, fn code ->
IO.puts("Evaluating: #{code}")
case SecureSandbox.eval(sandbox, code) do
{:ok, result} -> IO.puts("Result: #{inspect(result)}")
{:error, message} -> IO.puts("Error: #{message}")
end
IO.puts("")
end)
IO.puts("Resetting sandbox state...")
SecureSandbox.reset(sandbox)
IO.puts("\nSecond run after reset:")
Enum.each(inputs, fn code ->
IO.puts("Evaluating: #{code}")
case SecureSandbox.eval(sandbox, code) do
{:ok, result} -> IO.puts("Result: #{inspect(result)}")
{:error, message} -> IO.puts("Error: #{message}")
end
IO.puts("")
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment