diff --git a/index.html b/index.html
index a3d6868..b7cfc6b 100644
--- a/index.html
+++ b/index.html
@@ -1,10 +1,11 @@
-
-
+
+
+
Mode 7. A horror story.
diff --git a/js/core/teletext.js b/js/core/teletext.js
index 76c436b..6afda1a 100644
--- a/js/core/teletext.js
+++ b/js/core/teletext.js
@@ -80,6 +80,7 @@ export class TeletextTerminal { // Export the terminal class
this._keyQueue = []; // Key event queue
this._keyWaiters = []; // Promises waiting for a key
+ this._maxKeyQueue = 64; // Bound buffered input so key repeat cannot grow memory indefinitely
this._audioCtx = null; // AudioContext (lazy)
this._muted = false; // Mute flag
@@ -806,6 +807,9 @@ this.setInputMode(false); // Exit input mode after input is captured
_pushKey(evt) { // Push a key event into the queue or resolve a waiter
+if (!evt || !["enter", "backspace", "char"].includes(evt.type)) return; // Reject malformed events
+if (evt.type === "char" && (typeof evt.ch !== "string" || evt.ch.length !== 1)) return; // Require one character
+
if (evt && evt.type === "enter") { // Enter triggers fast-forward
this._fastForward = true; // Enable fast-forward
this._fastForwardUntil = Date.now() + this._fastForwardMs; // Set expiry
@@ -821,6 +825,7 @@ if (evt && evt.type === "enter") { // Enter triggers fast-forward
return; // Done
} // End waiter path
+ if (this._keyQueue.length >= this._maxKeyQueue) this._keyQueue.shift(); // Discard oldest buffered input
this._keyQueue.push(evt); // Otherwise queue it for later
} // End _pushKey
diff --git a/tests/security.test.mjs b/tests/security.test.mjs
new file mode 100644
index 0000000..1b085ff
--- /dev/null
+++ b/tests/security.test.mjs
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+import { TeletextTerminal } from "../js/core/teletext.js";
+
+function makeInputTarget() {
+ const term = Object.create(TeletextTerminal.prototype);
+ term._keyQueue = [];
+ term._keyWaiters = [];
+ term._maxKeyQueue = 64;
+ term._fastForward = false;
+ term._fastForwardAnyKey = false;
+ term._fastForwardMs = 1200;
+ term._fastForwardUntil = 0;
+ return term;
+}
+
+test("keyboard input queue remains bounded", () => {
+ const term = makeInputTarget();
+
+ for (let i = 0; i < 1000; i += 1) {
+ term._pushKey({ type: "char", ch: String(i % 10) });
+ }
+
+ assert.equal(term._keyQueue.length, 64);
+});
+
+test("malformed synthetic key events are rejected", () => {
+ const term = makeInputTarget();
+
+ term._pushKey(null);
+ term._pushKey({ type: "unknown" });
+ term._pushKey({ type: "char", ch: "too long" });
+
+ assert.deepEqual(term._keyQueue, []);
+});
+
+test("entry point defines restrictive browser policies", async () => {
+ const html = await readFile(new URL("../index.html", import.meta.url), "utf8");
+
+ assert.match(html, /name="referrer" content="no-referrer"/);
+ assert.match(html, /http-equiv="Content-Security-Policy"/);
+ assert.match(html, /script-src 'self'/);
+ assert.match(html, /object-src 'none'/);
+ assert.match(html, /base-uri 'none'/);
+});