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)