diff --git a/tests/security/color-validation.test.js b/tests/security/color-validation.test.js new file mode 100644 index 00000000..ed0ae34e --- /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('"#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.equal('"#000000"'); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F" }, + },workspace,); + expect(generate(block)).to.equal('"#000000"'); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F0" }, + },workspace,); + expect(generate(block)).to.equal('"#000000"'); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000" }, + },workspace,); + expect(generate(block)).to.equal('"#000000"'); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000F" }, + },workspace,); + 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 () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "80008" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#000000"'); + }); + + + + 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.equal('"#000000"'); + }); + + + + 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.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) + \"" }, + }, + workspace, + ); + 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() + \"" }, + }, + workspace, + ); + 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() }) + \"" }, + }, + workspace, + ); + 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\") + \"" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#000000"'); + }); + }); +} diff --git a/tests/security/free-text-validation.test.js b/tests/security/free-text-validation.test.js new file mode 100644 index 00000000..40db54e6 --- /dev/null +++ b/tests/security/free-text-validation.test.js @@ -0,0 +1,143 @@ +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 + +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", + "play_tune.ABC_TEXT", + "keyword.KEYWORD", +]; + +const payload = "\"; alert(1); //" + +export function runTextFieldValidationTests() { + describe("Text Field Validation @security", function () { + this.timeout(5000); + + let workspace = new Blockly.Workspace(); + + 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); + 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("the play tune block generates an empty string", function () { + const block = Blockly.serialization.blocks.append( + { + type: "play_tune", + }, + workspace, + ); + 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\""); + }); + }); +} 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 0c8501a3..4bc701a8 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -499,6 +499,13 @@