diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index de1d0e21..97a040d4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,6 +33,8 @@ "connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:", "img-src": "'self' asset: http://asset.localhost data: blob: https:", "style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com", + "style-src-elem": "'self' 'nonce-tolaria-runtime-style' https://fonts.googleapis.com", + "style-src-attr": "'unsafe-inline'", "font-src": "'self' data: https://fonts.gstatic.com", "media-src": "'self' data: blob: https:", "object-src": "'none'" diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 1e50ad21..78d8d3b7 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -1,7 +1,8 @@ import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react' import type { ComponentProps, PropsWithChildren, ReactElement } from 'react' -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import { formatShortcutDisplay } from '../hooks/appCommandCatalog' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' Object.defineProperty(window, 'matchMedia', { writable: true, @@ -34,6 +35,9 @@ const mockEditor = vi.hoisted(() => ({ focus: vi.fn(), setTextCursorPosition: vi.fn(), })) +const blockNoteCreation = vi.hoisted(() => ({ + options: [] as unknown[], +})) // Mock BlockNote components vi.mock('@blocknote/core', () => ({ @@ -61,7 +65,10 @@ let capturedGetItems: ((query: string) => Promise) | null = null vi.mock('@blocknote/react', () => ({ createReactBlockSpec: () => () => ({}), createReactInlineContentSpec: () => ({ render: () => null }), - useCreateBlockNote: () => mockEditor, + useCreateBlockNote: (options: unknown) => { + blockNoteCreation.options.push(options) + return mockEditor + }, FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}, LinkToolbar: ({ children }: PropsWithChildren) => <>{children}, getFormattingToolbarItems: () => [], @@ -210,6 +217,10 @@ function renderEditor(overrides: Partial = {}) { } describe('Editor', () => { + beforeEach(() => { + blockNoteCreation.options = [] + }) + it('shows empty state when no tabs are open', () => { const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' }) const newNoteHint = formatShortcutDisplay({ display: '⌘N' }) @@ -244,6 +255,19 @@ describe('Editor', () => { expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) + it('passes the runtime CSP style nonce into BlockNote and TipTap', () => { + renderEditor({ + tabs: [mockTab], + activeTabPath: mockEntry.path, + }) + + expect(blockNoteCreation.options.at(-1)).toMatchObject({ + _tiptapOptions: { + injectNonce: RUNTIME_STYLE_NONCE, + }, + }) + }) + it('disables native text assistance on the rich editor editable surface', () => { renderEditor({ tabs: [mockTab], diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index ae971652..7ff9dddf 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -5,6 +5,7 @@ import '@blocknote/mantine/style.css' import 'katex/dist/katex.min.css' import { uploadImageFile } from '../hooks/useImageDrop' import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types' import type { NoteListItem } from '../utils/ai-context' import type { FrontmatterValue } from './Inspector' @@ -177,6 +178,7 @@ function useEditorSetup({ const editor = useCreateBlockNote({ schema, uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current), + _tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE }, extensions: [createArrowLigaturesExtension()], }) useFilenameAutolinkGuard(editor) diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index eb260d15..48878bcb 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ReactNode } from 'react' import type { VaultEntry } from '../types' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' const NOTE_DRAG_MIME = 'application/x-laputa-note-path' @@ -11,6 +12,7 @@ const state = vi.hoisted(() => ({ capturedSuggestionProps: {} as Record>, capturedImageDropArgs: null as null | Record, capturedBlockNoteOnChange: null as null | (() => void), + capturedMantineGetStyleNonce: null as null | (() => string), hoverGuardMock: vi.fn(), imageDropState: { isDragOver: false }, linkActivationMock: vi.fn(), @@ -110,7 +112,16 @@ vi.mock('@mantine/core', async () => { const React = await vi.importActual('react') return { MantineContext: React.createContext(null), - MantineProvider: ({ children }: { children?: ReactNode }) => <>{children}, + MantineProvider: ({ + children, + getStyleNonce, + }: { + children?: ReactNode + getStyleNonce?: () => string + }) => { + state.capturedMantineGetStyleNonce = getStyleNonce ?? null + return <>{children} + }, } }) @@ -277,6 +288,7 @@ describe('SingleEditorView', () => { state.capturedSuggestionProps = {} state.capturedImageDropArgs = null state.capturedBlockNoteOnChange = null + state.capturedMantineGetStyleNonce = null state.imageDropState.isDragOver = false state.wikilinkEntriesRef.current = [] mockOpenExternalUrl.mockClear() @@ -469,6 +481,18 @@ describe('SingleEditorView', () => { expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-mantine-color-scheme', 'dark') }) + it('passes the runtime CSP style nonce to Mantine fallback style tags', () => { + render( + , + ) + + expect(state.capturedMantineGetStyleNonce?.()).toBe(RUNTIME_STYLE_NONCE) + }) + it('defers rich-editor change propagation until IME composition ends', async () => { const editor = createEditor() const onChange = vi.fn() diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 2cca2652..c579b733 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -27,6 +27,7 @@ import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personM import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment' import { openExternalUrl } from '../utils/url' import { observeNativeTextAssistanceDisabled } from '../lib/nativeTextAssistance' +import { getRuntimeStyleNonce } from '../lib/runtimeStyleNonce' import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu' import type { VaultEntry } from '../types' import { _wikilinkEntriesRef } from './editorSchema' @@ -91,6 +92,7 @@ function SharedContextBlockNoteView(props: React.ComponentProps undefined} > {view} diff --git a/src/hooks/useCodeMirror.test.ts b/src/hooks/useCodeMirror.test.ts index 7e9f8c51..288f8c48 100644 --- a/src/hooks/useCodeMirror.test.ts +++ b/src/hooks/useCodeMirror.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' +import { EditorView } from '@codemirror/view' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror' const noop = () => {} @@ -31,6 +33,15 @@ describe('useCodeMirror', () => { expect(container.querySelector('.cm-editor')).toBeInTheDocument() }) + it('tags generated CodeMirror style elements with the runtime CSP nonce', () => { + const ref = { current: container } + const { result } = renderHook(() => + useCodeMirror(ref, 'hello world', noopCallbacks), + ) + + expect(result.current.current?.state.facet(EditorView.cspNonce)).toBe(RUNTIME_STYLE_NONCE) + }) + it('calls requestMeasure when laputa-zoom-change event fires', () => { const ref = { current: container } const { result } = renderHook(() => diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 084369bc..5dd5b150 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -4,6 +4,7 @@ import { EditorState } from '@codemirror/state' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight' import { markdownLanguage } from '../extensions/markdownHighlight' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' import { resolveArrowLigatureInput } from '../utils/arrowLigatures' import { zoomCursorFix } from '../extensions/zoomCursorFix' import { nativeTextAssistanceDisabledAttributes } from '../lib/nativeTextAssistance' @@ -145,6 +146,7 @@ export function useCodeMirror( keymap.of([...defaultKeymap, ...historyKeymap]), buildSaveKeymap(callbacksRef), buildBaseTheme(), + EditorView.cspNonce.of(RUNTIME_STYLE_NONCE), EditorView.contentAttributes.of(nativeTextAssistanceDisabledAttributes), markdownLanguage(), frontmatterHighlightTheme(), diff --git a/src/lib/runtimeStyleNonce.ts b/src/lib/runtimeStyleNonce.ts new file mode 100644 index 00000000..6ffb36fd --- /dev/null +++ b/src/lib/runtimeStyleNonce.ts @@ -0,0 +1,6 @@ +export const RUNTIME_STYLE_NONCE = 'tolaria-runtime-style' +export const RUNTIME_STYLE_NONCE_SOURCE = `'nonce-${RUNTIME_STYLE_NONCE}'` + +export function getRuntimeStyleNonce() { + return RUNTIME_STYLE_NONCE +} diff --git a/src/utils/tauriCsp.test.ts b/src/utils/tauriCsp.test.ts index 9b440496..2aefef22 100644 --- a/src/utils/tauriCsp.test.ts +++ b/src/utils/tauriCsp.test.ts @@ -1,11 +1,14 @@ import { readFileSync } from 'node:fs' +import { RUNTIME_STYLE_NONCE_SOURCE } from '../lib/runtimeStyleNonce' describe('Tauri Content Security Policy', () => { - it('keeps broad inline styles available when runtime libraries inject style tags', () => { + it('allows nonce-tagged runtime style elements and React style attributes', () => { const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8')) - const styleSrc = config.app.security.csp['style-src'] as string + const csp = config.app.security.csp as Record - expect(styleSrc).toContain("'unsafe-inline'") - expect(styleSrc).not.toMatch(/'nonce-|sha(256|384|512)-/) + expect(csp['style-src']).toContain("'unsafe-inline'") + expect(csp['style-src-elem']).toContain(RUNTIME_STYLE_NONCE_SOURCE) + expect(csp['style-src-elem']).toContain('https://fonts.googleapis.com') + expect(csp['style-src-attr']).toBe("'unsafe-inline'") }) }) diff --git a/tests/smoke/keyboard-command-routing.spec.ts b/tests/smoke/keyboard-command-routing.spec.ts index 49a8677f..b61cfb5d 100644 --- a/tests/smoke/keyboard-command-routing.spec.ts +++ b/tests/smoke/keyboard-command-routing.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from '@playwright/test' import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog' +import { RUNTIME_STYLE_NONCE } from '../../src/lib/runtimeStyleNonce' import { dispatchShortcutEvent, triggerMenuCommand, @@ -19,6 +20,33 @@ async function openAlphaProjectInEditor(page: Page) { await page.locator('.bn-editor').click() } +function collectRuntimeStyleCspSignals(page: Page): string[] { + const messages: string[] = [] + + page.on('pageerror', (error) => { + messages.push(error.message) + }) + + page.on('console', (message) => { + const text = message.text() + if ( + /Content Security Policy|Refused to apply a stylesheet|Failed to insert placeholder CSS rule|style-src|insertRule/i + .test(text) + ) { + messages.push(text) + } + }) + + return messages +} + +async function expectRuntimeStyleNonce(page: Page): Promise { + await expect.poll(async () => page.evaluate((nonce) => { + const styles = Array.from(document.querySelectorAll('style')) as HTMLStyleElement[] + return styles.some((style) => style.nonce === nonce) + }, RUNTIME_STYLE_NONCE), { timeout: 5_000 }).toBe(true) +} + async function expectPropertiesPanelToggle(page: Page, toggle: () => Promise) { const propertiesButton = page.getByRole('button', { name: 'Open the properties panel' }) await expect(propertiesButton).toBeVisible({ timeout: 5_000 }) @@ -40,14 +68,14 @@ test.describe('keyboard command routing', () => { }) test('desktop menu-command bridge creates a note through the shared command path @smoke', async ({ page }) => { - const errors: string[] = [] - page.on('pageerror', (error) => errors.push(error.message)) + const runtimeStyleCspSignals = collectRuntimeStyleCspSignals(page) await openFixtureVaultDesktopHarness(page, tempVaultDir) await triggerMenuCommand(page, APP_COMMAND_IDS.fileNewNote) await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { timeout: 5_000 }) - expect(errors).toEqual([]) + await expectRuntimeStyleNonce(page) + expect(runtimeStyleCspSignals).toEqual([]) }) test('desktop menu-command bridge toggles the properties panel through the shared command path @smoke', async ({ page }) => { @@ -110,14 +138,18 @@ test.describe('keyboard command routing', () => { }) test('renderer shortcut bridge toggles the raw editor through the shared keyboard handler @smoke', async ({ page }) => { + const runtimeStyleCspSignals = collectRuntimeStyleCspSignals(page) + await openAlphaProjectInEditor(page) await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor) await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) + await expectRuntimeStyleNonce(page) await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor) await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 }) await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + expect(runtimeStyleCspSignals).toEqual([]) }) test('desktop menu-command bridge toggles the AI panel, while the wrong modifier event does not @smoke', async ({ page }) => {