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)