From 3d2372d6710bf53bda7bdc28cf8ab1d763b24cf6 Mon Sep 17 00:00:00 2001 From: hindessc <76522921+hindessc@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:36:53 +0100 Subject: [PATCH 1/5] Add tests for validating blocks with fields for inputting text Part of #701 AI use: Claude was used to create an example test for a generator and to write code to detect blocks with text fields --- tests/security/color-validation.test.js | 185 ++++++++++++++++++++ tests/security/free-text-validation.test.js | 124 +++++++++++++ tests/security/index.test.js | 7 + tests/tests.html | 7 + 4 files changed, 323 insertions(+) create mode 100644 tests/security/color-validation.test.js create mode 100644 tests/security/free-text-validation.test.js create mode 100644 tests/security/index.test.js diff --git a/tests/security/color-validation.test.js b/tests/security/color-validation.test.js new file mode 100644 index 00000000..016956fd --- /dev/null +++ b/tests/security/color-validation.test.js @@ -0,0 +1,185 @@ +import { expect } from "chai"; +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; +import { defineMaterialsBlocks } from "../../blocks/materials.js"; +import { registerMaterialGenerators } from "../../generators/generators-material.js"; + + + +export function runColorValidationTests() { + describe("colour_from_string generator @security", function () { + this.timeout(5000); + + let workspace; + + before(function () { + if (!Blockly.Blocks["colour_from_string"]) { + defineMaterialsBlocks(); + } + registerMaterialGenerators(javascriptGenerator); + }); + + beforeEach(function () { + workspace = new Blockly.Workspace(); + }); + + afterEach(function () { + workspace.dispose(); + }); + + // colour_from_string is a value block, so blockToCode returns + // [code, order] — unwrap to just the code string. + function generate(block) { + javascriptGenerator.init(workspace); + const code = javascriptGenerator.blockToCode(block); + return Array.isArray(code) ? code[0] : code; + } + + it("generates a hash-prefixed colour string from a bare hex value", function () { + let block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "F000F0" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#F000F0"'); + block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "F00" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#F00"'); + }); + + it("doesn't generate a hash-prefixed colour string from a bare hex value that is an invalid length", function () { + let block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000F0F" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F0" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000F" }, + },workspace,); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a hash-prefixed colour string from a bare hex value that is too short", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "80008" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + + + it("defaults to black when the field is empty", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#000000"'); + }); + + + + it("generates a colour name string from a valid colour name", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "red" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"red"'); + }); + + it("doesn't generate a hash-prefixed colour string from an invalid colour name", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "squirrel" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + + + it("doesn't generate a colour string from input with quotes", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "red" + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires evaluating code", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "#0000" + (255).toString(16) + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires invoking a Flock function", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "" + randomColour() + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires invoking a Flock function later", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "000000" + changeColor(capsule1, { color: randomColour() }) + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires calling a disallowed function", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "" + eval("1") + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + }); +} diff --git a/tests/security/free-text-validation.test.js b/tests/security/free-text-validation.test.js new file mode 100644 index 00000000..113f6eaa --- /dev/null +++ b/tests/security/free-text-validation.test.js @@ -0,0 +1,124 @@ +import { expect } from "chai"; +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; +import { initializeBlocks } from "../../main/blocklyinit.js"; + +initializeBlocks(); // registers all Flock custom blocks + generators + +const allBlockTypes = Object.keys(Blockly.Blocks).sort(); +function classifyField(field) { + if (field instanceof Blockly.FieldVariable) return "variable-name"; + if (field instanceof Blockly.FieldDropdown) return "dropdown"; + if (field instanceof Blockly.FieldTextInput) return "free-text"; + return null; // labels, checkboxes, numbers, images — not a text surface +} + +function scanRegisteredBlocks(workspace) { + const found = []; + for (const type of Object.keys(Blockly.Blocks)) { + let block; + try { + block = workspace.newBlock(type); + } catch { + continue; // blocks whose init needs a full UI environment + } + for (const input of block.inputList) { + for (const field of input.fieldRow) { + const kind = classifyField(field); + if (kind === "free-text") { + found.push(`${type}.${field.name}`); + } + } + } + block.dispose(); + } + return found.sort(); +} + +const TESTED_TEXT_FIELDS = [ + "text.TEXT", + "colour_from_string.COLOR", + "procedures_defnoreturn.NAME", + "procedures_defreturn.NAME", + "text_prompt.TEXT", +]; + +const payload = "\"; alert(1); //" + +export function runTextFieldValidationTests() { + describe("Text Field Validation @security", function () { + this.timeout(5000); + + let workspace = new Blockly.Workspace(); + + // colour_from_string is a value block, so blockToCode returns + // [code, order] — unwrap to just the code string. + function generate(block) { + javascriptGenerator.init(workspace); + const code = javascriptGenerator.blockToCode(block); + return Array.isArray(code) ? code[0] : code; + } + + for (const block of scanRegisteredBlocks(workspace)) { + it(block + " has a test", function () { + expect(TESTED_TEXT_FIELDS).to.include(block); + }); + } + + it("stringifies text field", function () { + const block = Blockly.serialization.blocks.append( + { + type: "text", + fields: { TEXT: payload }, + }, + workspace, + ); + expect(generate(block)).to.equal(JSON.stringify(payload)); + }); + + it("fixes name of procedure without return", function () { + const block = Blockly.serialization.blocks.append( + { + type: "procedures_defnoreturn", + fields: { NAME: payload }, + }, + workspace, + ); + block.argData_ = []; + expect(generate(block)).not.to.include(payload); + }); + + it("fixes name of procedure with return", function () { + const block = Blockly.serialization.blocks.append( + { + type: "procedures_defreturn", + fields: { NAME: payload }, + }, + workspace, + ); + block.argData_ = []; + expect(generate(block)).not.to.include(payload); + }); + + it("stringifies text prompt input", function () { + const block = Blockly.serialization.blocks.append( + { + type: "text_prompt", + fields: { TEXT: payload }, + }, + workspace, + ); + expect(generate(block)).to.equal("window.prompt('" + JSON.stringify(payload) + "')"); + }); + + it("play tune block", function () { + const block = Blockly.serialization.blocks.append( + { + type: "play_tune", + }, + workspace, + ); + expect(generate(block)).to.equal("window.prompt('" + JSON.stringify(payload) + "')"); + }); + }); +} diff --git a/tests/security/index.test.js b/tests/security/index.test.js new file mode 100644 index 00000000..b6fb1a45 --- /dev/null +++ b/tests/security/index.test.js @@ -0,0 +1,7 @@ +import { runColorValidationTests } from "./color-validation.test.js"; +import { runTextFieldValidationTests } from "./free-text-validation.test.js"; + +export function runSecurityTests() { + runColorValidationTests(); + runTextFieldValidationTests(); +} diff --git a/tests/tests.html b/tests/tests.html index 500a7e31..55639b1a 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -485,6 +485,13 @@

Flock Test Example

importFn: "runMicrobitTests", pattern: "@microbit", }, + { + id: "security", + name: "Security Tests", + importPath: "./security/index.test.js", + importFn: "runSecurityTests", + pattern: "@security", + }, ]; import * as flockmodule from "../flock.js"; From c8c8b02f26bfdf61d2d163360c597d35929043d1 Mon Sep 17 00:00:00 2001 From: hindessc <76522921+hindessc@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:00:05 +0100 Subject: [PATCH 2/5] Updated colour tests to expect defaulting to black rather than raising an error Part of #701 --- tests/security/color-validation.test.js | 36 ++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/security/color-validation.test.js b/tests/security/color-validation.test.js index 016956fd..ed0ae34e 100644 --- a/tests/security/color-validation.test.js +++ b/tests/security/color-validation.test.js @@ -51,30 +51,30 @@ export function runColorValidationTests() { }, workspace, ); - expect(generate(block)).to.equal('"#F00"'); + expect(generate(block)).to.equal('"#ff0000"'); }); it("doesn't generate a hash-prefixed colour string from a bare hex value that is an invalid length", function () { let block = Blockly.serialization.blocks.append({ type: "colour_from_string", fields: { COLOR: "F000F0F" }, },workspace,); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); block = Blockly.serialization.blocks.append({ type: "colour_from_string", fields: { COLOR: "F" }, },workspace,); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); block = Blockly.serialization.blocks.append({ type: "colour_from_string", fields: { COLOR: "F0" }, },workspace,); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); block = Blockly.serialization.blocks.append({ type: "colour_from_string", fields: { COLOR: "F000" }, },workspace,); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); block = Blockly.serialization.blocks.append({ type: "colour_from_string", fields: { COLOR: "F000F" }, },workspace,); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); it("doesn't generate a hash-prefixed colour string from a bare hex value that is too short", function () { @@ -85,7 +85,7 @@ export function runColorValidationTests() { }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); @@ -122,7 +122,7 @@ export function runColorValidationTests() { }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); @@ -131,55 +131,55 @@ export function runColorValidationTests() { const block = Blockly.serialization.blocks.append( { type: "colour_from_string", - fields: { COLOR: "red" + "" }, + fields: { COLOR: "red\" + \"" }, }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); it("doesn't generate a colour string from input that requires evaluating code", function () { const block = Blockly.serialization.blocks.append( { type: "colour_from_string", - fields: { COLOR: "#0000" + (255).toString(16) + "" }, + fields: { COLOR: "#0000\" + (255).toString(16) + \"" }, }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); it("doesn't generate a colour string from input that requires invoking a Flock function", function () { const block = Blockly.serialization.blocks.append( { type: "colour_from_string", - fields: { COLOR: "" + randomColour() + "" }, + fields: { COLOR: "\" + randomColour() + \"" }, }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); it("doesn't generate a colour string from input that requires invoking a Flock function later", function () { const block = Blockly.serialization.blocks.append( { type: "colour_from_string", - fields: { COLOR: "000000" + changeColor(capsule1, { color: randomColour() }) + "" }, + fields: { COLOR: "000000\" + changeColor(capsule1, { color: randomColour() }) + \"" }, }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); it("doesn't generate a colour string from input that requires calling a disallowed function", function () { const block = Blockly.serialization.blocks.append( { type: "colour_from_string", - fields: { COLOR: "" + eval("1") + "" }, + fields: { COLOR: "\" + eval(\"1\") + \"" }, }, workspace, ); - expect(() => generate(block)).to.throw(); + expect(generate(block)).to.equal('"#000000"'); }); }); } From f5207d9d9142f116128350fa9fab833abd38f37b Mon Sep 17 00:00:00 2001 From: hindessc <76522921+hindessc@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:04:35 +0100 Subject: [PATCH 3/5] Change text shown in play tune block when JSON is modified from an empty string --- tests/security/free-text-validation.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/security/free-text-validation.test.js b/tests/security/free-text-validation.test.js index 113f6eaa..9fea3e5e 100644 --- a/tests/security/free-text-validation.test.js +++ b/tests/security/free-text-validation.test.js @@ -41,6 +41,7 @@ const TESTED_TEXT_FIELDS = [ "procedures_defnoreturn.NAME", "procedures_defreturn.NAME", "text_prompt.TEXT", + "play_tune.ABC_TEXT", ]; const payload = "\"; alert(1); //" @@ -111,14 +112,14 @@ export function runTextFieldValidationTests() { expect(generate(block)).to.equal("window.prompt('" + JSON.stringify(payload) + "')"); }); - it("play tune block", function () { + it("the play tune block generates an empty string", function () { const block = Blockly.serialization.blocks.append( { type: "play_tune", }, workspace, ); - expect(generate(block)).to.equal("window.prompt('" + JSON.stringify(payload) + "')"); + expect(generate(block)).to.equal(""); }); }); } From a484fe938bb2d3ffa7f6fc252f03d0a5fcdda335 Mon Sep 17 00:00:00 2001 From: hindessc <76522921+hindessc@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:38:54 +0100 Subject: [PATCH 4/5] Add test for keyword block to ensure that it cannot be generated Part of #701 --- tests/security/free-text-validation.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/security/free-text-validation.test.js b/tests/security/free-text-validation.test.js index 9fea3e5e..a5f4470a 100644 --- a/tests/security/free-text-validation.test.js +++ b/tests/security/free-text-validation.test.js @@ -42,6 +42,7 @@ const TESTED_TEXT_FIELDS = [ "procedures_defreturn.NAME", "text_prompt.TEXT", "play_tune.ABC_TEXT", + "keyword.KEYWORD", ]; const payload = "\"; alert(1); //" @@ -121,5 +122,15 @@ export function runTextFieldValidationTests() { ); expect(generate(block)).to.equal(""); }); + + it("the keyword block cannot be generated", function () { + const block = Blockly.serialization.blocks.append( + { + type: "keyword", + }, + workspace, + ); + expect(() => generate(block)).to.throw("JavaScript generator does not know how to generate code for block type \"keyword\""); + }); }); } From fd519d78138e1ead7647996c1748eca026b967e4 Mon Sep 17 00:00:00 2001 From: hindessc <76522921+hindessc@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:56:10 +0100 Subject: [PATCH 5/5] Workspace now clears after each test and is disposed of afterwards Part of #701 AI use: CodeRabbit pointed out the issues --- tests/security/free-text-validation.test.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/security/free-text-validation.test.js b/tests/security/free-text-validation.test.js index a5f4470a..40db54e6 100644 --- a/tests/security/free-text-validation.test.js +++ b/tests/security/free-text-validation.test.js @@ -5,7 +5,6 @@ import { initializeBlocks } from "../../main/blocklyinit.js"; initializeBlocks(); // registers all Flock custom blocks + generators -const allBlockTypes = Object.keys(Blockly.Blocks).sort(); function classifyField(field) { if (field instanceof Blockly.FieldVariable) return "variable-name"; if (field instanceof Blockly.FieldDropdown) return "dropdown"; @@ -53,7 +52,15 @@ export function runTextFieldValidationTests() { let workspace = new Blockly.Workspace(); - // colour_from_string is a value block, so blockToCode returns + after(function () { + workspace.dispose(); + }); + + afterEach(function () { + workspace.clear(); + }); + + // all tested blocks are value blocks, so blockToCode returns // [code, order] — unwrap to just the code string. function generate(block) { javascriptGenerator.init(workspace);