openvibers/openvibe.live Commits

Stored XSS on public paste pages: make every HTML escaper escape quotes

OP OpenVibers committed 16 Sept 2026 · 94af0ad335 · 6 files, +65 −16

pastes.js escapeHtml, messenger.js esc, vibes.js escHb and call.js _esc all used the
textContent-then-innerHTML trick. That escapes < > and & but leaves " and ' untouched — fine for
text, wrong for attribute values, and these were used in attribute values:

- A screenshot paste's page_url is stored by OpenVibe.Media straight from the uploader's request
  body and rendered on the public paste page as href="${escapeHtml(meta.page_url)}". The only check
  was that it starts with http(s)://, which `https://x" onmouseover="…` satisfies — script running
  for anyone who opens the paste.
- A group DM's name, set by one member, rendered into other members' messenger as
  value="${esc(conv.name)}".
- Commenter avatar/colour values in paste comment attributes (lower risk: colours are validated
  hex server-side).

All four helpers now escape & < > " '. The AI-moment link on pastes also passed its URL into an
inline onclick JS string; HTML escaping cannot protect that context, because the browser decodes
&#39; back into ' before the handler runs, so it now reads the URL from the element's own href.

Tests: each helper is evaluated against an attribute-breakout payload; a scan fails if a
textContent/innerHTML escaper appears anywhere in public/js again; the moment link is checked not
to build a JS string. Confirmed failing, with a message naming the helper, when pastes.js is
reverted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 184a8eb6..2da47cf8 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5195,7 +5195,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
</div>
--<script src="/js/vibes.js?v=11" defer></script>
++<script src="/js/vibes.js?v=12" defer></script>
<script src="/js/opencoins.js?v=7" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
@@@ -5203,7 +5203,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
--<script src="/js/call.js?v=3" defer></script>
++<script src="/js/call.js?v=4" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
@@@ -5211,9 +5211,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/recap.js?v=1" defer></script>
<script src="/js/context-cards.js?v=4" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
--<script src="/js/pastes.js?v=15" defer></script>
++<script src="/js/pastes.js?v=16" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
--<script src="/js/messenger.js?v=3" defer></script>
++<script src="/js/messenger.js?v=4" defer></script>
<script src="/js/ov-mark.js?v=2" defer></script>
<script src="/shared/tooltip.js?v=2" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
ddiff --git a/public/js/call.js b/public/js/call.js
iindex 4ff63efc..bfb4ccee 100644
---- a/public/js/call.js
++++ b/public/js/call.js
@@@ -2632,10 +2632,9 @@ async function switchCallCam(deviceId) {
}
}
++// Quote-safe, like every other escaper on the site: a textContent/innerHTML round-trip leaves " and ' alone.
function _esc(str) {
-- const d = document.createElement('div');
-- d.textContent = str;
-- return d.innerHTML;
++ return String(str == null ? '' : str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** Close call context menus when clicking outside */
ddiff --git a/public/js/messenger.js b/public/js/messenger.js
iindex fea11b1b..eec4f228 100644
---- a/public/js/messenger.js
++++ b/public/js/messenger.js
@@@ -154,11 +154,11 @@
let $toggle, $badge, $panel;
// ── Helpers ──────────────────────────────────────────────────
++ // Escapes quotes as well as < > &. The previous version used the textContent/innerHTML trick,
++ // which leaves " and ' untouched — safe in text, but these helpers are also interpolated into
++ // attribute values, where an unescaped quote ends the attribute and starts a new one.
function esc(s) {
-- if (s == null) return '';
-- const d = document.createElement('div');
-- d.textContent = String(s);
-- return d.innerHTML;
++ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function timeAgo(dateStr) {
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex e536c727..224e8034 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -410,7 +410,7 @@ async function loadPasteViewer(slug) {
// grabbed from — surface it as a real button (and drop the raw "▶ Watch…" line the
// description text used to carry).
const momentLink = (meta.ai_moment && typeof meta.vod_link === 'string' && /^\/(vod|clip)\//.test(meta.vod_link))
-- ? `<a class="btn btn-primary btn-sm paste-moment-link" href="${escapeHtml(meta.vod_link)}" onclick="return handleLinkClick(event, '${escapeHtml(meta.vod_link)}')"><i class="fa-solid fa-circle-play"></i> Watch this moment on the VOD</a>`
++ ? `<a class="btn btn-primary btn-sm paste-moment-link" href="${escapeHtml(meta.vod_link)}" onclick="return handleLinkClick(event, this.getAttribute('href'))"><i class="fa-solid fa-circle-play"></i> Watch this moment on the VOD</a>`
: '';
const displayContent = (p.content || '').replace(/\n*▶[^\n]*$/,'').trim();
@@@ -1597,8 +1597,9 @@ function formatTimeAgo(dateStr) {
return date.toLocaleDateString();
}
++// Escapes quotes as well as < > &. The previous version used the textContent/innerHTML trick,
++// which leaves " and ' untouched — safe in text, but these helpers are also interpolated into
++// attribute values, where an unescaped quote ends the attribute and starts a new one.
function escapeHtml(str) {
-- const div = document.createElement('div');
-- div.textContent = str || '';
-- return div.innerHTML;
++ return String(str == null ? '' : str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex a2c0f88f..f14efd40 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -328,7 +328,8 @@ async function _loadDonateGoals() {
}
} catch { wrap.style.display = 'none'; }
}
--function escHb(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
++// Quote-safe, like every other escaper on the site: a textContent/innerHTML round-trip leaves " and ' alone.
++function escHb(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function setDonateAmount(amount) {
document.getElementById('modal-donate-amount').value = amount;
ddiff --git a/test/security-redaction.test.js b/test/security-redaction.test.js
iindex 51d3aad8..9b9b4f4e 100644
---- a/test/security-redaction.test.js
++++ b/test/security-redaction.test.js
@@@ -258,4 +258,52 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
ok('private VOD context is authorised before the cache, and never shareable');
}
++// ── Escapers used in attributes must escape quotes ───────────────────────────
++{
++ // A textContent/innerHTML round-trip escapes < > & but leaves " and ' alone. Several helpers were
++ // built that way and interpolated into attribute values: a screenshot paste's page_url (taken
++ // straight from the uploader's request body) in href="…" on a public page, and a group DM name
++ // in value="…". Each helper is evaluated here against a breakout payload.
++ const cases = [
++ ['public/js/pastes.js', /function escapeHtml\(str\) \{[\s\S]*?\n\}/, 'escapeHtml'],
++ ['public/js/messenger.js', /function esc\(s\) \{[\s\S]*?\n \}/, 'esc'],
++ ['public/js/vibes.js', /function escHb\(s\) \{[^\n]*\}/, 'escHb'],
++ ['public/js/call.js', /function _esc\(str\) \{[\s\S]*?\n\}/, '_esc'],
++ ];
++ const payload = `https://x" onmouseover="alert(1)' x='`;
++ for (const [file, re, name] of cases) {
++ const src = fs.readFileSync(path.join(ROOT, file), 'utf8');
++ const m = src.match(re);
++ assert(m, `${name} should exist in ${file}`);
++ // A stand-in for the DOM that behaves like a browser's textContent -> innerHTML serialisation
++ // (escapes & < > only), so a helper reverted to that trick fails on the quote assertion
++ // below with a clear message rather than on "document is not defined".
++ const fakeDocument = { createElement: () => { let t = ''; return {
++ set textContent(v) { t = String(v); },
++ get innerHTML() { return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); },
++ }; } };
++ // eslint-disable-next-line no-new-func
++ const fn = new Function('document', `${m[0]}; return ${name};`)(fakeDocument);
++ const out = fn(payload);
++ assert(!out.includes('"') && !out.includes("'"), `${file} ${name}() must escape quotes, got: ${out}`);
++ assert.strictEqual(fn(0), '0', `${file} ${name}(0) should render 0`);
++ }
++ ok('every attribute-context escaper escapes both quote characters');
++
++ // And nobody reintroduces the round-trip escaper somewhere new.
++ const offenders = [];
++ for (const f of fs.readdirSync(path.join(ROOT, 'public/js')).filter(n => n.endsWith('.js'))) {
++ const src = fs.readFileSync(path.join(ROOT, 'public/js', f), 'utf8');
++ const re = /\.textContent\s*=\s*[^;]+;\s*return\s+\w+\.innerHTML/g;
++ if (re.test(src)) offenders.push(f);
++ }
++ assert.deepStrictEqual(offenders, [], 'quote-unsafe textContent/innerHTML escaper found in: ' + offenders.join(', '));
++ ok('no textContent/innerHTML round-trip escapers remain in public/js');
++
++ const pastes = fs.readFileSync(path.join(ROOT, 'public/js/pastes.js'), 'utf8');
++ assert(!/handleLinkClick\(event, '\$\{escapeHtml\(meta\.vod_link\)\}'\)/.test(pastes),
++ 'the moment link must not interpolate its URL into an inline JS string');
++ ok('paste moment link reads its URL from the element, not an inline JS string');
++}
++
console.log(`\n${pass} checks passed`);