fix: allow editor runtime styles under CSP

This commit is contained in:
lucaronin
2026-04-26 11:31:41 +02:00
parent 133242eff5
commit f58e9d8930
10 changed files with 118 additions and 10 deletions

View File

@@ -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'"

View File

@@ -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<any[]>) | 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<EditorComponentProps> = {}) {
}
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],

View File

@@ -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)

View File

@@ -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<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
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<typeof import('react')>('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(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
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()

View File

@@ -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<typeof BlockNote
<MantineProvider
// BlockNote scopes Mantine defaults under `.bn-mantine` instead of `:root`.
withCssVariables={false}
getStyleNonce={getRuntimeStyleNonce}
getRootElement={() => undefined}
>
{view}

View File

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

View File

@@ -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(),

View File

@@ -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
}

View File

@@ -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<string, string>
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'")
})
})

View File

@@ -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<void> {
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<void>) {
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 }) => {