Skip to content

Commit c56d83e

Browse files
authored
fix(extension): keep site access compatible with strict CSP (#9524)
1 parent 5f00f19 commit c56d83e

5 files changed

Lines changed: 113 additions & 25 deletions

File tree

‎apps/extension/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sim/extension",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"private": true,
55
"type": "module",
66
"license": "Apache-2.0",

‎apps/extension/scripts/build.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ await writeFile(
107107
{
108108
manifest_version: 3,
109109
name: development ? 'Sim (Development)' : 'Sim',
110-
version: '0.1.0',
110+
version: '0.1.1',
111111
description:
112112
'Ask Sim about the page you are reading, with your existing Sim conversations and tools.',
113113
minimum_chrome_version: '120',

‎apps/extension/scripts/test-e2e.ts‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ try {
128128
await signIn.goto(`${origin}/sign-in`)
129129
await signIn.close()
130130
const panel: Page = await context.newPage()
131+
// Observe dispatch before the bridge so background metadata queries stay outside each action.
132+
await panel.addInitScript(() => {
133+
window.addEventListener('message', () => {
134+
document.documentElement.dataset.queryCountAtDispatch =
135+
document.documentElement.dataset.browserQueryCount ?? '0'
136+
})
137+
})
131138
await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`)
132139
await panel.getByTitle('Sim chat').waitFor()
133140
const frame: Frame = panel.frames()[1] ?? (await panel.waitForEvent('frameattached'))
@@ -588,6 +595,7 @@ try {
588595
callback?: (tabs: chrome.tabs.Tab[]) => void
589596
) => {
590597
queries++
598+
document.documentElement.dataset.browserQueryCount = String(queries)
591599
return callback ? originalQuery(query, callback) : originalQuery(query)
592600
}) as typeof chrome.tabs.query
593601
window.addEventListener('message', (event: MessageEvent<unknown>) => {
@@ -599,24 +607,31 @@ try {
599607
event.data.type !== 'action'
600608
)
601609
return
602-
document.body.dataset.actionQueries = String(queries)
610+
document.body.dataset.actionQueries = String(
611+
queries - Number(document.documentElement.dataset.queryCountAtDispatch ?? '0')
612+
)
603613
document.body.dataset.observedActions = String(++actions)
604614
})
605615
})
616+
await panel.evaluate(() => chrome.tabs.query({ active: true, currentWindow: true }))
606617
const parentCdp = await panel.context().newCDPSession(panel)
607618
const cdp = await panel.context().newCDPSession(app)
608619
await expect
609620
.poll(
610621
async () => {
611-
const result = await parentCdp.send('Runtime.evaluate', {
612-
expression: 'navigator.userActivation.isActive',
613-
userGesture: false,
614-
})
615-
return result.result.value
622+
const results = await Promise.all(
623+
[parentCdp, cdp].map((session) =>
624+
session.send('Runtime.evaluate', {
625+
expression: 'navigator.userActivation.isActive',
626+
userGesture: false,
627+
})
628+
)
629+
)
630+
return results.map((result) => result.result.value)
616631
},
617632
{ timeout: 10_000 }
618633
)
619-
.toBe(false)
634+
.toEqual([false, false])
620635
const action = {
621636
channel: 'sim-extension',
622637
version: 1,
@@ -653,6 +668,55 @@ try {
653668
await parentCdp.detach()
654669
}
655670
)
671+
await check(
672+
'Site access stays modal without violating the packaged security policy',
673+
async () => {
674+
const policyErrors: string[] = []
675+
panel.on('console', (message) => {
676+
if (message.type() === 'error' && message.text().includes('Content Security Policy'))
677+
policyErrors.push(message.text())
678+
})
679+
const openAccess = () =>
680+
app.evaluate(
681+
({ targetOrigin, sessionId }) => {
682+
parent.postMessage(
683+
{
684+
channel: 'sim-extension',
685+
version: 1,
686+
type: 'action',
687+
sessionId,
688+
action: 'site-access',
689+
},
690+
targetOrigin
691+
)
692+
},
693+
{ targetOrigin: `chrome-extension://${extensionId}`, sessionId: receiverSessionId }
694+
)
695+
await panel.bringToFront()
696+
await openAccess()
697+
const dialog = panel.getByRole('dialog', { name: 'Site access' })
698+
await expect(dialog).toBeVisible()
699+
await settleMessages()
700+
assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`)
701+
await panel.evaluate(() => document.querySelector('iframe')?.focus())
702+
assert(
703+
await dialog.evaluate((element) => element.contains(document.activeElement)),
704+
'The hosted frame accepted focus behind the modal'
705+
)
706+
await dialog.getByRole('button', { name: 'Close' }).press('Escape')
707+
await expect(dialog, 'Escape closes the modal').toBeHidden()
708+
await openAccess()
709+
await expect(dialog).toBeVisible()
710+
await dialog.getByRole('button', { name: 'Close' }).click()
711+
await expect(dialog, 'The Close button dismisses the modal').toBeHidden()
712+
await openAccess()
713+
await expect(dialog).toBeVisible()
714+
await dialog.click({ trial: true })
715+
await panel.mouse.click(1, 1)
716+
await expect(dialog, 'The backdrop dismisses the modal').toBeHidden()
717+
assert(policyErrors.length === 0, `Site access violated CSP: ${policyErrors.join('\n')}`)
718+
}
719+
)
656720
await mkdir(dirname(output), { recursive: true })
657721
await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true })
658722
} catch (error) {

‎apps/extension/src/sidepanel.tsx‎

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type ExtensionToAppMessage,
55
type PageSnapshot,
66
} from '@sim/browser-extension-protocol'
7-
import { Chip, ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn'
7+
import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn'
88
import { getErrorMessage } from '@sim/utils/errors'
99
import { createRoot } from 'react-dom/client'
1010
import { captureActivePage } from '@/capture'
@@ -16,6 +16,13 @@ interface SidePanelProps {
1616
origin: string
1717
}
1818

19+
/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */
20+
function mountAccessDialog(dialog: HTMLDialogElement | null) {
21+
if (!dialog) return
22+
dialog.showModal()
23+
return () => dialog.close()
24+
}
25+
1926
function SidePanel({ origin }: SidePanelProps) {
2027
const frameRef = useRef<HTMLIFrameElement>(null)
2128
const pendingRef = useRef<PageSnapshot | null>(null)
@@ -279,20 +286,37 @@ function SidePanel({ origin }: SidePanelProps) {
279286
referrerPolicy='no-referrer'
280287
sandbox='allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads'
281288
/>
282-
<ChipModal open={accessOpen} onOpenChange={setAccessOpen} srTitle='Site access' size='sm'>
283-
<ChipModalHeader onClose={() => setAccessOpen(false)}>Site access</ChipModalHeader>
284-
<ChipModalBody>
285-
{site ? (
286-
<Chip className='max-w-full' onClick={() => void toggleSite()}>
287-
{`${site.allowed ? 'Remove access to' : 'Allow access to'} ${new URL(site.origin).hostname}`}
288-
</Chip>
289-
) : (
290-
<p className='text-[var(--text-muted)] text-small'>
291-
Open a web page and click the Sim toolbar icon to manage access.
292-
</p>
293-
)}
294-
</ChipModalBody>
295-
</ChipModal>
289+
{accessOpen && (
290+
<dialog
291+
ref={mountAccessDialog}
292+
aria-labelledby='site-access-title'
293+
className='m-auto max-h-[85vh] w-[90vw] max-w-[440px] overflow-visible border-0 bg-transparent p-0 text-inherit backdrop:bg-black/10 backdrop:backdrop-blur-[2px]'
294+
onCancel={(event) => {
295+
event.preventDefault()
296+
setAccessOpen(false)
297+
}}
298+
onClick={(event) => {
299+
if (event.target === event.currentTarget) setAccessOpen(false)
300+
}}
301+
>
302+
<ChipModalSurface>
303+
<ChipModalHeader onClose={() => setAccessOpen(false)}>
304+
<span id='site-access-title'>Site access</span>
305+
</ChipModalHeader>
306+
<ChipModalBody>
307+
{site ? (
308+
<Chip className='max-w-full' onClick={() => void toggleSite()}>
309+
{`${site.allowed ? 'Remove access to' : 'Allow access to'} ${new URL(site.origin).hostname}`}
310+
</Chip>
311+
) : (
312+
<p className='text-[var(--text-muted)] text-small'>
313+
Open a web page and click the Sim toolbar icon to manage access.
314+
</p>
315+
)}
316+
</ChipModalBody>
317+
</ChipModalSurface>
318+
</dialog>
319+
)}
296320
</main>
297321
)
298322
}

‎bun.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)