Skip to content

fix(widget): conversation export review follow-ups - #159

Merged
PAMulligan merged 5 commits into
mainfrom
55-export-review-followups
Sep 20, 2026
Merged

PAMulligan merged 5 commits into
mainfrom
55-export-review-followups

Conversation

@PAMulligan

Copy link
Copy Markdown
Contributor

Follow-up to #55 (already closed) and #157. Two small bugs in conversation export that shipped in 1.17.0. They were found by the re-review of #157's final fix wave, after that wave had closed, so they went out unapplied. The feature is opt-in, so no embed is affected unless a site has switched it on.

What

1. A crafted line could hide the rest of a rendered transcript (09f45fc). Message text gets a backslash in front of a line-leading < so an unterminated HTML block cannot swallow later messages. Autolinks such as <https://example.com> and <help@example.com> are exempt, because escaping them shows the brackets and links the wrong place. That exemption accepted any text before the @, so <!--a@b>, <?a@b> and <![CDATA[a@b> passed as addresses, stayed unescaped, and opened a block that never closes. The address must now start with a letter or digit. Real autolinks are still left alone. Only reachable by a deliberately crafted message, and only against the author's own export.

2. copyText could reject, which it promises never to do (03a76a1, 7a3bf81). The execCommand fallback removed its textarea and restored focus in a finally without guarding either call, and copyText called the fallback outside its own try. Each cleanup step is now guarded, so a copy that worked still reports true and a failed removal does not also cost the visitor their focus. The whole call is wrapped too, so "never rejects" is structural rather than something each statement has to be audited for. The temporary textarea also leaves the tab order, so if removal ever failed it could not linger as a keyboard stop holding the transcript.

3. A comment I wrote gave the wrong reason the fix is safe (e639d3b). It said !, ? and [ "begin exactly the blocks that span blank lines". [ begins nothing, and <script/<pre/<style/<textarea blocks also span blank lines and start with a letter. They stay escaped for a structural reason: the tag name must be followed by whitespace, > or the end of the line, and neither : nor @ is any of those. The comment and the design doc now say that, and what would break it. The design doc had never recorded the autolink exemption at all. No behaviour change.

How it was verified

Test-first throughout: each new test was seen failing against the 1.17.0 code for the stated reason before its fix.

Check Result
Widget tests 679 passing (673 on main)
ESLint, Prettier, tsc, TypeDoc clean (the 2 react-refresh warnings on ChatWidget.tsx are on main too)
Build, size gate pass, no limit touched
Playwright, chromium-desktop 20/20

Beyond the unit tests:

  • The symptom, against a real parser. For each crafted line, the message after it now survives rendering by micromark, and the legitimate autolinks still render as links.
  • The execCommand fallback in real Chrome, for the first time. Until now it had only ever run in jsdom against a mocked execCommand. With navigator.clipboard removed to force the fallback: the copy command ran once on the focused textarea, the "Copied to clipboard" status appeared, the textarea was removed, focus returned to the menu trigger, and the transcript was on the system clipboard.
  • An independent review before opening this PR. It ran about 124,000 generated lines through micromark looking for any that slip past the new rule and still open a block that spans blank lines, and found none. It confirmed no legitimate autolink regressed and that the regex stays linear on hostile input (400,000 characters in under 3 ms per match). Its one substantive finding was item 3 above.

Notes

🤖 Generated with Claude Code

PAMulligan and others added 5 commits September 20, 2026 11:37
The exception that spares a line-leading autolink from the HTML-block
escape accepted any local part before the "@". So `<!--a@b>`, `<?a@b>`
and `<![CDATA[a@b>` passed as addresses and were left unescaped, and each
opens a CommonMark block that runs across blank lines until a terminator
that never comes. In a rendered transcript, every later message vanished.

The address form must now start with a letter or digit. Real autolinks
such as <help@example.com> and <https://example.com> are still left
alone. Checked against micromark: after each of those lines the next
message now survives rendering.

Shipped in 1.17.0. Only reachable by a deliberately crafted message, and
only against the author's own export, in an opt-in feature.

Follow-up to #55. The design doc now records the exception, which it
never did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s up

legacyCopy removed its textarea and restored focus in a `finally`, but
neither call was guarded. A throw there replaced the function's result,
and copyText calls it outside its own try, so the promise rejected on
the host page: the one thing copyText promises never to do.

Each cleanup step now has its own guard. A copy that worked still
reports true if tidying up fails, and a failed removal no longer costs
the visitor their focus as well.

Shipped in 1.17.0. Needs a document where remove() or focus() throws, so
it is theoretical, but the contract is "never rejects".

Follow-up to #55.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of the previous commit found one statement in legacyCopy still
outside every guard: reading document.activeElement runs before its try,
and copyText called legacyCopy outside its own. A throw there rejected
the promise. It cannot realistically happen, but "never rejects" should
be structural, not the result of auditing each statement, so the call
is now wrapped and answers false.

The temporary textarea also leaves the tab order. If removing it ever
fails, it must not stay behind as a keyboard stop that holds the whole
transcript while hidden from assistive technology.

Follow-up to #55.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… once

CommonMark allows "?" and "!" in an address, so <?q@example.com> and
<!weird@example.com> are valid autolinks that also open a processing
instruction and a declaration that never close. They are the strongest
cases for the rule that an address must start with a letter or digit.
Confirmed to fail against the 1.17.0 regex.

Follow-up to #55.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The comment added with the fix gave a wrong reason. It said "!", "?" and
"[" begin exactly the blocks that span blank lines. "[" begins nothing
(CDATA starts with "!"), and "exactly" is false: script, pre, style and
textarea blocks also span blank lines and start with a letter, which the
address form allows. They stay escaped for a structural reason instead:
the tag name must be followed by whitespace, ">" or the end of the line,
and neither ":" nor "@" is any of those.

A wrong rationale above a regex that has already shipped one bug is how
the next one gets in, so the comment and the design doc now record the
real invariant and what would break it. No behaviour change.

Follow-up to #55.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
claudius.iife.js (raw) 217.75 KB (+0.04% 🔺)
claudius.iife.js (gzip) 70.27 KB (+0.04% 🔺)
claudius.iife.js (brotli) 61.38 KB (-0.05% 🔽)
claudius.js (raw) 112.75 KB (+0.12% 🔺)
claudius.js (gzip) 31.13 KB (+0.07% 🔺)
claudius.js (brotli) 26.6 KB (+0.17% 🔺)
claudius.css (raw) 24.03 KB (0%)
claudius.css (gzip) 5.06 KB (0%)
claudius.css (brotli) 4.45 KB (0%)

@PAMulligan
PAMulligan merged commit a1d0c3b into main Sep 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant