bbuilder-host / bb-runtime.js
joddabod's picture
Update bb-runtime.js
a87780a verified
Raw History Blame Contribute Delete
9.42 kB
#!/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)