Spaces:
Running on Zero
Running on Zero
Download bb-runtime.js from joddabod/bbuilder-host: direct link, hf CLI and curl.
- Browser
- Download file 9.42 kB
-
https://huggingface.co/spaces/joddabod/bbuilder-host/resolve/main/bb-runtime.js
- Command line
-
hf download hf://spaces/joddabod/bbuilder-host/bb-runtime.js
-
curl -L -o bb-runtime.js https://huggingface.co/spaces/joddabod/bbuilder-host/resolve/main/bb-runtime.js
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) |