Last active
July 24, 2024 21:02
-
-
Save vhf/8a094905ecc0bfef51b4307a4521b4b2 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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