Spaces:
Paused
Paused
File size: 7,340 Bytes
3151305 | 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 | #!/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")
# --- 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")
# 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)
|