Ctrl K
ComunidadGuías…

tests/pr6503_security_browser.cjs

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const playwright = require('playwright');
const { readFileSync } = require('node:fs');
const { execFileSync } = require('node:child_process');
const assert = require('node:assert/strict');

const source = readFileSync('static/js/document.js', 'utf8');
function documentFunction(name, text = source) {
  const match = text.match(new RegExp('^  (?:async )?function ' + name + '\\(.*?^  }', 'ms'));
  assert(match, name);
  return match[0];
}

(async () => {
  const engine = process.env.ODYSSEUS_SECURITY_BROWSER || 'chromium';
  assert(['chromium', 'firefox', 'webkit'].includes(engine), engine);
  const browser = await playwright[engine].launch({ headless: true });
  try {
    // Opt-in positive controls use an explicit vulnerable revision, so these
    // regressions also work after the fix is committed or in a shallow checkout.
    const baselineRevision = process.env.ODYSSEUS_SECURITY_BASELINE_REVISION;
    if (baselineRevision) {
      const original = execFileSync('git', ['show', baselineRevision + ':static/js/document.js'], { encoding: 'utf8' });
      const baseline = await browser.newPage();
      await baseline.route('**/api/**', route => route.fulfill({ json: { fonts: {}, value: null } }));
      await baseline.route('**/static/js/chatRenderer-head-harness.js', route => route.fulfill({
        contentType: 'application/javascript',
        body: execFileSync('git', ['show', baselineRevision + ':static/js/chatRenderer.js'], { encoding: 'utf8' }),
      }));
      await baseline.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js');
      const originalFunctions = Object.fromEntries([
        '_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
        '_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply', '_loadOdysseusAttachItems',
        '_odysseusAttachLabel', '_escHtml',
      ].map(name => [name, documentFunction(name, original)]));
      const vulnerable = await baseline.evaluate(async functions => {
        const { recordSessionMetricsCost } = await import('/static/js/chatRenderer-head-harness.js');
        recordSessionMetricsCost({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
          endpoint_cost_tracked: true, _costRecordId: 'pollutedByLedger' }, '__proto__');
        await navigator.locks.request('odysseus-session-cost-ledger', () => {});
        const polluted = Object.hasOwn(Object.prototype, 'pollutedByLedger');
        delete Object.prototype.pollutedByLedger;
        localStorage.clear();
        // Isolate the second annotation's overflow assignment from the real
        // inherited-session lookup defect by seeding an OWN __proto__ run map.
        // The addition assigned at HEAD:1424 is primitive, so __proto__'s setter
        // ignores it rather than changing either this map or Object.prototype.
        const prototypeBefore = Object.getOwnPropertyDescriptors(Object.prototype);
        localStorage.setItem('ody-session-cost-runs', JSON.stringify({ ['__proto__']:
          Object.fromEntries(Array.from({ length: 256 }, (_, i) => ['seed-' + i, 0.001])) }));
        recordSessionMetricsCost({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
          endpoint_cost_tracked: true, _costRecordId: 'overflow-proof' }, '__proto__');
        await navigator.locks.request('odysseus-session-cost-ledger', () => {});
        const prototypeAfter = Object.getOwnPropertyDescriptors(Object.prototype);
        const overflowUnchanged = Reflect.ownKeys(prototypeBefore).length === Reflect.ownKeys(prototypeAfter).length
          && Reflect.ownKeys(prototypeBefore).every(key => Reflect.ownKeys(prototypeBefore[key])
            .every(field => prototypeBefore[key][field] === prototypeAfter[key]?.[field]));
        const overflowRuns = JSON.parse(localStorage.getItem('ody-session-cost-runs'))['__proto__'];
        const overflowCostStore = localStorage.getItem('ody-session-cost');
        localStorage.clear();
        const _unfoldEmailHeaderLines = eval('(' + functions._unfoldEmailHeaderLines + ')');
        const _parseEmailHeader = eval('(' + functions._parseEmailHeader + ')');
        const _looksLikeWrappedEmailContent = eval('(' + functions._looksLikeWrappedEmailContent + ')');
        const _decodeBase64EmailWrapper = eval('(' + functions._decodeBase64EmailWrapper + ')');
        const _sanitizeOutgoingEmailBody = eval('(' + functions._sanitizeOutgoingEmailBody + ')');
        const _emailHtmlToPlainText = eval('(' + functions._emailHtmlToPlainText + ')');
        const activeDocId = 'fixture';
        const docs = new Map();
        const uiModule = { showToast() {} };
        const _splitEmailReplyQuote = text => ({ body: text, quote: '' });
        const generate = eval('(' + functions._aiReply + ')');
        document.body.innerHTML = '<textarea id="doc-editor-textarea"></textarea>';
        const payload = '<p>Existing draft</p><img src="data:,bad" onerror="window.executed++">';
        const executions = [];
        for (const inspect of [() => _sanitizeOutgoingEmailBody(payload), () => _emailHtmlToPlainText(payload),
          () => { document.getElementById('doc-editor-textarea').value = payload; return generate(); }]) {
          window.executed = 0;
          await inspect();
          await new Promise(resolve => setTimeout(resolve, 100));
          executions.push(window.executed);
        }
        const API_BASE = '';
        const _escHtml = eval('(' + functions._escHtml + ')');
        const _odysseusAttachLabel = eval('(' + functions._odysseusAttachLabel + ')');
        const spinnerModule = { createLoadingRow: () => document.createElement('div') };
        const _syncOdysseusAttachSelection = () => {};
        const fetch = async () => ({ ok: true, json: async () => ({ items: [{ id: 'quote', filename: 'quote.png',
          url: 'data:,bad" onerror="window.executed++' }] }) });
        const menu = document.createElement('div');
        menu.innerHTML = '<div class="email-odysseus-attach-list"></div>';
        document.body.appendChild(menu);
        window.executed = 0;
        await eval('(' + functions._loadOdysseusAttachItems + ')')(menu, 'gallery');
        await new Promise(resolve => setTimeout(resolve, 100));
        return { polluted, executions, gallery: window.executed, injected: !!menu.querySelector('[onerror]'),
          overflowUnchanged, overflowRuns: Object.keys(overflowRuns).length, overflowCostStore };
      }, originalFunctions);
      assert.equal(vulnerable.polluted, true);
      assert(vulnerable.executions.every(count => count > 0), JSON.stringify(vulnerable));
      assert.equal(vulnerable.injected, true);
      assert(vulnerable.gallery > 0);
      assert.equal(vulnerable.overflowUnchanged, true);
      assert.equal(vulnerable.overflowRuns, 256);
      assert.equal(vulnerable.overflowCostStore, '{}');
      await baseline.close();
    }

    const page = await browser.newPage();
    const errors = [];
    const probes = [];
    page.on('pageerror', error => errors.push(error.message));
    page.on('request', request => {
      if (request.url().includes('/inert-probe')) probes.push(request.url());
    });
    await page.route('**/api/**', route => route.fulfill({ json: { fonts: {}, value: null } }));
    await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js');
    await page.setContent('<!doctype html><textarea id="doc-editor-textarea"></textarea>');

    const ledger = await page.evaluate(async () => {
      const { recordSessionMetricsCost, getSessionCost, resetSessionCost } = await import('/static/js/chatRenderer.js');
      const metrics = id => ({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
        endpoint_cost_tracked: true, _costRecordId: id });
      const before = Object.getOwnPropertyDescriptors(Object.prototype);
      for (const sid of ['__proto__', 'constructor', 'prototype', ['__proto__'], { toString: () => '__proto__' }]) {
        for (const id of ['pollutedByLedger', '__proto__', 'constructor', 'prototype', '']) {
          localStorage.clear();
          recordSessionMetricsCost(metrics(id), sid);
          // Web Locks settle asynchronously in Chromium.
          await navigator.locks.request('odysseus-session-cost-ledger', () => {});
          if (Object.hasOwn(Object.prototype, 'pollutedByLedger')) throw new Error('Object.prototype polluted');
          if (localStorage.getItem('ody-session-cost') || localStorage.getItem('ody-session-cost-runs')) {
            throw new Error('Unsafe session key was stored');
          }
        }
        localStorage.clear();
        recordSessionMetricsCost(metrics('  ' + sid + '  '), 'safe-session');
        await navigator.locks.request('odysseus-session-cost-ledger', () => {});
        if (localStorage.getItem('ody-session-cost-runs')) throw new Error('Unsafe run key was stored');
      }
      localStorage.clear();
      recordSessionMetricsCost(metrics('valid-run'), 'safe-session');
      await navigator.locks.request('odysseus-session-cost-ledger', () => {});
      const cost = getSessionCost('safe-session');
      recordSessionMetricsCost(metrics('valid-run'), 'safe-session');
      await navigator.locks.request('odysseus-session-cost-ledger', () => {});
      // Keys are literal property names, not dot-separated traversal paths.
      recordSessionMetricsCost(metrics('constructor.prototype'), 'safe.__proto__.session');
      await navigator.locks.request('odysseus-session-cost-ledger', () => {});
      const legacy = metrics('');
      recordSessionMetricsCost(legacy, 'legacy-session');
      recordSessionMetricsCost(legacy, 'legacy-session');
      await navigator.locks.request('odysseus-session-cost-ledger', () => {});
      for (let i = 0; i < 257; i++) recordSessionMetricsCost(metrics('run-' + i), 'overflow-session');
      await navigator.locks.request('odysseus-session-cost-ledger', () => {});
      // Snapshot all ordinary-ledger results before the reserved-key fixture
      // deliberately replaces localStorage below.
      const replayCost = getSessionCost('safe-session');
      const legacyCost = getSessionCost('legacy-session');
      const dottedCost = getSessionCost('safe.__proto__.session');
      const overflowCost = getSessionCost('overflow-session');
      const runWire = JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}');
      const costWire = JSON.parse(localStorage.getItem('ody-session-cost') || '{}');
      const retainedRuns = Object.keys(runWire['overflow-session']).length;
      const wireShape = !Array.isArray(runWire)
        && !Array.isArray(costWire)
        && !!runWire['overflow-session']
        && !Array.isArray(runWire['overflow-session']);

      // A persisted legacy/reserved key must remain data rather than becoming
      // prototype state. Reading and removing it must also be side-effect free.
      localStorage.setItem('ody-session-cost', '{"__proto__":1}');
      localStorage.setItem('ody-session-cost-runs', '{"__proto__":{"legacy-run":2}}');
      const legacyReservedCost = getSessionCost('__proto__');
      resetSessionCost('__proto__');
      const clearedReserved = !Object.hasOwn(
        JSON.parse(localStorage.getItem('ody-session-cost') || '{}'),
        '__proto__',
      ) && !Object.hasOwn(
        JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}'),
        '__proto__',
      );

      const after = Object.getOwnPropertyDescriptors(Object.prototype);
      return { cost, replayCost, legacyCost, dottedCost,
        overflowCost, retainedRuns, wireShape, legacyReservedCost, clearedReserved,
        unchanged: Reflect.ownKeys(before).length === Reflect.ownKeys(after).length
          && Reflect.ownKeys(before).every(key => Reflect.ownKeys(before[key]).every(field => before[key][field] === after[key]?.[field])) };
    });
    assert(ledger.cost > 0);
    assert.equal(ledger.replayCost, ledger.cost);
    assert.equal(ledger.unchanged, true);
    assert.equal(ledger.legacyCost, ledger.cost);
    assert.equal(ledger.dottedCost, ledger.cost);
    assert(Math.abs(ledger.overflowCost - 257 * ledger.cost) < 1e-10);
    assert.equal(ledger.retainedRuns, 256);
    assert.equal(ledger.wireShape, true);
    assert.equal(ledger.legacyReservedCost, 3);
    assert.equal(ledger.clearedReserved, true);

    const names = ['_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
      '_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply',
      '_loadOdysseusAttachItems', '_odysseusAttachLabel', '_escHtml',
      '_normalizeRichLinkUrl', '_smartRichPasteUrl', '_insertSmartRichPasteLink', '_cleanRichTextPasteHtml', 'exportAsPdf'];
    const functions = Object.fromEntries(names.map(name => [name, documentFunction(name)]));
    const email = await page.evaluate(async functions => {
      window.executed = 0;
      const _unfoldEmailHeaderLines = eval('(' + functions._unfoldEmailHeaderLines + ')');
      const _parseEmailHeader = eval('(' + functions._parseEmailHeader + ')');
      const _looksLikeWrappedEmailContent = eval('(' + functions._looksLikeWrappedEmailContent + ')');
      const _decodeBase64EmailWrapper = eval('(' + functions._decodeBase64EmailWrapper + ')');
      const _sanitizeOutgoingEmailBody = eval('(' + functions._sanitizeOutgoingEmailBody + ')');
      const _emailHtmlToPlainText = eval('(' + functions._emailHtmlToPlainText + ')');
      const activeDocId = 'fixture';
      const docs = new Map();
      const toasts = [];
      const uiModule = { showToast: text => toasts.push(text) };
      const _splitEmailReplyQuote = text => ({ body: text, quote: '' });
      const generate = eval('(' + functions._aiReply + ')');
      const payloads = [
        '<img src="/inert-probe" onerror="window.executed++">',
        '<svg onload="window.executed++"><script>window.executed++</script></svg>',
        '<iframe srcdoc="<script>parent.executed++</script>"></iframe>',
        '<img src="data:,bad" onerror="window.executed++">',
      ];
      const plain = [];
      for (const payload of payloads) {
        _sanitizeOutgoingEmailBody(payload);
        plain.push(_emailHtmlToPlainText(payload));
        // A user-authored body must be inspected without executing its HTML.
        document.getElementById('doc-editor-textarea').value = '<p>Existing draft</p>' + payload;
        await generate();
      }
      // Media-only drafts must not be mistaken for an empty reply. This checks
      // the query boundary moved from the element to template.content too.
      for (const body of ['<img src="/inert-probe">', '<video src="/inert-probe"></video>',
        '<audio src="/inert-probe"></audio>', '<iframe src="/inert-probe"></iframe>', '<table><tr><td></td></tr></table>']) {
        document.getElementById('doc-editor-textarea').value = body;
        await generate();
      }
      const normal = _emailHtmlToPlainText('<p>Hello <b>world</b> &amp; friends</p>');
      const literal = _emailHtmlToPlainText('&lt;img src=x onerror="window.executed++"&gt;');
      const outgoing = _sanitizeOutgoingEmailBody('Hello\n\nworld');
      const wrapped = _sanitizeOutgoingEmailBody(btoa('To: person@example.com\nSubject: Test\n---\nValid reply'));
      await new Promise(resolve => setTimeout(resolve, 150));
      return { normal, literal, outgoing, wrapped, toasts, executed: window.executed };
    }, functions);
    assert.equal(email.normal, 'Hello world & friends');
    assert.equal(email.literal, '<img src=x onerror="window.executed++">');
    assert.equal(email.outgoing, 'Hello\n\nworld');
    assert.equal(email.wrapped, 'Valid reply');
    assert.deepEqual(email.toasts, Array(9).fill('Reply already has text'));
    assert.equal(email.executed, 0);
    assert.deepEqual(probes, []);

    // The current payload must execute at an active DOM boundary. This makes
    // the non-execution assertions above independent of old git revisions.
    const activeEmailControl = await page.evaluate(async () => {
      const active = document.createElement('div');
      active.innerHTML = '<img src="data:,bad" onerror="window.executed++">';
      document.body.appendChild(active);
      const deadline = Date.now() + 2000;
      while (!window.executed && Date.now() < deadline) {
        await new Promise(resolve => setTimeout(resolve, 20));
      }
      active.remove();
      const executed = window.executed;
      window.executed = 0;
      return executed;
    });
    assert(activeEmailControl > 0);

    const gallery = await page.evaluate(async functions => {
      const API_BASE = '';
      const _escHtml = eval('(' + functions._escHtml + ')');
      const _odysseusAttachLabel = eval('(' + functions._odysseusAttachLabel + ')');
      const spinnerModule = { createLoadingRow: () => document.createElement('div') };
      const _syncOdysseusAttachSelection = () => {};
      const load = eval('(' + functions._loadOdysseusAttachItems + ')');
      const urls = ['/static/missing.png" onerror="window.executed++" data-injected="yes',
        '/static/missing.png"><svg onload="window.executed++"></svg>', '/static/missing.png',
        'javascript:window.executed++', 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" onload="parent.executed++"/>'];
      const fetch = async () => ({ ok: true, json: async () => ({ items: urls.map((url, i) => ({
        id: 'image-' + i, url, filename: '<b>Picture</b>', title: 'Safe title' })) }) });
      const menu = document.createElement('div');
      menu.innerHTML = '<div class="email-odysseus-attach-list"></div>';
      document.body.appendChild(menu);
      await load(menu, 'gallery');
      const rows = [...menu.querySelectorAll('.email-odysseus-attach-row')];
      rows[0].click();
      await new Promise(resolve => setTimeout(resolve, 150));
      return { urls, sources: rows.map(row => row.querySelector('img').getAttribute('src')),
        unsafe: menu.querySelectorAll('[onerror], [onload], [data-injected], svg, script').length,
        selected: rows[0].classList.contains('is-selected'),
        labels: rows.map(row => row.querySelector('.email-odysseus-attach-meta').textContent),
        executed: window.executed };
    }, functions);
    assert.deepEqual(gallery.sources, gallery.urls);
    assert.equal(gallery.unsafe, 0);
    assert.equal(gallery.executed, 0);
    assert.equal(gallery.selected, true);
    assert.deepEqual(gallery.labels, Array(5).fill('<b>Picture</b>'));

    const links = await page.evaluate(async functions => {
      const markdownModule = await import('/static/js/markdown.js');
      const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')');
      const _smartRichPasteUrl = eval('(' + functions._smartRichPasteUrl + ')');
      const insert = eval('(' + functions._insertSmartRichPasteLink + ')');
      const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')');
      const rejected = ['javascript:window.executed++', 'java\tscript:window.executed++',
        'data:text/html,<script>window.executed++</script>', 'vbscript:msgbox(1)',
        'file:///etc/passwd', 'https://example.com/\njavascript:window.executed++',
        'blob:https://example.com/id', 'about:blank', 'ftp://example.com/path',
        'JaVaScRiPt:window.executed++', 'java\rscript:window.executed++',
        'https://', '//outside.example/path'];
      const rich = document.createElement('div');
      rich.contentEditable = 'true';
      // Literal markup in an existing selection must remain text after linking.
      const literal = '<img src=x onerror="window.executed++">';
      const bold = document.createElement('strong');
      bold.textContent = literal;
      rich.appendChild(bold);
      document.body.appendChild(rich);
      const range = document.createRange();
      range.selectNodeContents(rich);
      getSelection().removeAllRanges();
      getSelection().addRange(range);
      rich.focus();
      const internal = location.origin + '/static/index.html?session=valid#doc';
      const inserted = insert(rich, internal);
      const link = rich.querySelector('a');
      const result = { rejected: rejected.map(_smartRichPasteUrl), inserted,
        href: link?.href, internal, text: link?.textContent, literal,
        bold: link?.querySelector('strong')?.textContent,
        unsafe: rich.querySelectorAll('img,script,[onerror],[onload]').length,
        mail: _smartRichPasteUrl('person@example.com'), tel: _smartRichPasteUrl('tel:+12345'),
        external: _smartRichPasteUrl('https://outside.example/path?q=1#fragment'),
        domain: _smartRichPasteUrl('example.com/path'),
        network: _smartRichPasteUrl('//outside.example/path'),
        deceptive: _smartRichPasteUrl('https://internal.example@outside.example/path'),
        executed: window.executed };
      rich.innerHTML = cleanPaste('<p><strong onmouseover="window.executed++"><a href="javascript:window.executed++">Safe selection</a></strong></p>');
      const pastedRange = document.createRange();
      pastedRange.selectNodeContents(rich.querySelector('p'));
      getSelection().removeAllRanges();
      getSelection().addRange(pastedRange);
      result.cleanSelection = insert(rich, 'https://outside.example/path');
      result.cleanText = rich.querySelector('a')?.textContent;
      result.cleanUnsafe = rich.querySelectorAll('[onmouseover], a[href^="javascript:"]').length;

      const collapsed = document.createRange();
      collapsed.selectNodeContents(rich);
      collapsed.collapse(false);
      getSelection().removeAllRanges();
      getSelection().addRange(collapsed);
      result.collapsed = insert(rich, 'https://outside.example/\"><svg/onload=window.executed++>');
      result.quoteUnsafe = rich.querySelectorAll('svg,[onload]').length;

      rich.innerHTML = '<p>First</p><p>Second</p>';
      const crossing = document.createRange();
      crossing.setStart(rich.firstChild.firstChild, 0);
      crossing.setEnd(rich.lastChild.firstChild, 6);
      getSelection().removeAllRanges();
      getSelection().addRange(crossing);
      result.crossing = insert(rich, internal);
      const outside = document.createRange();
      outside.selectNodeContents(document.getElementById('doc-editor-textarea'));
      getSelection().removeAllRanges();
      getSelection().addRange(outside);
      result.outside = insert(rich, internal);
      return result;
    }, functions);
    assert.deepEqual(links.rejected, Array(13).fill(''));
    assert.equal(links.inserted, true);
    assert.equal(links.href, links.internal);
    assert.equal(links.text, links.literal);
    assert.equal(links.bold, links.literal);
    assert.equal(links.unsafe, 0);
    assert.equal(links.executed, 0);
    assert.equal(links.mail, 'mailto:person@example.com');
    assert.equal(links.tel, 'tel:+12345');
    assert.equal(links.external, 'https://outside.example/path?q=1#fragment');
    assert.equal(links.domain, 'https://example.com/path');
    assert.equal(links.network, '');
    assert.equal(new URL(links.deceptive).hostname, 'outside.example');
    assert.equal(links.cleanSelection, true);
    assert.equal(links.cleanText, 'Safe selection');
    assert.equal(links.cleanUnsafe, 0);
    assert.equal(links.collapsed, true);
    assert.equal(links.quoteUnsafe, 0);
    assert.equal(links.crossing, false);
    assert.equal(links.outside, false);

    // Exercise the sanitizer itself, then the exact live HTML insertion path.
    const markdown = await page.evaluate(async functions => {
      const mod = await import('/static/js/markdown.js');
      const markdownModule = mod;
      const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')');
      const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')');
      const payloads = [
        '<script>window.executed++</script><img src="data:,bad" onerror="window.executed++">',
        '<a href="java&#x09;script:window.executed++" onclick="window.executed++">click</a>',
        '<a href="data:text/html,<script>window.executed++</script>">data</a>',
        '<img srcset="/safe.png 1x, data:image/svg+xml,bad 2x" onload="window.executed++">',
        '<svg><foreignObject><img src=x onerror="window.executed++"></foreignObject><script>window.executed++</script></svg>',
        '<math><mtext><img src=x onerror="window.executed++"></mtext></math>',
        '<math><mtext><table><mglyph><style><!--</style><img title="--><img src=x onerror=window.executed++>">',
        '<svg><p><style><g title="</style><img src=x onerror=window.executed++>">',
        '<details><summary onmouseover="window.executed++">Nested</summary><p style="background:url(javascript:window.executed++)"><a href="javascript:window.executed++">bad</a></p></details>',
        '<iframe srcdoc="<script>parent.executed++</script>"></iframe><object data="data:text/html,bad"></object>',
        '<noscript><p title="</noscript><img src=x onerror=window.executed++>">',
        '<a href="jav&#97;script:window.executed++" ONCLICK="window.executed++">entity</a>',
        '<a href="&#x0d;&#x0a;JaVaScRiPt:window.executed++">controls</a>',
        '<a href="vbscript:msgbox(1)">legacy</a><img src="data:image/svg+xml,bad">',
        '<template><img src=x onerror="window.executed++"></template>',
        '<details open ontoggle="window.executed++"><summary>Event</summary><p>Text</p></details>',
        '<svg><a xlink:href="javascript:window.executed++"><text>SVG link</text></a></svg>',
        '<math><annotation-xml encoding="text/html"><img src=x onerror="window.executed++"></annotation-xml></math>',
        '<table><caption><details><summary onclick="window.executed++">Nested</summary><img src=x onerror="window.executed++"></details></caption></table>',
      ];
      const box = document.createElement('div');
      document.body.appendChild(box);
      let unsafe = 0;
      for (const payload of payloads) {
        const clean = mod.sanitizeAllowedHtml(payload);
        if (mod.sanitizeAllowedHtml(clean) !== clean) throw new Error('Sanitizer did not stabilize');
        for (const html of [clean, cleanPaste(payload)]) {
          // Include repeated serialize/reparse boundaries used by chat and paste.
          box.innerHTML = html;
          for (let pass = 0; pass < 3; pass++) box.innerHTML = box.innerHTML;
          unsafe += box.querySelectorAll('script,svg,math,iframe,object,embed,style,base,meta,template,noscript').length;
          for (const el of box.querySelectorAll('*')) for (const attr of el.attributes) {
            const value = attr.value.replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
            if (attr.name.startsWith('on') || attr.name === 'srcdoc'
                || (/^(href|src|srcset|style)$/.test(attr.name) && /javascript:|vbscript:|data:/.test(value))) unsafe++;
          }
          box.querySelectorAll('a').forEach(a => a.click());
        }
      }
      box.innerHTML = mod.sanitizeAllowedHtml('<details><summary>Title</summary><b>Bold</b><a href="https://example.com/path">Link</a></details>');
      await new Promise(resolve => setTimeout(resolve, 100));
      return { count: payloads.length, unsafe, executed: window.executed, bold: box.querySelector('b')?.textContent,
        href: box.querySelector('a')?.href, details: !!box.querySelector('details') };
    }, functions);
    assert.equal(markdown.count, 19);
    assert.equal(markdown.unsafe, 0);
    assert.equal(markdown.executed, 0);
    assert.equal(markdown.bold, 'Bold');
    assert.equal(markdown.href, 'https://example.com/path');
    assert.equal(markdown.details, true);

    // Run the real print function. Only the OS dialog is replaced; HTML parsing,
    // frame policy and renderMath are the production implementations.
    const print = await page.evaluate(async functions => {
      const mod = await import('/static/js/markdown.js');
      const activeDocId = 'fixture';
      const _isRichTextLang = lang => lang === 'richtext';
      const _isDocxLang = () => false;
      const _getExportBaseName = () => 'Print <test>';
      const _richTextExportCss = () => 'b { font-weight: bold; }';
      const failures = [];
      const uiModule = { showError: text => failures.push(text) };
      let prints = 0;
      const markdownModule = { ...mod, renderMath(container) {
        container.ownerDocument.defaultView.print = () => { prints++; };
        container.ownerDocument.defaultView.focus = () => {};
        return mod.renderMath(container);
      } };
      document.body.insertAdjacentHTML('beforeend', '<select id="doc-language-select"><option>richtext</option></select>');
      const payload = '<b>Printable</b><script>parent.executed++</script>'
        + '<img src="data:,bad" onerror="parent.executed++">'
        + '<svg onload="parent.executed++"></svg>'
        + '<iframe sandbox="allow-scripts allow-same-origin" srcdoc="<script>top.executed++</script><img src=x onerror=top.executed++>"></iframe>'
        + '<a href="javascript:parent.executed++">bad link</a>';
      document.getElementById('doc-editor-textarea').value = payload;
      await eval('(' + functions.exportAsPdf + ')')();
      const frame = document.getElementById('doc-browser-print-frame');
      frame.contentDocument.querySelector('a').click();
      await new Promise(resolve => setTimeout(resolve, 150));
      const result = { prints, failures, sandbox: frame.getAttribute('sandbox'),
        text: frame.contentDocument.querySelector('b')?.textContent,
        title: frame.contentDocument.title, executed: window.executed };
      frame.remove();
      // Without sandbox, the same event/srcdoc payload must execute.
      const control = document.createElement('iframe');
      control.srcdoc = payload;
      document.body.appendChild(control);
      await new Promise(resolve => setTimeout(resolve, 150));
      result.controlExecuted = window.executed;
      control.remove();
      window.executed = 0;
      return result;
    }, functions);
    assert.equal(print.prints, 1);
    assert.deepEqual(print.failures, []);
    assert.equal(print.sandbox, 'allow-same-origin allow-modals');
    assert.equal(print.text, 'Printable');
    assert.equal(print.title, 'Print <test>');
    assert.equal(print.executed, 0);
    assert(print.controlExecuted > 0);

    // Both appendChild findings follow tainted card._md, an ordinary JS
    // property. Rendering and re-rendering must keep that markdown as text.
    const skillPayload = '<img src=x onerror="window.executed++"><svg onload="window.executed++"></svg>';
    await page.route('**/api/skills', route => route.fulfill({ json: { skills: [
      { name: 'user-fixture', source: 'user', status: 'published', confidence: 1,
        description: skillPayload, tags: [skillPayload] },
      { name: 'builtin-fixture', source: 'builtin', status: 'published', confidence: 1,
        description: skillPayload, tags: [skillPayload] },
    ] } }));
    await page.route('**/static/js/skills-pr6503-harness.js', route => route.fulfill({
      contentType: 'application/javascript',
      body: readFileSync('static/js/skills.js', 'utf8') + '\nexport { _mdCache, _expandSkillCard, _toggleSkillEdit, _saveSkillEdit };\n',
    }));
    const skills = await page.evaluate(async payload => {
      document.body.insertAdjacentHTML('beforeend', '<div id="toast"></div><div id="skills-list"></div>');
      const mod = await import('/static/js/skills-pr6503-harness.js');
      mod._mdCache.set('user-fixture', payload);
      mod._mdCache.set('builtin-fixture', payload);
      await mod.loadSkills();
      for (const card of document.querySelectorAll('#skills-list .skill-card')) {
        await mod._expandSkillCard(card, card.dataset.skillName);
      }
      await mod.loadSkills();
      const cards = [...document.querySelectorAll('#skills-list .skill-card')];
      for (const card of cards) await mod._expandSkillCard(card, card.dataset.skillName);
      return { sections: cards.map(card => card.dataset.skillSection).sort(),
        text: cards.map(card => card.querySelector('.skill-md-pre').textContent),
        stored: cards.map(card => card._md),
        descriptions: cards.map(card => card.querySelector('.skill-card-desc').textContent),
        tags: cards.map(card => card.querySelector('.skill-tag-pill').textContent),
        unsafe: document.querySelectorAll('#skills-list img, #skills-list script, #skills-list [onerror], #skills-list [onload]').length,
        executed: window.executed };
    }, skillPayload);
    assert.deepEqual(skills.sections, ['builtin', 'user']);
    assert.deepEqual(skills.text, [skillPayload, skillPayload]);
    assert.deepEqual(skills.stored, [skillPayload, skillPayload]);
    assert.deepEqual(skills.descriptions, [skillPayload, skillPayload]);
    assert.deepEqual(skills.tags, [skillPayload, skillPayload]);
    assert.equal(skills.unsafe, 0);
    assert.equal(skills.executed, 0);

    // Also trace the real API -> cache -> textContent path with a cold cache;
    // the seeded fixtures above exercise the preserved-card rerender path.
    const fetchedSkills = new Set();
    let postedSkillMarkdown;
    await page.route('**/api/skills/*/markdown', route => {
      if (route.request().method() === 'POST') {
        postedSkillMarkdown = route.request().postDataJSON().markdown;
        return route.fulfill({ json: {} });
      }
      fetchedSkills.add(decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]));
      return route.fulfill({ json: { markdown: skillPayload } });
    });
    const coldSkills = await page.evaluate(async () => {
      const mod = await import('/static/js/skills-pr6503-harness.js');
      mod._mdCache.clear();
      document.querySelectorAll('#skills-list .skill-card').forEach(card => card.remove());
      await mod.loadSkills();
      const cards = [...document.querySelectorAll('#skills-list .skill-card')];
      for (const card of cards) await mod._expandSkillCard(card, card.dataset.skillName);
      await new Promise(resolve => setTimeout(resolve, 100));
      return { text: cards.map(card => card.querySelector('.skill-md-pre').textContent),
        stored: cards.map(card => card._md),
        unsafe: document.querySelectorAll('#skills-list img, #skills-list script, #skills-list [onerror], #skills-list [onload]').length,
        executed: window.executed };
    });
    assert.deepEqual([...fetchedSkills].sort(), ['builtin-fixture', 'user-fixture']);
    assert.deepEqual(coldSkills.text, [skillPayload, skillPayload]);
    assert.deepEqual(coldSkills.stored, [skillPayload, skillPayload]);
    assert.equal(coldSkills.unsafe, 0);
    assert.equal(coldSkills.executed, 0);

    // The alert's actual source is the edit textarea at skills.js:1312.
    const editedPayload = '" & <script>window.executed++</script>' + skillPayload;
    const editedSkill = await page.evaluate(async payload => {
      const mod = await import('/static/js/skills-pr6503-harness.js');
      const card = document.querySelector('[data-skill-name="user-fixture"]');
      if (!card.classList.contains('doclib-card-expanded')) await mod._expandSkillCard(card, 'user-fixture');
      mod._toggleSkillEdit(card, 'user-fixture');
      card.querySelector('.skill-md-editor').value = payload;
      await mod._saveSkillEdit(card, 'user-fixture');
      const restored = document.querySelector('[data-skill-name="user-fixture"]');
      await mod._expandSkillCard(restored, 'user-fixture');
      await new Promise(resolve => setTimeout(resolve, 100));
      return { text: restored.querySelector('.skill-md-pre').textContent, stored: restored._md,
        unsafe: restored.querySelectorAll('img,script,[onerror],[onload]').length, executed: window.executed };
    }, editedPayload);
    assert.equal(postedSkillMarkdown, editedPayload);
    assert.equal(editedSkill.text, editedPayload);
    assert.equal(editedSkill.stored, editedPayload);
    assert.equal(editedSkill.unsafe, 0);
    assert.equal(editedSkill.executed, 0);

    // #767 reparses the instruction read from a rendered message's textContent.
    // Exercise the real addMessage path in every selected browser too.
    const chat = await page.evaluate(async () => {
      document.body.insertAdjacentHTML('beforeend',
        '<div id="sidebar"></div><div id="chat-container"></div><div id="chat-history"></div>');
      const { addMessage } = await import('/static/js/chatRenderer.js');
      const payloads = [
        '<img src=x onerror="window.executed++">',
        '<details><summary>Nested</summary><a href="java&#x09;script:window.executed++">bad</a><img src=x onerror="window.executed++"></details>',
        '<math><mtext><img src=x onerror="window.executed++"></mtext></math>',
        '<think><svg onload="window.executed++"></svg></think>**Valid** instruction',
      ];
      const refs = [];
      let unsafe = 0;
      for (const payload of payloads) {
        const message = addMessage('user', 'In the document, edit this specific text (lines 1–2):\n```\nselected\n```\n\nInstruction: ' + payload);
        if (!message) throw new Error('addMessage failed');
        refs.push(message.querySelector('.doc-edit-tag')?.dataset.docEditRef);
        unsafe += message.querySelector('.body').querySelectorAll('script,math,svg[onload],[onerror],[onload],a[href^="javascript:"]').length;
      }
      await new Promise(resolve => setTimeout(resolve, 100));
      return { refs, unsafe, executed: window.executed };
    });
    assert.deepEqual(chat.refs, Array(4).fill('lines 1–2'));
    assert.equal(chat.unsafe, 0);
    assert.equal(chat.executed, 0);
    assert.deepEqual(errors, []);
    console.log(JSON.stringify({ ledger: true, email: true, gallery: true, links: true, skills: true,
      sanitizer: true, print: true }));
  } finally {
    await browser.close();
  }
})().catch(error => { console.error(error); process.exit(1); });