fix: allow editor runtime styles under CSP
This commit is contained in:
@@ -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'"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
6
src/lib/runtimeStyleNonce.ts
Normal file
6
src/lib/runtimeStyleNonce.ts
Normal 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
|
||||
}
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user