daily-focus-board: harden canvas (DNS-rebind, XSS, destructive write) + skill a11y

Resolves the remaining Copilot review comments.

Canvas extension (board-core.mjs, board.html):
- DNS-rebinding: pin the Host header to the exact 127.0.0.1:<port> authority and
  require a per-server capability token (minted at startup, embedded in the served
  page, sent as x-board-token) on ALL /api/* routes -- so GET /api/state can't leak
  task data and POSTs can't be forged. Mirrors extensions/signals-dashboard.
- Destructive write: loadDoc only synthesizes a fresh board for ENOENT and now
  propagates I/O + JSON parse errors, so a transient/malformed state file is never
  overwritten by a later mutation.
- XSS: escape emoji (from the seed / add_task action) at render, like title/unit.

Skill (board.template.html, sample-board.html):
- a11y: each task card gets role=group + aria-label so screen readers get task context.
- counters: step=1 on the goal/update number inputs to match the positive-integer contract.

Verified headless (35/35): token gates reads+writes, CSRF + foreign-Host refused,
malformed file left intact. Repo plugin + skill validation green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cb356aa8-0af2-48f3-b3c6-8086c69d5308
This commit is contained in:
Jenny Ferries
2026-07-28 20:50:59 -07:00
parent c606f79f31
commit 227ede1ef9
4 changed files with 74 additions and 19 deletions
+57 -9
View File
@@ -14,6 +14,7 @@ import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, dirname, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BOARD_HTML_PATH = join(__dirname, "assets", "board.html");
@@ -112,9 +113,16 @@ export function normalize(doc) {
}
export async function loadDoc(file) {
let doc = {};
try { doc = JSON.parse(await readFile(file, "utf-8")); } catch { /* missing/corrupt -> fresh */ }
return normalize(doc);
let raw;
try { raw = await readFile(file, "utf-8"); }
catch (e) {
if (e && e.code === "ENOENT") return normalize({}); // no file yet -> a fresh board is correct
throw e; // EACCES/EBUSY/etc: propagate so a transient read error never overwrites the file
}
let parsed;
try { parsed = JSON.parse(raw); }
catch { throw new Error(`state file is not valid JSON (refusing to overwrite): ${file}`); }
return normalize(parsed);
}
async function atomicWrite(file, obj) {
@@ -272,21 +280,60 @@ export async function handleApi(stateFile, op, b) {
}
}
// Pin the Host header to the exact loopback authority we bound. A DNS-rebinding
// page reaches us under its own hostname (Host: attacker.example:<port>), so an
// exact match against 127.0.0.1:<port> refuses those requests before any read or
// write — Origin/Host equality alone can't, since the attacker controls both.
function isCanonicalHost(req, canonicalHost) {
return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase();
}
// Per-server capability token, minted at startup and embedded in the page we
// serve. Only the loopback document we rendered knows it, so a blind
// cross-origin / rebinding caller can't read state or forge a mutation even if
// it reaches the socket.
function hasToken(req, token) {
const h = req.headers["x-board-token"];
const v = Array.isArray(h) ? h[0] : h;
return typeof v === "string" && v.length > 0 && v === token;
}
// Start the local board server bound to loopback. Serves the board HTML at / and
// the JSON state + mutation API under /api/. Returns { server, url }.
// the JSON state + mutation API under /api/. Returns { server, url, token }.
export async function startServer(stateFile) {
const token = randomUUID();
let boardHtml;
try { boardHtml = await readFile(BOARD_HTML_PATH, "utf-8"); }
try { boardHtml = (await readFile(BOARD_HTML_PATH, "utf-8")).replace(/__BOARD_TOKEN__/g, token); }
catch { boardHtml = "<!doctype html><meta charset=utf-8><p>board.html asset is missing.</p>"; }
let canonicalHost = null;
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req)) {
// Host pin first: reject anything not addressed to the exact loopback
// authority we bound (defeats DNS rebinding for reads and writes alike).
if (canonicalHost && !isCanonicalHost(req, canonicalHost)) {
res.writeHead(403, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" }));
res.end(JSON.stringify({ ok: false, error: "bad_host" }));
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
// Every /api/* route (read AND write) requires the capability token, so
// GET /api/state can't leak task data and POSTs can't be forged. Writes
// additionally reject cross-site browser requests.
if (url.pathname.startsWith("/api/")) {
if (!hasToken(req, token)) {
res.writeHead(403, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "missing_capability_token" }));
return;
}
if (req.method === "POST" && isCrossSiteRequest(req)) {
res.writeHead(403, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" }));
return;
}
}
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(boardHtml);
@@ -322,7 +369,8 @@ export async function startServer(stateFile) {
});
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
return { server, url: `http://127.0.0.1:${port}/` };
canonicalHost = `127.0.0.1:${port}`;
return { server, url: `http://127.0.0.1:${port}/`, token };
}
// --- state file resolution + demo seed --------------------------------------