From 69841bf0ec75d50ed08dad9cb81c1de771e75fd0 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Wed, 19 Aug 2026 10:50:44 -0700 Subject: [PATCH] feat(ui): prototype automatic Shadow DOM style isolation (#347) * feat(ui)!: prototype automatic Shadow DOM isolation * docs(examples): add hostile CSS POC demo * docs(examples): clarify hostile CSS vectors * docs(ui): align shadow DOM prototype ADR * test(ui): strengthen shadow isolation proof * refactor(ui): remove shadow isolation type assertions * fix(ui): block hostile shadow host pseudo-elements * docs(ui): clarify shadow isolation trade-offs * test(ui): align shadow isolation demo conventions * docs(examples): keep hostile demo labels readable * fix(ui): use style precedence for shadow fallback stylesheet Addresses PR review feedback: the non-adoptedStyleSheets fallback rendered a bare + ))} + +
+

Automatic Shadow DOM isolation POC

+

+ This branch automatically isolates only YouVersionAuthButton. The plain host + controls are positive witnesses: they should look broken when an attack is active, while + the SDK button should remain stable. The font-face option demonstrates a known Shadow DOM + limitation. +

+ +
+ Hostile stylesheet vectors + {HOSTILE_VECTORS.map((vector) => ( +
+ + + {vector.example} + +
+ ))} +
+
+ +
+
+

LIGHT DOM — SHOULD BE AFFECTED

+
+ +

Plain host text for inherited-property attacks.

+
+ Host-box witness — this should disappear during the host attack. +
+
+ Pseudo-element witness — generated content should appear above this text. +
+

+ Host text requesting Inter for the font-face collision. +

+
+
+ +
+

SDK POC — SHOULD RESIST

+
+ console.error('Auth error:', error)} + /> +
+

+ Other SDK components are intentionally absent: automatic isolation has not been rolled + out to them on this POC branch. +

+
+
+ + ); +} diff --git a/packages/ui/src/components/YouVersionAuthButton.shadow-isolation.stories.tsx b/packages/ui/src/components/YouVersionAuthButton.shadow-isolation.stories.tsx new file mode 100644 index 00000000..95fb05bd --- /dev/null +++ b/packages/ui/src/components/YouVersionAuthButton.shadow-isolation.stories.tsx @@ -0,0 +1,170 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { http, HttpResponse } from 'msw'; +import { createRoot } from 'react-dom/client'; +import { expect, waitFor } from 'storybook/test'; +import { ShadowRootHost } from '../lib/shadow-root-host'; +import { YouVersionAuthButton } from './YouVersionAuthButton'; + +/** + * Focused architectural POC coverage. These stories answer two questions: + * whether a literal global `button {}` rule can change an automatically + * isolated SDK button, and whether the document-bound constructed stylesheet + * works when a shadow host mounts in a same-origin iframe. Package-wide hostile + * vectors and component-specific behavior are deliberately deferred. + */ +const HOSTILE_CSS = ` + button { + appearance: none !important; + background: rgb(185, 28, 28) !important; + border: 10px dashed lime !important; + color: yellow !important; + font: 32px/1 fantasy !important; + padding: 40px !important; + text-transform: uppercase !important; + } + + [data-yv-shadow-host]::before, + [data-yv-shadow-host]::after, + [data-host-pseudo-control]::before { + content: "HOSTILE" !important; + display: block !important; + background: red !important; + } +`; + +const meta = { + title: 'Spikes/Automatic Shadow DOM isolation', + component: YouVersionAuthButton, + parameters: { + msw: { + handlers: [ + http.get('*/v1/fonts/1/stylesheet', () => + HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }), + ), + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function buttonStyleSnapshot(button: HTMLButtonElement) { + const ownerWindow = button.ownerDocument.defaultView; + if (!ownerWindow) throw new Error('button owner window not available'); + const styles = ownerWindow.getComputedStyle(button); + return { + appearance: styles.appearance, + backgroundColor: styles.backgroundColor, + borderTopColor: styles.borderTopColor, + borderTopStyle: styles.borderTopStyle, + borderTopWidth: styles.borderTopWidth, + color: styles.color, + display: styles.display, + fontFamily: styles.fontFamily, + fontSize: styles.fontSize, + lineHeight: styles.lineHeight, + padding: styles.padding, + textTransform: styles.textTransform, + }; +} + +export const HostileGlobalButtonRule: Story = { + tags: ['integration'], + render: () => ( +
+ + +
+ ), + play: async ({ canvasElement }) => { + const ownerDocument = canvasElement.ownerDocument; + const ownerWindow = ownerDocument.defaultView; + if (!ownerWindow) throw new Error('story owner window not available'); + + const control = await waitFor(() => { + const element = canvasElement.querySelector( + '[data-testid="host-control"]', + ); + if (!element) throw new Error('host control not rendered'); + return element; + }); + const host = await waitFor(() => { + const element = canvasElement.querySelector('[data-yv-shadow-host]'); + if (!element?.shadowRoot) throw new Error('shadow root not attached'); + return element; + }); + const sdkButton = await waitFor(() => { + const element = host.shadowRoot?.querySelector( + '[data-testid="sdk-button"]', + ); + if (!element) throw new Error('SDK button not rendered'); + return element; + }); + + const sdkBaseline = buttonStyleSnapshot(sdkButton); + // Guard against a false positive where an unstyled browser-default button + // also happens not to equal the hostile values below. + void expect(sdkBaseline.display).toBe('flex'); + void expect(sdkBaseline.fontFamily).toContain('Inter'); + + const style = ownerDocument.createElement('style'); + style.textContent = HOSTILE_CSS; + + try { + ownerDocument.head.append(style); + + await waitFor(() => { + void expect(ownerWindow.getComputedStyle(control).backgroundColor).toBe('rgb(185, 28, 28)'); + void expect(ownerWindow.getComputedStyle(control, '::before').content).toBe('"HOSTILE"'); + }); + + void expect(ownerWindow.getComputedStyle(host, '::before').content).toBe('none'); + void expect(ownerWindow.getComputedStyle(host, '::before').display).toBe('none'); + void expect(ownerWindow.getComputedStyle(host, '::after').content).toBe('none'); + void expect(ownerWindow.getComputedStyle(host, '::after').display).toBe('none'); + + // The complete relevant style snapshot—not merely a few negative values— + // must remain identical to the pre-attack SDK baseline. + void expect(buttonStyleSnapshot(sdkButton)).toEqual(sdkBaseline); + } finally { + style.remove(); + } + }, +}; + +export const SameOriginIframeDocument: Story = { + tags: ['integration'], + parameters: { includeAuth: false }, + render: () =>