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
@@ -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+"%";});