openvibers/openvibe.live Commits
Stored XSS on public paste pages: make every HTML escaper escape quotes
Changes
@@@ -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>@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');}/** Close call context menus when clicking outside */@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');}function timeAgo(dateStr) {@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');}@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }function setDonateAmount(amount) {document.getElementById('modal-donate-amount').value = amount;@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); },++ }; } };++ // 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`);
@@ -5195,7 +5195,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5195,7 +5195,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
</div>
</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/opencoins.js?v=7" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<!-- 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
@@ -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-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" 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/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/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/dashboard-moderation.js?v=5" 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
@@ -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/recap.js?v=1" defer></script>
<script src="/js/context-cards.js?v=4" defer></script>
<script src="/js/context-cards.js?v=4" defer></script>
<script src="/js/powerchat.js?v=10" 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/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="/js/ov-mark.js?v=2" defer></script>
<script src="/shared/tooltip.js?v=2" defer></script>
<script src="/shared/tooltip.js?v=2" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
@@ -2632,10 +2632,9 @@ async function switchCallCam(deviceId) {
@@ -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) {
function _esc(str) {
- const d = document.createElement('div');
- d.textContent = str;
- return d.innerHTML;
+ return String(str == null ? '' : str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
}
/** Close call context menus when clicking outside */
/** Close call context menus when clicking outside */
@@ -154,11 +154,11 @@
@@ -154,11 +154,11 @@
let $toggle, $badge, $panel;
let $toggle, $badge, $panel;
// ── Helpers ──────────────────────────────────────────────────
// ── 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) {
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
}
function timeAgo(dateStr) {
function timeAgo(dateStr) {
@@ -410,7 +410,7 @@ async function loadPasteViewer(slug) {
@@ -410,7 +410,7 @@ async function loadPasteViewer(slug) {
// grabbed from — surface it as a real button (and drop the raw "▶ Watch…" line the
// grabbed from — surface it as a real button (and drop the raw "▶ Watch…" line the
// description text used to carry).
// description text used to carry).
const momentLink = (meta.ai_moment && typeof meta.vod_link === 'string' && /^\/(vod|clip)\//.test(meta.vod_link))
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();
const displayContent = (p.content || '').replace(/\n*▶[^\n]*$/,'').trim();
@@ -1597,8 +1597,9 @@ function formatTimeAgo(dateStr) {
@@ -1597,8 +1597,9 @@ function formatTimeAgo(dateStr) {
return date.toLocaleDateString();
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) {
function escapeHtml(str) {
- const div = document.createElement('div');
- div.textContent = str || '';
- return div.innerHTML;
+ return String(str == null ? '' : str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
}
@@ -328,7 +328,8 @@ async function _loadDonateGoals() {
@@ -328,7 +328,8 @@ async function _loadDonateGoals() {
}
}
} catch { wrap.style.display = 'none'; }
} 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function setDonateAmount(amount) {
function setDonateAmount(amount) {
document.getElementById('modal-donate-amount').value = amount;
document.getElementById('modal-donate-amount').value = amount;
@@ -258,4 +258,52 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -258,4 +258,52 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
ok('private VOD context is authorised before the cache, and never shareable');
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, '&').replace(/</g, '<').replace(/>/g, '>'); },
+ }; } };
+ // 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`);
console.log(`\n${pass} checks passed`);