← back to Crazy News Channel

cta/CTA-REPORT-escalation-arc.md

96 lines

# CTA — Click-Through Report: Escalation-Arc Feature (commit e4c7519) + Regression Sweep

Target: `file:///Users/macstudio3/Projects/crazy-news-channel/index.html`
Date: 2026-09-23 · Engines: Chrome (Playwright chromium 1.61.0), Safari (Playwright webkit), Browserbase cloud Chromium.
Scope: this pass focuses on the NEW `synthesizeStages()` / `pickEscalationArc()` / `ESCALATION_ARCS` feature from commit `e4c7519` (8 distinct 5-stage comic arcs for admin-created stories) plus a full regression sweep. Grid/article/mood/klaxon systems were already deep-tested in the prior cycle (`cta/CTA-REPORT.md`, WebKit sticky-header ghosting finding) — not re-litigated here except as regression checks.

## VERDICT: BROKEN — one HIGH-severity attribute-injection/self-XSS bug in the admin panel (same in Chrome + Safari + Browserbase, all three engines equally affected — it's a code bug, not a rendering-engine difference). Everything else — the new 8-arc escalation feature, the requested `<img src=x onerror=alert(1)>` payload, and the full regression sweep — is clean across all 3 engines with 0 console/page errors.

**BROKEN — admin-panel attribute-injection (event-handler injection) via a bare double-quote in the headline field.** `escapeHTML()` (`div.textContent = str; return div.innerHTML`) escapes `&`, `<`, and `>` but does **not** escape `"` or `'`. It is used to fill **double-quoted HTML attributes** in the admin story list — `title="${escapeHTML(headline)}"` and `aria-label="Delete story: ${escapeHTML(headline)}"`. Any admin headline containing a `"` breaks out of the attribute and lets the rest of the headline text be parsed as **new HTML attributes on that element**, including event handlers. Reproduced live: a headline of `" onmouseover="alert(1)" data-pwn="` creates a `<span title="" onmouseover="alert(1)" data-pwn="">` in the DOM, and **`alert(1)` fires the instant a mouse hovers that row** — confirmed firing in both Chrome and Safari (Playwright's real `hover()`, not synthetic). This is a genuinely more serious defect than the specific `<img src=x onerror=alert(1)>` payload requested in the test brief (which IS safely escaped, see below) — it's a quote character, not an angle bracket, that breaks the sanitizer. It also produces a **visible functional bug with zero malicious intent**: the natural "punctuation" test headline `Storm System Named After Someone's Ex, Feelings Reportedly "Complicated"` silently truncates its own `title` tooltip at the embedded quote (`title="Storm System Named After Someone's Ex, Feelings Reportedly "` — "Complicated" is gone, and everything after it became unparsed attribute soup). Scope: **admin panel only** (`#adminStoryList` rows) — the public-facing grid card headline (`<h3><a>`) and article view render the headline as **text content only**, never inside an attribute, so those paths are safe; this is why the requested `<img>` payload test passed everywhere. See §3 for full detail, repro, and fix recommendation.

Everything else tested — the 8-arc escalation system, XSS-shaped payload safety on the public-facing paths, template-placeholder leakage, and the full regression sweep (390/768/1024/1440 viewports, mood filter, klaxon, delete/reset, article open, malformed-hash) — rendered and worked identically and correctly in Chrome, Safari, and Browserbase, 0 console/page errors, 0 dialogs (aside from the deliberate injection repro above).

---

## 1. Setup

- **Chrome**: Playwright `chromium` 1.61.0, 1440×900, `recordVideo` on. Full pass, 0 console errors, 0 unexpected dialogs.
- **Safari**: Playwright `webkit`, same click-list + escalation trace, own video. Full pass, 0 console errors, 0 unexpected dialogs.
- **Browserbase**: cloud Chromium session `https://browserbase.com/sessions/ab542360-de77-4be3-a5d6-f1a535d0bf1f`, reached via `chromium.connectOverCDP()`. Since the target is a local `file://` page, content was served to the remote browser via Playwright `page.route()` fulfilling requests from local disk (same technique as the prior CTA cycle — no tunnel needed, no external ingress). Lighter pass (4 of the 6 headlines + core regression checks) given cloud round-trip cost; 0 console errors, 0 dialogs.
- Credentials present at `~/.claude/skills/browserbase/.env` — no degraded mode needed.
- **Escalation fast-forward method:** rather than waiting real wall-clock time (admin stories tick every 6s × 4 advances = ~24–30s to fully conclude), the harness called the app's own global `tick()` function directly via `page.evaluate(() => tick())` in a loop (it's a `function` declaration, so it's a real `window.tick` — same code path `setInterval(tick, 1000)` uses, not a mock) and screenshotted the instant each tracked story's `stageIndex` advanced. This exercises the exact production escalation logic, just without waiting on the wall clock — permitted by the CTA brief ("let time pass, **or trigger ticks**").
- Harness: `/private/tmp/claude-501/.../scratchpad/cta-escalation.js` (not committed to the repo — scratchpad per session convention). Raw per-engine JSON: `cta/cta2-chrome-results.json`, `cta/cta2-safari-results.json`, `cta/cta2-all-results.json` (includes Browserbase).
- Videos: `cta/rec2/chrome.mp4`, `cta/rec2/safari.mp4` (ffmpeg-converted from Playwright's native `.webm`). Browserbase's recording is the cloud session replay linked above (no downloadable local file, per platform).
- Screenshots: `cta/shots2/{chrome,safari,browserbase}/*.png`; dedicated bug-repro shots at `cta/shots2/bug-repro/{chrome,safari}-{quote-truncates-title,attr-injection-hover}.png`.
- One self-caught false positive worth naming: an early version of the "no literal template placeholder" check flagged `{h}` as present in the raw page source — this was the check matching `${h}` **inside the app's own unexecuted `<script>` source code** (the arc frame functions are literally written as `(h) => \`${h}, ...\``), not in rendered output. Fixed by testing only rendered DOM text (`stageTextHasLiteralPlaceholder`, verified `false` on every captured stage in both engines) plus direct DOM dumps of real headlines (e.g. `"Senate Subcommittee Proposes Banning Tuesdays 'On a Trial Basis'"` — fully interpolated, no placeholder residue). Noted so it isn't mistaken for a real defect.

## 2. Component × Engine Table

| Component | Chrome | Safari (WebKit) | Browserbase | Notes |
|---|---|---|---|---|
| Admin panel open/close | ✓ | ✓ | ✓ | Panel un-hides, button label flips |
| Create story — short headline ("Cat Stuck", Sports) | ✓ | ✓ | ✓ | Created, arc assigned deterministically |
| Create story — long headline (203 chars, Politics) | ✓ | ✓ | ✓ | No truncation, no overflow in card/list |
| Create story — punctuation headline (`Wait, WHAT?! (Seriously?? -- No. Way.)`, Entertainment) | ✓ | ✓ | ✓ | Renders verbatim, safe (no bare `"`) |
| Create story — XSS payload (`<img src=x onerror=alert(1)>`, Sci-Tech) | ✓ safe | ✓ safe | ✓ safe | Rendered as escaped **text** everywhere (grid, admin list, article, all 5 stages); 0 live `<img src="x">` elements in DOM at any point; 0 dialogs |
| Create story — short2 (Business) | ✓ | ✓ | ✓ | — |
| Create story — punctuation2 headline w/ embedded `"` (Storm System…, Weather) | ✓ renders, **title attr corrupted** | ✓ renders, **title attr corrupted** | not run (lighter cloud pass) | See §3 — real defect, not a rendering difference |
| 8-arc escalation — arc diversity | ✓ | ✓ | ✓ | XSS headline (Sci-Tech) → "Corporate Rebrand" arc; long headline (Politics) → "Bureaucratic Spiral" arc; confirmed via `pickEscalationArc` category bias — different headlines land on genuinely different arcs, not one shared template |
| Escalation stage 0→4 (2 tracked stories, full 5-stage trace) | ✓ all 5 stages screenshotted, correct headline/detail/conclusion per stage | ✓ same | ✓ full escalation to conclusion (ticked to end) | `.escalation .seg.on` count, `.concluded` class, and "Story Concluded" ribbon all correct at stage 4 |
| No literal `{h}` / `${...}` in rendered stage text | ✓ 0 found | ✓ 0 found | ✓ 0 found | Verified against actual rendered card text across all 10 captured stage transitions (2 stories × 5 stages) |
| XSS payload never executes (any stage, any surface) | ✓ 0 dialogs, 0 live `<img>` | ✓ 0 dialogs, 0 live `<img>` | ✓ 0 dialogs, 0 live `<img>` | Checked after every escalation tick, not just once |
| **Admin-list attribute injection via bare `"` in headline** | **✗ BROKEN — `alert()` fires on hover** | **✗ BROKEN — identical** | not run | **HIGH — see §3** |
| Viewport sweep 390/768/1024/1440 — no horizontal overflow | ✓ all 4 clean | ✓ all 4 clean | ✓ all 4 clean | `scrollWidth <= clientWidth` at every breakpoint, all engines |
| Category/mood filter (8 chips incl. "All") | ✓ all correct mood text | ✓ all correct mood text | not re-run (covered prior cycle + this cycle's viewport pass implicitly exercises "all") | `moodReadout` text matched exactly: Crisis Red / Storm Watch / Synthwave Alert / Hype Orange / Glam Panic / Money Green / Slate Neutral / Calm-ish (Default) |
| Klaxon toggle on/off | ✓ `aria-pressed` + banner correct both ways | ✓ same | ✓ on-state confirmed | |
| Admin delete story | ✓ count 46→45 | ✓ same | not run | |
| Admin "Reset to Defaults" | ✓ count 45→40 (back to seeded default) | ✓ same | not run | |
| Open article ("Read full article →") | ✓ opens, headline/dek/body render | ✓ same | not run | |
| Article back button | ✓ closes, grid restored | ✓ same | not run | |
| Malformed hash `#/article/%` doesn't freeze the clock | ✓ clock advanced (19:01:04→19:01:06), grid still visible | ✓ clock advanced (19:01:17→19:01:19), grid still visible | ✓ clock advanced | `getRouteId()`'s existing `try/decodeURIComponent/catch` correctly treats it as "not found" and falls through; confirms the fix from a prior cycle still holds |
| Console/page errors (entire pass, all steps) | 0 | 0 | 0 | |
| Unexpected dialogs (entire pass, all steps) | 0 | 0 | 0 | The only `alert()` that fired anywhere was the deliberate attribute-injection repro in §3, run as a separate isolated test — not part of the main 6-story pass, and dismissed programmatically |

## 3. Defect detail — HIGH: admin-panel attribute injection via unescaped quotes

**Root cause:** `escapeHTML()` at index.html is:
```js
function escapeHTML(str) {
  const div = document.createElement("div");
  div.textContent = str;
  return div.innerHTML;
}
```
Browser HTML serialization of a text node only escapes `&`, `<`, and `>` — it does **not** escape `"` or `'` (they're only dangerous inside quoted attribute values, not in text content, so the browser correctly leaves them alone for `innerHTML`). That makes `escapeHTML()` safe **only when used inside element text content**, which is most of its call sites — but two admin-panel call sites use it to fill a **double-quoted attribute**:

- `index.html` (`renderAdminStoryList`): `title="${escapeHTML(headline)}"` and `aria-label="Delete story: ${escapeHTML(headline)}"`.

Any headline containing a literal `"` closes the attribute early; everything after it is parsed as new attributes on the same tag (including event handlers), until the next real `"` in the string closes it out.

**Two concrete repros (both engines, both fire identically):**

1. **Benign/functional** — headline `Storm System Named After Someone's Ex, Feelings Reportedly "Complicated"` (a perfectly ordinary punctuation headline, no malicious intent) → rendered `title` attribute silently truncates to `Storm System Named After Someone's Ex, Feelings Reportedly ` — "Complicated" and the closing quote vanish from the tooltip. Screenshot: `cta/shots2/bug-repro/{chrome,safari}-quote-truncates-title.png`.
2. **Exploit** — headline `" onmouseover="alert(1)" data-pwn="` → DOM becomes `<span class="admin-story-headline" title="" onmouseover="alert(1)" data-pwn="">...` — **hovering the row fires `alert(1)`**, confirmed via Playwright's real synthetic mouse `hover()` in both Chrome and WebKit. Screenshot: `cta/shots2/bug-repro/{chrome,safari}-attr-injection-hover.png`.

**Severity: HIGH as a code defect, contained blast radius today.** It's a real, working script-injection primitive (not a browser quirk — identical root cause fires the same way in Chromium and WebKit), and it's triggered by something as mundane as a straight quotation mark in a headline — well within the "punctuation" test case this brief explicitly asked for. Today's blast radius is limited because: (a) it's confined to the admin-only story list, not the public-facing grid/article views (those interpolate the headline as text content only, confirmed safe against the exact same input), and (b) the only person who can plant the payload is whoever already has access to the admin panel — so it's self-XSS today, not attacker-to-victim. It stops being "self" the moment this admin tool is shared by more than one operator, headlines are ever pasted from an untrasted source, or the same `escapeHTML`-in-an-attribute pattern gets reused on a future public-facing feature — so it's worth fixing now while it's cheap, not after.

**Recommended fix:** stop reusing `escapeHTML()` (built for text-node context) for attribute contexts. Either (a) add a second helper, `escapeAttr()`, that also encodes `"` → `&quot;` and `'` → `&#39;`, and use it at both flagged call sites, or (b) simpler and equally correct: build those two elements via `document.createElement` + `.setAttribute()`/`.title` instead of a template-string attribute, which sidesteps the whole class of bug. Either is a small, surgical, low-risk change — not a rewrite.

**Not affected (verified safe with the identical inputs):** grid card headline/detail (`storyCardHTML`), article view headline/dek/body (`renderArticleContent`), live-update block (`buildLiveUpdateInnerHTML`) — all interpolate `escapeHTML(...)` into text content, not into an attribute, and correctly render the `<img src=x onerror=alert(1)>` and the bare-quote payloads as inert text in every stage, in all 3 engines, with 0 dialogs.

## 4. Chrome-vs-Safari mismatches

**None found in this pass.** The one defect found (§3) is identical in root cause and identical in observable behavior (fires `alert()` on hover) in both Chrome and WebKit — it's a JS/template-string bug, not a rendering-engine difference, so it doesn't belong in a "mismatch" bucket. Every other check — the 8-arc escalation content, viewport overflow, mood filter, klaxon, delete/reset, article open/close, and the malformed-hash clock check — produced byte-for-byte identical results across Chrome, Safari, and Browserbase.

This pass did not re-verify the prior cycle's WebKit-only sticky-header ghosting finding (`cta/CTA-REPORT.md` §3) — that was out of scope for this focused pass (grid/mood/klaxon already deep-tested per the task brief) and nothing here suggests it has changed; it should be treated as still open until explicitly re-verified or fixed.

## 5. Ranked defects

1. **HIGH — Admin-panel attribute injection / event-handler injection via unescaped `"`/`'` in `escapeHTML()`** (§3, this pass). Fix: add an attribute-safe escaper (or use `setAttribute`) at the two flagged call sites in `renderAdminStoryList`.
2. **(Carried over, not re-verified this pass) HIGH — WebKit-only sticky-header ghosting** during scroll, `cta/CTA-REPORT.md` from the prior cycle. Flagging for awareness since it's still presumably unresolved; re-verify before calling it fixed.
3. No other defects found. The new escalation-arc feature (commit `e4c7519`) itself is clean: 8 arcs genuinely diverge in copy, category-biased selection is deterministic and correct, stage progression/conclusion/ribbon logic is correct, and the requested XSS payload is safely neutralized on every surface it actually reaches (public grid + article view).

---

**Files:** harness `/private/tmp/claude-501/-Users-macstudio3-Projects/61408f3d-efd9-454d-a988-06b5f8896273/scratchpad/cta-escalation.js` · results `~/Projects/crazy-news-channel/cta/cta2-{chrome,safari,all}-results.json` · screenshots `~/Projects/crazy-news-channel/cta/shots2/` · bug repros `~/Projects/crazy-news-channel/cta/shots2/bug-repro/` · videos `~/Projects/crazy-news-channel/cta/rec2/{chrome,safari}.mp4` · Browserbase replay `https://browserbase.com/sessions/ab542360-de77-4be3-a5d6-f1a535d0bf1f`.