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('