8.1 KiB
GDScript Sandboxing for User-Written Scripts
Status: Proposed Owner: Platform team Related: Content moderation & age verification (scripts are one of the three moderated content types), avatar component system
1. Overview
This document evaluates how to safely run user-written scripts as part of UGC (user-generated content) in agp-game, and recommends an approach.
Bottom line: Godot 4 has no built-in security sandbox for GDScript, and none of the three options named in the original task hold up under testing. A script running anywhere inside the Godot process — however it's loaded, parented, or wrapped — runs with full engine privileges: arbitrary process execution, full filesystem read/write, and raw network access. Sandboxing has to happen outside the scripting language, not through an in-engine restriction mechanism, because no such mechanism exists.
This was verified directly against Godot 4.6.2 (godot --headless, evidence
in sandbox_probe/ on this branch) rather than assumed from documentation,
some of which (see §2.1) describes APIs that don't actually exist in this
version.
2. Options Evaluated
2.1 Godot's built-in "ScriptServer" restrictions — does not exist
There is no ScriptServer class in Godot 4.6.2:
ClassDB.class_exists("ScriptServer") → false
There's also no project setting to disable engine subsystems for scripts —
none of application/run/disable_os, disable_file_access, or
disable_network exist as settings, and ProjectSettings.get_property_list()
returns nothing matching "sandbox", "restrict", or "security". Any plan that
assumes a flag like this exists needs to be dropped; it was likely conflated
with sandboxing features from a different engine/scripting ecosystem, or
with export template stripping (which controls what ships in a build,
not what a script can call at runtime).
Directly probed capabilities of an ordinary GDScript running as a normal node script, no special setup:
| Capability | Available to any script? |
|---|---|
OS.execute(...) — run arbitrary processes |
✅ Yes |
FileAccess.open(...) — read/write any path the OS user can reach |
✅ Yes (read /etc/hostname, write /tmp/... both succeeded) |
TCPServer / HTTPClient / HTTPRequest / PacketPeerUDP / WebSocketPeer |
✅ All instantiate and work |
ClassDB.instantiate(...) — construct any engine class by name |
✅ Yes |
Loading and running a second script from source text at runtime (GDScript.new() + .reload()) |
✅ Yes |
Verdict: rejected. There is nothing to configure here — the restriction surface this option assumed doesn't exist in the engine.
2.2 Isolated SubViewport with a limited API — doesn't isolate scripts
SubViewport is a rendering and input isolation primitive (it gives a
scene subtree its own render target and can gate input propagation) — it is
not a security or scripting boundary. A script attached to any node inside a
SubViewport still runs in the same process, on the same GDScript VM, with
the exact same access to OS, FileAccess, networking classes, and
ClassDB as a script anywhere else in the tree. Nothing about being
"inside" a SubViewport changes what API calls are reachable from a script.
Verdict: rejected. This solves a rendering/UI-composition problem, not the security problem the task is asking about. Worth keeping in mind for other purposes (e.g. isolated preview rendering of user avatars), but it provides zero script sandboxing.
2.3 Custom GDScript interpreter with an allowlist — right idea, wrong layer for "custom interpreter"
Two ways to read this option, tested separately:
(a) Write/embed a separate GDScript parser+interpreter. Not practical — this means re-implementing (and then indefinitely maintaining, in lockstep with every Godot release) a full language interpreter outside the engine. Far more engineering than the problem justifies.
(b) Use Godot's built-in Expression class as a restricted subset.
Expression looks like a natural allowlist mechanism — it only evaluates
a single expression, no control flow, no arbitrary statements. But it isn't
safe by default: it can still reach through to unsafe engine methods if you
aren't careful about what object you hand it as a base instance:
var expr = Expression.new()
expr.parse("get_environment('HOME')", ["OS"])
expr.execute([], OS) # → "/home/daddy" (successfully called OS.get_environment)
So Expression is a viable building block only if the host never passes
a base instance (or arguments) that exposes anything sensitive — i.e. the
allowlisting work still has to happen at the call site, Expression itself
enforces none of it.
Static text pattern-matching (blocklisting dangerous substrings like
"OS.execute" before parsing) was also probed and is not a real
defense — it's trivially defeated by string concatenation, call(),
Callable, or any other indirection, and produces false confidence.
Verdict: partially viable, but only as a foundation, not a solution on
its own. A hand-built, narrow, data-driven DSL (not full GDScript, not
raw Expression) with an explicit allowlist of safe operations and no
ability to reference engine singletons or arbitrary objects is the only
version of "custom interpreter" that's actually safe. This is real
engineering effort, not a config flag.
3. Recommendation
Don't run untrusted GDScript in-process at all. Given none of the three in-engine options provide a real boundary, the two approaches actually worth building are, in order of recommended priority:
-
Primary: a narrow, non-Turing-complete content DSL, not general scripting. Most UGC scripting needs in a game like this (custom interactions, simple triggers, item behaviors) don't need a general programming language — they need a small set of declarative verbs/conditions (e.g.
on_interact: give_item("torch"),on_enter: play_sound("chime")). Expressed as data (JSON/Resource, not code) and interpreted by trusted, hand-written GDScript on the engine side. This has zero attack surface by construction — there's no interpreter to escape because there's no general execution, just a fixed, reviewed set of verbs. This is almost certainly sufficient for the actual UGC use cases in this game and should be scoped against real feature requirements before assuming more is needed. -
If genuinely general scripting becomes a hard requirement, isolate it at the OS level, not the engine level: run untrusted scripts in a separate process (a stripped-down Godot instance or a dedicated interpreter) under OS sandboxing (a restricted user account, a container, or
firejail/bwrap/seccomp), communicating with the main game over a narrow IPC/RPC boundary that only exposes intentionally-safe operations. This is the only approach that provides a real security boundary for arbitrary code, because it doesn't depend on the scripting language cooperating — the OS enforces it. Substantially more infrastructure than option 1; only worth it if option 1's DSL genuinely can't express what's needed.
Do not attempt in-process GDScript sandboxing via SubViewport, ScriptServer, or ad hoc blocklisting — all three were directly tested and none provide a real boundary (§2).
4. Open Questions
- What do actual planned UGC scripts need to do? (Trigger conditions, simple item/state effects, dialogue branching?) This determines whether the DSL in §3.1 is sufficient or whether §3.2's process isolation is needed. Needs a concrete feature list before implementation starts.
- If §3.2 is needed later, does it run per-user-session or shared/pooled? Affects resource cost and isolation guarantees.
- Interaction with the content moderation pipeline (
content_moderation.md): scripts as a moderated content type still need a moderation flag path regardless of which execution model is chosen — a DSL makes static analysis of what a script can possibly do far easier than it would be for general code, which is an additional point in its favor.