summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md19
-rw-r--r--README.md5
-rw-r--r--VERSION2
-rw-r--r--src/config.example.js5
-rw-r--r--src/widget.html121
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.
diff --git a/README.md b/README.md
index fcea453..e5ef3d4 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/VERSION b/VERSION
index 3eefcb9..7dea76e 100644
--- a/VERSION
+++ b/VERSION
@@ -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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
}
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';