diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-09 15:36:11 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-09 15:36:11 +0100 |
| commit | 0ac6a7a584dbbf1c7e37599700db4ca866ae71fb (patch) | |
| tree | 4cc81ff160db5386b0b9e3cedcb651e6eacf9829 | |
| parent | ba15e7a4bf9dc4ab6b312f40c2e0199d4e5a8255 (diff) | |
A review of all three products in Chromium found defects that only execution exposes.
pdf2txt 1.0.0 DID NOT RUN. Both the packaged file and the live demo contained a duplicated
const declaration inside fetchModels, so the inline script threw a SyntaxError at parse time and
no handler was ever bound: no extraction, no translation, no config. Proven by extracting both
inline scripts from the committed file and from spaces/chatstudio/pdf2txt.html and running
node --check (SyntaxError: Identifier 'config' has already been declared). The two published
versions of this product were dead on arrival.
pdf2txt 1.0.1 also stops writing the API key and the whole extracted document to localStorage
(sessionStorage for the key, nothing for the text), keeps paragraphs (hasEOL), splits chunks on
word boundaries, reports a non-JSON 200 or a missing response field instead of writing "undefined"
into the editor, re-enables Translate after a run, resets stopRequested, stops a workflow on a
failed step, warns on a PDF with no text layer, recognises .PDF, fixes an unterminated mobile
media query, stops the Fullscreen button covering line 1, and makes the drop zone and all 39
controls reachable and named.
shiftplanner 1.0.1 fixes seven demonstrated defects: the settings modal recursed until the stack
overflowed, "+ Worker" prefilled the last edited worker id and overwrote that person, date maths
was off by one west of UTC (local-noon Date formatted with toISOString), same-day and overnight
shift overlaps were never flagged, the generator sliders never reached state.settings, CSV export
could execute a formula and broke on a quote, and the header never wrapped (390 px viewport gave a
1139 px document). Plus four data-integrity guards: unparseable saved plans are copied to a
recovery key instead of silently overwritten, wrong-typed fields are normalised, unknown top-level
fields survive a save, and a storage event syncs another tab.
chat-widget 1.0.1 closes a critical attribute-injection XSS. escapeHtml() round-tripped through
textContent, which does not escape a double quote, so a reply containing
https://x.test/"onclick="alert(1)// produced a real <a onclick=...> and fired alert(1) when
clicked. Proven both ways: the 1.0.0 file fired a dialog, the fixed one renders only
href/target/rel and stays silent. The launcher also covered the Send button at 390 px, so the
widget could not be used on a phone at all.
Also in this commit:
- Landing pages and demos are generated (build-product-pages.py). The "Try the demo" button used
to point at spaces/chatstudio/pdf2txt.html (the dead copy above) and at a v2.1 shiftplanner with
no AI panel: a demo that was a different build than the ZIP. Demos are now built into
/demos/<slug>/ from products/<slug>/, with the ../vendor/ prefix rewritten, and the publish
wrapper regenerates them before every publish.
- publish-public-repo.py refuses to publish a subtree with uncommitted changes. It reads git, so
it silently republished the old tip while rc was 0 for every slug.
| -rw-r--r-- | CHANGELOG.md | 19 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | VERSION | 2 | ||||
| -rw-r--r-- | src/config.example.js | 5 | ||||
| -rw-r--r-- | src/widget.html | 121 |
5 files changed, 132 insertions, 20 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a97b2..d0f231b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to the WebX11 Chat Widget are recorded here. +## 1.0.1 — 2026-11-09 + +Security and mobile fixes found by a review that rendered hostile model output in Chromium. + +- **Critical: attribute injection in linkified replies.** `escapeHtml()` escaped `& < >` by + round-tripping through `textContent`, which does not escape a double quote — so a reply containing + `https://x.test/"onclick="alert(1)//` produced a real `<a onclick=…>` and fired `alert(1)` when + clicked. Escaping now covers `"` and `'` as well. Proven both ways: the 1.0.0 file fired a dialog, + this one renders only `href/target/rel` and stays silent. +- **The launcher covered the Send button at 390 px**, making the widget unusable on a phone (the + click was intercepted by the 56 px bubble). The launcher is now hidden while the panel is open. +- Requests get an idle timeout (`idleTimeoutMs`, default 60 s, re-armed on every chunk) so a gateway + that accepts and never answers cannot leave the input disabled forever. +- An empty stream or an HTML error page returned with status 200 now shows an error line instead of + an empty bubble; a JSON body without stream framing is accepted as a whole reply. +- Closing the panel restores the host page's own `body` overflow value instead of clearing it. +- Accessibility: `aria-expanded` / `aria-controls` on the launcher, focus returns to it on Escape, + the demo page no longer blocks zoom (`user-scalable=no` removed). + ## 1.0.0 — 2026-11-09 First packaged release. @@ -58,6 +58,7 @@ Full instructions: `docs/USAGE.md`. | `chips` | `[]` | Quick-reply buttons `[label, message]` | | `position` | `right` | `right` or `left` | | `statusLine` | `true` | Show the `Model: …` line | +| `idleTimeoutMs` | `60000` | Abort if the gateway sends nothing for this long | ## Known limitations (be aware before you buy) @@ -69,6 +70,10 @@ Full instructions: `docs/USAGE.md`. so the transcript is gone on reload (and there is no cookie banner to add). - Markdown support is intentionally minimal: bold, italic, inline code, links, line breaks. +- The widget is an inline `<style>` + `<script>` block, so a host page with a + strict Content-Security-Policy must allow those blocks (hash/nonce) or serve + them as files from its own origin. It uses no `eval`, no CDN and no + third-party requests. ## License @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/src/config.example.js b/src/config.example.js index 84d2a01..b41f4fd 100644 --- a/src/config.example.js +++ b/src/config.example.js @@ -48,6 +48,11 @@ window.WX_WIDGET_CONFIG = { /* Show the "Model: …" line under the header. Set to false to hide it. */ statusLine: true, + /* Abort a request when the gateway sends nothing for this many + * milliseconds (default 60000). Raise it if your model can take minutes + * to produce the first token; the timer resets on every received chunk. */ + idleTimeoutMs: 60000, + /* The assistant's instructions. Write this for your own business. */ systemPrompt: 'You are a helpful assistant for this website. ' + diff --git a/src/widget.html b/src/widget.html index f0ed5ca..2dd5cf2 100644 --- a/src/widget.html +++ b/src/widget.html @@ -2,7 +2,7 @@ <html lang="en"> <head> <meta charset="UTF-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Chat Widget</title> <!-- WebX11 Chat Widget 1.0.0 @@ -145,6 +145,7 @@ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } .wx-msg-error { border-color: #ef4444; } + .wx-error-detail { opacity: .65; } .wx-chips { display: flex; gap: 8px; flex-wrap: wrap; @@ -214,6 +215,9 @@ border-radius: 0; border: none; } .wx-messages { padding: 14px; } + /* Fullscreen panel covers the viewport; the launcher must not sit on top + of the Send button. Closing the panel brings it straight back. */ + #wx-chat-widget[data-open="true"] #wx-chat-button { display: none; } } @media (prefers-reduced-motion: reduce) { @@ -256,7 +260,7 @@ <button id="wx-send" type="button">Send</button> </div> </div> - <button id="wx-chat-button" type="button" aria-label="Open chat"> + <button id="wx-chat-button" type="button" aria-label="Open chat" aria-expanded="false" aria-controls="wx-chat-window"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/> </svg> @@ -290,7 +294,8 @@ questionPrefix: 'User Question:', chips: [], // e.g. [['Pricing', 'What does it cost?']] position: 'right', // 'right' or 'left' - statusLine: true // show "Model: …" under the header + statusLine: true, // show "Model: …" under the header + idleTimeoutMs: 60000 // abort if the gateway sends nothing for this long }; var supplied = window.WX_WIDGET_CONFIG || {}; @@ -360,9 +365,14 @@ // Helpers ---------------------------------------------------------- // ------------------------------------------------------------------ function escapeHtml(str) { - var div = document.createElement('div'); - div.textContent = String(str == null ? '' : str); - return div.innerHTML; + // Escape quotes too: link hrefs are built as HTML strings, and a model + // reply like https://x/"onclick="... would otherwise inject attributes. + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } function formatAiText(text) { @@ -452,26 +462,47 @@ function toggleChat() { var open = chatWin.style.display === 'flex'; if (open) { - chatWin.style.display = 'none'; - document.body.style.overflow = ''; + closeChat(); } else { chatWin.style.display = 'flex'; + root.setAttribute('data-open', 'true'); + chatBtn.setAttribute('aria-expanded', 'true'); chatBtn.classList.remove('has-unread'); - if (window.innerWidth <= 520) document.body.style.overflow = 'hidden'; + if (window.innerWidth <= 520) lockScroll(); userInput.focus(); } } + // The host page owns its body: remember whatever overflow it had and put it + // back, instead of assuming it was empty. + var savedBodyOverflow = null; + function lockScroll() { + if (savedBodyOverflow === null) savedBodyOverflow = document.body.style.overflow || ''; + document.body.style.overflow = 'hidden'; + } + function unlockScroll() { + if (savedBodyOverflow !== null) { + document.body.style.overflow = savedBodyOverflow; + savedBodyOverflow = null; + } + } + + function closeChat() { + chatWin.style.display = 'none'; + root.removeAttribute('data-open'); + chatBtn.setAttribute('aria-expanded', 'false'); + unlockScroll(); + } + chatBtn.addEventListener('click', toggleChat); closeBtn.addEventListener('click', function () { - chatWin.style.display = 'none'; - document.body.style.overflow = ''; + closeChat(); chatBtn.focus(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && chatWin.style.display === 'flex') { - chatWin.style.display = 'none'; - document.body.style.overflow = ''; + closeChat(); + chatBtn.focus(); } }); @@ -512,17 +543,37 @@ var fullPrompt = systemPrompt + '\n\n' + CFG.questionPrefix + ' ' + text; + // Abort the request if the gateway goes quiet. This covers the "server + // accepts the connection and never answers" case, which used to leave the + // send button disabled forever. + var idleMs = Math.max(2000, Number(CFG.idleTimeoutMs) || 60000); + var ctrl = typeof AbortController === 'function' ? new AbortController() : null; + var idleTimer = null; + var timedOut = false; + function armIdle() { + if (!ctrl) return; + clearTimeout(idleTimer); + idleTimer = setTimeout(function () { + timedOut = true; + ctrl.abort(); + }, idleMs); + } + function disarmIdle() { clearTimeout(idleTimer); } + armIdle(); + fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: CFG.model, prompt: fullPrompt, stream: true }) + body: JSON.stringify({ model: CFG.model, prompt: fullPrompt, stream: true }), + signal: ctrl ? ctrl.signal : undefined }).then(function (res) { if (!res.ok) throw new Error('API ' + res.status + ' ' + res.statusText); + statusDot.classList.remove('offline'); removeMessage(loadingEl); if (!res.body || !res.body.getReader) { return res.json().then(function (data) { - appendMessage(formatAiText(data.response || ''), 'wx-msg-ai'); + appendMessage(formatAiText(data.response || (data.message && data.message.content) || ''), 'wx-msg-ai'); chipRow.innerHTML = ''; }); } @@ -530,11 +581,14 @@ var aiEl = appendMessage('', 'wx-msg-ai'); var full = ''; var buffer = ''; + var raw = ''; + var apiError = ''; var reader = res.body.getReader(); var decoder = new TextDecoder(); function paint() { aiEl.innerHTML = formatAiText(full); + if (chatWin.style.display !== 'flex') chatBtn.classList.add('has-unread'); messages.scrollTop = messages.scrollHeight; } function consume(line) { @@ -545,17 +599,42 @@ try { var json = JSON.parse(line); var piece = json.response || (json.message && json.message.content) || ''; + if (json.error) apiError = String(json.error); if (piece) { full += piece; paint(); } } catch (_) { /* keep-alive or partial line */ } } - function pump() { return reader.read().then(function (r) { if (r.done) { if (buffer.trim()) consume(buffer); + if (!full) { + // Whole-body fallback: a pretty-printed (multi-line) JSON reply, + // or an SSE frame whose data was split over several lines. + var whole = raw.trim(); + if (whole) { + var cand = whole.indexOf('data:') === 0 + ? whole.replace(/^data:\s?/gm, '') + : whole; + try { + var data = JSON.parse(cand); + var fallback = data.response || (data.message && data.message.content) || ''; + if (data.error) apiError = String(data.error); + if (fallback) { full = fallback; paint(); } + } catch (_) { /* not a single JSON document */ } + } + } + if (!full) { + aiEl.className = 'wx-msg wx-msg-ai wx-msg-error'; + aiEl.textContent = apiError + ? 'Chat service error: ' + apiError + : 'The chat service returned an empty or unreadable response.'; + } return; } - buffer += decoder.decode(r.value, { stream: true }); + armIdle(); + var chunk = decoder.decode(r.value, { stream: true }); + raw += chunk; + buffer += chunk; var idx; while ((idx = buffer.indexOf('\n')) >= 0) { consume(buffer.slice(0, idx)); @@ -572,11 +651,15 @@ removeMessage(loadingEl); statusDot.classList.add('offline'); appendMessage( - 'Sorry — I could not reach the chat service. ' + - '<br><small style="opacity:.65">' + escapeHtml(err.message) + '</small>', + 'Sorry — ' + (timedOut + ? 'the chat service did not respond within ' + Math.round(idleMs / 1000) + ' s.' + : 'I could not reach the chat service.') + + '<br><small class="wx-error-detail">' + + escapeHtml(err && err.message ? err.message : '') + '</small>', 'wx-msg-ai wx-msg-error' ); }).then(function () { + disarmIdle(); isProcessing = false; sendBtn.disabled = false; sendBtn.textContent = 'Send'; |