Spaces:
Running on Zero
Running on Zero
File size: 9,415 Bytes
a87780a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | #!/usr/bin/env python3
"""Tests for the server-side block compiler.
Two jobs:
1. every fixture the editor produces must compile to JavaScript that actually parses
2. a hostile project must not be able to inject code
The second is the point of compiling on the host at all, so it gets the most attention.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from compiler import CompileError, Compiler, compile_project # noqa: E402
HERE = Path(__file__).parent
FIXTURES = HERE / "fixtures"
failures = 0
checks = 0
def check(condition: bool, what: str) -> None:
global failures, checks
checks += 1
if condition:
print(f" ok: {what}")
else:
failures += 1
print(f" FAIL: {what}")
def node_check(source: str, label: str) -> bool:
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f:
f.write(source)
path = f.name
try:
r = subprocess.run(["node", "--check", path], capture_output=True, text=True)
if r.returncode != 0:
print(f" node: {r.stderr.strip().splitlines()[:3]}")
return r.returncode == 0
finally:
Path(path).unlink(missing_ok=True)
# --- fixtures ---------------------------------------------------------------
print("== fixtures")
if not FIXTURES.exists():
print(" !! no fixtures; run app/tools/export_fixtures.gd first")
raise SystemExit(1)
for path in sorted(FIXTURES.glob("*.bbproj")):
project = json.loads(path.read_text())
source, warnings = compile_project(project, path.stem)
check(bool(source), f"{path.stem}: compiled")
check(node_check(source, path.stem), f"{path.stem}: output parses as JavaScript")
check("{" not in source or "}" in source, f"{path.stem}: braces balanced enough to parse")
for w in warnings:
print(f" warning: {w}")
# The orphan fixture must warn rather than silently dropping a stack.
orphan = json.loads((FIXTURES / "orphan.bbproj").read_text())
_, orphan_warnings = compile_project(orphan, "orphan")
check(any("event block" in w for w in orphan_warnings), "orphan stack produces a warning")
# ping_pong registers its command.
pp, _ = compile_project(json.loads((FIXTURES / "ping_pong.bbproj").read_text()), "pp")
check("registerCommands" in pp, "command hat emits registration")
check('"replies with pong"' in pp, "command help text is used as the description")
# Command options become variables, assigned from the words typed after the command.
co, _ = compile_project(json.loads((FIXTURES / "command_options.bbproj").read_text()), "co")
check("let v_count = 0;" in co and "let v_sides = 0;" in co,
"command options are declared as variables")
check("v_count = __bb.commandOption(__args, 0, 2);" in co
and "v_sides = __bb.commandOption(__args, 1, 2);" in co,
"command options are read positionally from the command's args")
check(co.index("if (!(__bb.eq") < co.index("v_count = __bb.commandOption"),
"options are only read once the command name has matched")
check('args: "count sides"' in co, "option names are registered as the usage hint")
check(len(re.findall(r"^let v_count = 0;$", co, re.M)) == 1,
"an option used as a variable is declared exactly once")
# --- hostile input ----------------------------------------------------------
# These are the cases that justify compiling on the host instead of trusting the client.
print("\n== injection attempts")
def compile_nodes(scripts: list) -> str:
return Compiler().compile({"scripts": scripts}, "evil")
def code_only(js: str) -> str:
"""Blank out every string literal, leaving only executable code.
Checking that a payload is 'absent from the output' is too weak — it appears verbatim
inside the string literal it was escaped into, which is exactly correct behaviour. What
matters is whether it can appear as *code*, so strip the literals and look at the rest.
"""
out: list[str] = []
i, n = 0, len(js)
while i < n:
ch = js[i]
if ch in ('"', "'", "`"):
quote = ch
i += 1
while i < n:
if js[i] == "\\":
i += 2
continue
if js[i] == quote:
i += 1
break
i += 1
out.append('""')
continue
out.append(ch)
i += 1
return "".join(out)
# 1. Code in a text field must stay a string literal.
evil_text = compile_nodes([{
"opcode": "event_ready", "id": "a", "fields": {},
"next": {"opcode": "message_send", "id": "b",
"fields": {"TEXT": '"); process.exit(1); //'}},
}])
check("process.exit" not in code_only(evil_text),
"quote-breaking text field cannot inject a statement")
check(node_check(evil_text, "evil_text"), "escaped output still parses")
# 2. Numeric fields must not smuggle an expression.
evil_num = compile_nodes([{
"opcode": "event_ready", "id": "a", "fields": {},
"next": {"opcode": "control_wait", "id": "b",
"fields": {"SECS": "1); require('fs').rmSync('/', {recursive:true}); ("}},
}])
check("rmSync" not in code_only(evil_num), "non-numeric number field cannot inject a call")
check(node_check(evil_num, "evil_num"), "escaped numeric output still parses")
# 3. Dropdowns must be restricted to catalogue values.
evil_menu = compile_nodes([{
"opcode": "event_ready", "id": "a", "fields": {},
"next": {"opcode": "sensing_current", "id": "b", "fields": {"WHAT": "__proto__"}},
}])
check("__proto__" not in evil_menu, "dropdown falls back to a known option")
# 4. Variable names become identifiers, never raw code.
evil_var = compile_nodes([{
"opcode": "event_ready", "id": "a", "fields": {},
"next": {"opcode": "data_setvariableto", "id": "b",
"fields": {"VARIABLE": "x = require('child_process'); y", "VALUE": "1"}},
}])
# The sanitised name still *contains* the letters "child_process" — that's fine, it's one
# identifier. What matters is that it is only an identifier, and introduces no new require.
declared = re.findall(r"^let ([^ ]+) =", evil_var, re.M)
check(bool(declared) and all(re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", d) for d in declared),
"variable name is sanitised to a bare identifier")
check(code_only(evil_var).count("require(") == 2,
"no require() beyond the two header imports")
check(node_check(evil_var, "evil_var"), "sanitised variable output parses")
# 4b. So do command option names, which reach the same identifier path by another route.
evil_options = compile_nodes([{
"opcode": "event_command", "id": "a",
"fields": {"NAME": "go", "HELP": "go", "OPTIONS": "ok require('child_process') y"},
"next": {"opcode": "message_reply", "id": "b", "fields": {"TEXT": "hi"}},
}])
declared = re.findall(r"^let ([^ ]+) =", evil_options, re.M)
check(bool(declared) and all(re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", d) for d in declared),
"option name is sanitised to a bare identifier")
check(code_only(evil_options).count("require(") == 2,
"an option name introduces no require() beyond the two header imports")
check(node_check(evil_options, "evil_options"), "sanitised option output parses")
# 4c. Options have to be known while compiling, so a reporter in the slot is refused loudly
# rather than quietly producing a command whose variables never fill in.
_, option_warnings = compile_project({"scripts": [{
"opcode": "event_command", "id": "a", "fields": {"NAME": "go", "HELP": "go"},
"inputs": {"OPTIONS": {"opcode": "message_text", "id": "b", "fields": {}}},
}]}, "opts")
check(any("options" in w for w in option_warnings), "a block in the options slot warns")
# 5. Block ids are echoed into __bb.step("…"); they must not close the string.
evil_id = compile_nodes([{
"opcode": "event_ready", "id": 'a"); process.exit(1); //', "fields": {},
"next": {"opcode": "message_send", "id": "b", "fields": {"TEXT": "hi"}},
}])
check("process.exit" not in code_only(evil_id), "block id cannot break out of its string literal")
check(node_check(evil_id, "evil_id"), "sanitised id output parses")
# 6. Unknown opcodes are rejected outright.
try:
compile_nodes([{"opcode": "totally_made_up", "id": "a", "fields": {}}])
check(False, "unknown opcode is rejected")
except CompileError:
check(True, "unknown opcode is rejected")
# 7. Absurdly deep nesting is refused rather than blowing the stack.
deep: dict = {"opcode": "message_send", "id": "z", "fields": {"TEXT": "x"}}
for _ in range(400):
deep = {"opcode": "control_if", "id": "n", "fields": {},
"substacks": {"SUBSTACK": deep}}
try:
compile_nodes([{"opcode": "event_ready", "id": "a", "fields": {}, "next": deep}])
check(False, "excessive nesting is refused")
except (CompileError, RecursionError):
check(True, "excessive nesting is refused")
# 8. Malformed projects give a readable error, not a traceback.
for bad in ({}, {"scripts": "nope"}, {"scripts": [42]}):
try:
Compiler().compile(bad, "bad")
check(False, f"malformed project rejected: {bad}")
except CompileError:
check(True, f"malformed project rejected: {bad}")
print(f"\nchecks: {checks}, failures: {failures}")
raise SystemExit(1 if failures else 0) |