From 254278bac970ece63151a45d23e9b2da6c7fa1e0 Mon Sep 17 00:00:00 2001 From: Rachel Petry <287457313+rachel-petry@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:33:25 -0500 Subject: [PATCH] fix(deploy): copy error text verbatim, never re-parse it as HTML The Copy error button set a detached div's innerHTML to data-copy and copied its textContent. The browser has already decoded the escaped attribute, so this parsed the raw error text as HTML a second time: it stripped <...> from copied stack traces and let markup in an error run (e.g. ). Copy this.dataset.copy directly in both src/deploy/index.js and the inlined copy in deploy/index.html. Replace the test that asserted the innerHTML handler, guard the inlined copy against regressing, and add a Playwright test that clicks Copy on a stack trace containing ' + + '' + + // Stub the clipboard so the test can read what was written. + await page.evaluate(() => { + window.__copied = [] + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: (text) => { + window.__copied.push(text) + return Promise.resolve() + }, + }, + }) + }) + + await page.evaluate((errStack) => { + const err = new Error('bad ') + err.stack = errStack + showStatus('error', buildDeployErrorHtml(err)) + }, stack) + + await page.locator('.status-error details summary').click() + await page.locator('.status-error .copy-btn').click() + await expect.poll(() => page.evaluate(() => window.__copied.length)).toBe(1) + + expect(await page.evaluate(() => window.__copied[0])).toBe(stack) + + // Control: a detached with this payload does fire onerror here, so a + // re-parse would have set __pwned before __control is set. + await page.evaluate(() => { + const d = document.createElement('div') + d.innerHTML = '' + }) + await page.waitForFunction(() => window.__control === 1) + expect(await page.evaluate(() => window.__pwned)).toBeUndefined() + }) + // ── showStatus type classes ───────────────────────────────────────────────── test('error sign-in response shows status-error class', async ({ page }) => { diff --git a/src/deploy/index.js b/src/deploy/index.js index 4d3217c3..f4951926 100644 --- a/src/deploy/index.js +++ b/src/deploy/index.js @@ -315,8 +315,10 @@ function buildDeployErrorHtml(err) { `
` + `Error detail` + `
${safeErrorDetail}
` + + // The browser decodes the escaped attribute, so dataset.copy is already the + // original text. Never re-parse it as HTML (#595). `` + `
` diff --git a/src/deploy/tests/index.test.js b/src/deploy/tests/index.test.js index 7f54558f..0e2ff5fc 100644 --- a/src/deploy/tests/index.test.js +++ b/src/deploy/tests/index.test.js @@ -1,5 +1,7 @@ 'use strict' +const fs = require('fs') +const path = require('path') const { APPS_SCRIPT_API_BASE, getScriptCatalog, @@ -714,16 +716,30 @@ describe('buildDeployErrorHtml', () => { expect(html).toContain('copy-btn') }) - test('copy button decodes HTML entities via innerHTML before writing to clipboard', () => { + test('copy button writes data-copy verbatim and never re-parses it as HTML', () => { const err = new Error('') const html = buildDeployErrorHtml(err) - // data-copy must hold the HTML-escaped value so quotes in the attribute are safe + // data-copy holds the HTML-escaped value so the attribute is safe to insert expect(html).toContain('data-copy="') expect(html).toContain('<script>') - // onclick must use div.innerHTML decode trick so the clipboard receives unescaped text - expect(html).toContain('d.innerHTML=this.dataset.copy') - expect(html).toContain('d.textContent') + expect(html).not.toContain('