diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ca510a1..89f50d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,22 @@
name: CI
on:
+ workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
+ rust-security-backport:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
+ - run: cargo test --manifest-path src-tauri/vendor/glib/Cargo.toml --locked --release --test variant_str_iter_backport
+ - run: cargo check --manifest-path src-tauri/Cargo.toml --locked
+
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-latest
@@ -30,7 +40,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
- version: latest
+ version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/__tests__/markdown-security.test.tsx b/__tests__/markdown-security.test.tsx
new file mode 100644
index 0000000..63d9385
--- /dev/null
+++ b/__tests__/markdown-security.test.tsx
@@ -0,0 +1,33 @@
+import React from 'react'
+import { describe, it, expect } from 'vitest'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { parse } from 'create-markdown'
+import { BlockRenderer } from 'create-markdown/react'
+import { secureMarkdownBlocks } from '../lib/markdown-security'
+
+function render(markdown: string) {
+ const element = document.createElement('div')
+ element.innerHTML = renderToStaticMarkup()
+ return element
+}
+
+describe('markdown renderer URL boundary', () => {
+ it.each(['data:text/html,payload', 'file:///etc/passwd', 'vbscript:alert', 'javascript:alert', 'https://user:password@example.com'])('removes active link destination %s while preserving text', (url) => {
+ const element = render(`[label](${url})`)
+ expect(element.querySelector('a')).toBeNull()
+ expect(element.textContent).toBe('label')
+ })
+ it('filters links in nested lists and image sources', () => {
+ const element = render('- [outer](file:///etc/passwd)\n - [inner](data:text/html,payload)\n\n')
+ expect(element.querySelector('a')).toBeNull()
+ expect(element.querySelector('img')?.getAttribute('src')).toBeNull()
+ })
+ it('preserves ordinary navigation, formatting and code examples', () => {
+ const element = render('# Heading\n\n**bold** and [site](https://example.com/path) [local](./guide.md) [mail](mailto:a@example.com)\n\n```html\n
\n```')
+ expect(element.querySelector('h1')?.textContent).toBe('Heading')
+ expect(element.querySelector('strong')?.textContent).toBe('bold')
+ expect(element.querySelectorAll('a')).toHaveLength(3)
+ expect(element.querySelector('pre code')?.textContent).toBe('
')
+ expect(element.querySelector('[onerror]')).toBeNull()
+ })
+})
diff --git a/__tests__/security-navigation.test.ts b/__tests__/security-navigation.test.ts
new file mode 100644
index 0000000..4742e3a
--- /dev/null
+++ b/__tests__/security-navigation.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { safePreviewUrl, safeSpotifyHandoff } from '../lib/safe-navigation'
+import { storeDeviceToken, loadDeviceToken } from '../lib/device-auth'
+
+describe('untrusted navigation and token keys', () => {
+ beforeEach(() => localStorage.clear())
+ it('permits HTTP development previews and rejects executable schemes', () => {
+ expect(safePreviewUrl('http://localhost:3000/page')).toBe('http://localhost:3000/page')
+ for (const value of [
+ 'javascript:alert(1)',
+ 'data:text/html,',
+ 'file:///etc/passwd',
+ 'https://user:pass@example.com',
+ 'invalid',
+ ]) {
+ expect(safePreviewUrl(value)).toBeNull()
+ }
+ })
+ it('limits handoffs to the Spotify authorization endpoint', () => {
+ expect(safeSpotifyHandoff('https://accounts.spotify.com/authorize?client_id=example')).toBe(
+ 'https://accounts.spotify.com/authorize?client_id=example',
+ )
+ for (const value of [
+ 'https://accounts.spotify.com.attacker.test/authorize',
+ 'https://attacker.test/accounts.spotify.com',
+ 'javascript:alert(1)',
+ 'https://accounts.spotify.com/other',
+ 'https://user@accounts.spotify.com/authorize',
+ ]) {
+ expect(safeSpotifyHandoff(value)).toBeNull()
+ }
+ })
+ it('stores hostile property names as own data without modifying prototypes', () => {
+ storeDeviceToken('__proto__', 'role', 'example', [])
+ expect(loadDeviceToken('__proto__', 'role')).toBe('example')
+ expect(Object.hasOwn(Object.prototype, 'role')).toBe(false)
+ storeDeviceToken('device', '__proto__', 'second', [])
+ expect(loadDeviceToken('device', '__proto__')).toBe('second')
+ expect(loadDeviceToken('toString', 'role')).toBeNull()
+ })
+})
diff --git a/components/markdown-preview.tsx b/components/markdown-preview.tsx
index aa1b336..084fd6b 100644
--- a/components/markdown-preview.tsx
+++ b/components/markdown-preview.tsx
@@ -4,6 +4,7 @@ import { useMemo, useState, useRef, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import DOMPurify from 'dompurify'
import { parse } from 'create-markdown'
+import { secureMarkdownBlocks } from '@/lib/markdown-security'
import { BlockRenderer } from 'create-markdown/react'
import { copyToClipboard } from '@/lib/clipboard'
import { installAbortErrorSuppression, isAbortError } from '@/lib/abort-error'
@@ -227,7 +228,7 @@ export function MarkdownPreview({
? DOMPurify.sanitize(content, { ALLOWED_TAGS: [], KEEP_CONTENT: true })
: content.replace(/