diff --git a/.changeset/prototype-shadow-dom-isolation.md b/.changeset/prototype-shadow-dom-isolation.md new file mode 100644 index 00000000..6c11fe80 --- /dev/null +++ b/.changeset/prototype-shadow-dom-isolation.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': major +'@youversion/platform-react-hooks': major +'@youversion/platform-react-ui': major +--- + +Prototype automatic Shadow DOM style isolation on `YouVersionAuthButton` so host-page selectors cannot override its internal styles. diff --git a/docs/adr/0005-prototype-shadow-dom-style-isolation.md b/docs/adr/0005-prototype-shadow-dom-style-isolation.md new file mode 100644 index 00000000..6baa48b2 --- /dev/null +++ b/docs/adr/0005-prototype-shadow-dom-style-isolation.md @@ -0,0 +1,93 @@ +# ADR 0005: Prototype automatic Shadow DOM style isolation + +Status: Proposed proof of concept + +## Problem + +Host applications can apply unlayered global rules such as `button { ... }` or +Tailwind v3 preflight to SDK markup. Unlayered author CSS outranks the SDK's +layered CSS, so selector specificity alone cannot guarantee isolation. + +Resets, stronger selectors, `!important`, cascade layers, and `@scope` all +continue participating in the host document's cascade. They can reduce +accidental conflicts but cannot prevent an outside selector from matching SDK +internals. Shadow DOM was selected because it creates a browser-enforced +selector boundary. + +## Prototype + +`YouVersionAuthButton` automatically creates an open shadow root and renders its +existing implementation inside it through a React portal. The SDK's compiled +Tailwind CSS—generated from `src/styles/global.css` and embedded as +`__YV_STYLES__`—is installed inside that root. Consumers continue to write +``; isolation is not an option they must discover or +enable. + +This PR intentionally applies the architecture to one representative component. +It asks whether automatic Shadow DOM boundaries are the right foundation before +the same pattern is rolled out across the UI package. + +The constructable stylesheet is cached per owner `Document`, because a sheet +created in the top-level document cannot be adopted by a shadow root rendered in +a same-origin iframe. Browsers without constructable stylesheets receive a +` + ))} + +
+

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: () =>