4da8687f2c
Verified all three options from the task against Godot 4.6.2 directly (godot --headless), rather than trusting the probe scripts' own printed output from earlier sessions: - ScriptServer doesn't exist as a class; no disable_os/disable_file_access/ disable_network project settings exist either. Nothing to configure. - SubViewport isolates rendering/input only, not script capabilities — a script inside one has identical OS/FileAccess/network access. - Expression is not safe by default (can still reach unsafe methods through a base instance); blocklisting dangerous substrings is trivially bypassed. Recommendation: don't sandbox GDScript in-process (nothing to sandbox with) — use a narrow non-Turing-complete content DSL for the common case, and OS-level process isolation only if genuinely general scripting turns out to be required.
160 lines
8.1 KiB
Markdown
160 lines
8.1 KiB
Markdown
# 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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
1. **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.
|
|
|
|
2. **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.
|