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
+11 -6
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="board-token" content="__BOARD_TOKEN__"/>
<title>Daily Focus Board · let's go 🔥</title>
<!--
daily-focus-board (Ember) — CANVAS extension UI. Executive-function-friendly
@@ -147,6 +148,9 @@ const $=id=>document.getElementById(id);
const sparks=["One block at a time.","Starting is the win. The rest follows.","You don't have to feel ready — just start the next one.","Small, real, done. Repeat.","Progress over perfect.","Pick one thing. Just one."];
let doc=null,tasks=[],NAME="",state=null,confettiStarted=false;
const cardsEl=document.getElementById("cards"),feedEl=document.getElementById("feed"),brainEl=document.getElementById("brain");
// Per-server capability token the extension embedded in this page; sent on every
// request so the local server accepts reads/writes only from the page it served.
const TOKEN=(document.querySelector('meta[name="board-token"]')||{}).content||"";
function esc(s){return (s||"").replace(/[&<>]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]));}
function fmt(ms){return new Date(ms).toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}).toLowerCase().replace(" ","");}
@@ -156,8 +160,8 @@ function statusOf(t){if(isCounter(t)){const v=state.counters[t.id]||0;return v>=
function carriedOf(t){return !isCounter(t)&&!!(state.t[t.id]||{}).carried;}
// --- server-backed state (the file the AI partner also reads/writes) ---
async function pull(){try{const r=await fetch("api/state");const j=await r.json();if(j&&j.state&&(!doc||j.state.updatedAt!==doc.updatedAt))apply(j.state);}catch(e){}}
async function api(path,body){try{const r=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body||{})});const j=await r.json();if(j&&j.state)apply(j.state);return j;}catch(e){return {ok:false};}}
async function pull(){try{const r=await fetch("api/state",{headers:{"x-board-token":TOKEN}});const j=await r.json();if(j&&j.state&&(!doc||j.state.updatedAt!==doc.updatedAt))apply(j.state);}catch(e){}}
async function api(path,body){try{const r=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","x-board-token":TOKEN},body:JSON.stringify(body||{})});const j=await r.json();if(j&&j.state)apply(j.state);return j;}catch(e){return {ok:false};}}
function apply(d){
doc=d;NAME=d.name||"";tasks=d.tasks||[];state=d.progress;
document.getElementById("h1").textContent=NAME?`Let's go, ${NAME} 🔥`:"Let's go 🔥";
@@ -184,18 +188,19 @@ function render(){
const st=statusOf(t),carried=carriedOf(t);
const c=document.createElement("div");
c.className="card "+st+(carried?" carried":"")+(state.focus===t.id?" focused":"");
c.setAttribute("role","group");c.setAttribute("aria-label",t.title||"task");
const tagHtml=t.tag?`<span class="tag ${t.tagc||""}">${esc(t.tag)}</span>`:"";
const dh=dueStr(t);
if(isCounter(t)){
const v=state.counters[t.id]||0,pct=Math.min(100,Math.round(v/t.goal*100)),inc=t.inc||Math.max(1,Math.round(t.goal/10)),unit=t.unit||"";
c.innerHTML=`<div class="top"><div class="emoji">${t.emoji||"🎯"}</div>
c.innerHTML=`<div class="top"><div class="emoji">${esc(t.emoji||"🎯")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>
<div class="sub"><b style="color:var(--ink)">${v.toLocaleString()}</b> / ${t.goal.toLocaleString()} ${esc(unit)} · ${pct}%${st==="done"?" — done 🎉":""}</div>
${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<span class="pill ${st}">${LABEL[st]}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this">🎯</button>${tagHtml}</div>
<div class="bar"><div class="fill" data-fill="${t.id}"></div></div>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0"/>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0" step="1"/>
<button data-cset="${t.id}">set</button><button data-cinc="${t.id}">+${inc.toLocaleString()}</button></div>`;
cardsEl.appendChild(c);
requestAnimationFrame(()=>{const f=cardsEl.querySelector(`[data-fill="${t.id}"]`);if(f)f.style.width=pct+"%";});
@@ -204,7 +209,7 @@ function render(){
const pillCls=carried?"carried":st, pillTxt=carried?"→ tomorrow":LABEL[st];
c.innerHTML=`<div class="top">
<div class="box ${st}" data-cyc="${t.id}">${st==="done"?"✓":(st==="doing"?"…":"")}</div>
<div class="emoji">${t.emoji||"•"}</div>
<div class="emoji">${esc(t.emoji||"•")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>${t.sub?`<div class="sub">${esc(t.sub)}</div>`:""}${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<span class="pill ${pillCls}" data-cyc="${t.id}">${pillTxt}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this">🎯</button>${tagHtml}</div>
@@ -230,7 +235,7 @@ function renderFeed(){
state.day.forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:"📌",src:"day",idx:i}));
items.sort((a,b)=>b.t-a.t);
if(!items.length){feedEl.innerHTML=`<div class="empty">No steps logged yet — starting counts. Log your first one 👆</div>`;return;}
feedEl.innerHTML=items.map(it=>`<div class="fitem"><span class="ft">${fmt(it.t)}</span><span>${it.emoji}</span><span>${esc(it.txt)}</span><span class="fx" data-fdel="${it.src}:${it.idx}">×</span></div>`).join("");
feedEl.innerHTML=items.map(it=>`<div class="fitem"><span class="ft">${fmt(it.t)}</span><span>${esc(it.emoji)}</span><span>${esc(it.txt)}</span><span class="fx" data-fdel="${it.src}:${it.idx}">×</span></div>`).join("");
feedEl.querySelectorAll("[data-fdel]").forEach(x=>x.onclick=()=>{const[src,i]=x.dataset.fdel.split(":");if(src==="day")api("api/day-del",{idx:+i});else api("api/note-del",{id:src,idx:+i});});
}
function renderBrain(){
+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 --------------------------------------
@@ -223,7 +223,7 @@
<div class="addtask-form" id="addtaskform" style="display:none">
<input id="ntitle" placeholder="what needs doing?"/>
<label class="addtask-count"><input type="checkbox" id="niscount"/> count toward a number</label>
<span id="ncountfields" class="ncount-fields" style="display:none"><input type="number" id="ngoal" min="1" placeholder="goal"/><input id="nunit" placeholder="unit (pages, reps…)"/></span>
<span id="ncountfields" class="ncount-fields" style="display:none"><input type="number" id="ngoal" min="1" step="1" placeholder="goal"/><input id="nunit" placeholder="unit (pages, reps…)"/></span>
<button id="naddbtn">add</button>
<button id="ncancel" class="soft">cancel</button>
</div>
@@ -324,6 +324,7 @@ function render(){
const c=document.createElement("div");
const q=quadOf(t), tag=tagOf(t);
c.className="card "+st+(carried?" carried":"")+(state.focus===t.id?" focused":"")+(q?" q-"+q:"");
c.setAttribute("role","group");c.setAttribute("aria-label",t.title||"task");
c.dataset.cardid=t.id;
const dh=dueStr(t);
const rmHtml=t.added?`<button class="rmbtn" data-rmtask="${t.id}" title="remove this task" aria-label="remove ${escAttr(t.title)}">🗑</button>`:"";
@@ -341,7 +342,7 @@ function render(){
<span class="pill ${st}">${LABEL[st]}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this" aria-label="focus on ${escAttr(t.title)}">🎯</button>${rmHtml}</div>
<div class="bar"><div class="fill" data-fill="${t.id}"></div></div>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0"/>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0" step="1"/>
<button data-cset="${t.id}">set</button><button data-cinc="${t.id}">+${inc.toLocaleString()}</button></div>${metaHtml}`;
cardsEl.appendChild(c);
requestAnimationFrame(()=>{const f=cardsEl.querySelector(`[data-fill="${t.id}"]`);if(f)f.style.width=pct+"%";});
@@ -231,7 +231,7 @@
<div class="addtask-form" id="addtaskform" style="display:none">
<input id="ntitle" placeholder="what needs doing?"/>
<label class="addtask-count"><input type="checkbox" id="niscount"/> count toward a number</label>
<span id="ncountfields" class="ncount-fields" style="display:none"><input type="number" id="ngoal" min="1" placeholder="goal"/><input id="nunit" placeholder="unit (pages, reps…)"/></span>
<span id="ncountfields" class="ncount-fields" style="display:none"><input type="number" id="ngoal" min="1" step="1" placeholder="goal"/><input id="nunit" placeholder="unit (pages, reps…)"/></span>
<button id="naddbtn">add</button>
<button id="ncancel" class="soft">cancel</button>
</div>
@@ -332,6 +332,7 @@ function render(){
const c=document.createElement("div");
const q=quadOf(t), tag=tagOf(t);
c.className="card "+st+(carried?" carried":"")+(state.focus===t.id?" focused":"")+(q?" q-"+q:"");
c.setAttribute("role","group");c.setAttribute("aria-label",t.title||"task");
c.dataset.cardid=t.id;
const dh=dueStr(t);
const rmHtml=t.added?`<button class="rmbtn" data-rmtask="${t.id}" title="remove this task" aria-label="remove ${escAttr(t.title)}">🗑</button>`:"";
@@ -349,7 +350,7 @@ function render(){
<span class="pill ${st}">${LABEL[st]}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this" aria-label="focus on ${escAttr(t.title)}">🎯</button>${rmHtml}</div>
<div class="bar"><div class="fill" data-fill="${t.id}"></div></div>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0"/>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0" step="1"/>
<button data-cset="${t.id}">set</button><button data-cinc="${t.id}">+${inc.toLocaleString()}</button></div>${metaHtml}`;
cardsEl.appendChild(c);
requestAnimationFrame(()=>{const f=cardsEl.querySelector(`[data-fill="${t.id}"]`);if(f)f.style.width=pct+"%";});