Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions __tests__/markdown-security.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BlockRenderer blocks={secureMarkdownBlocks(parse(markdown))} />)
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![image](file:///etc/passwd)')
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<img src=x onerror=alert(1)>\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('<img src=x onerror=alert(1)>')
expect(element.querySelector('[onerror]')).toBeNull()
})
})
41 changes: 41 additions & 0 deletions __tests__/security-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>',
'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()
})
})
3 changes: 2 additions & 1 deletion components/markdown-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -227,7 +228,7 @@ export function MarkdownPreview({
? DOMPurify.sanitize(content, { ALLOWED_TAGS: [], KEEP_CONTENT: true })
: content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
const normalized = normalizeToMarkdown(clean)
const result = parse(normalized)
const result = secureMarkdownBlocks(parse(normalized))
lastParsedRef.current = content
lastBlocksRef.current = result
lastParseTimeRef.current = parseTickRef.current
Expand Down
15 changes: 9 additions & 6 deletions components/split-preview-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useRef, useEffect, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { safePreviewUrl } from '@/lib/safe-navigation'
import { Icon } from '@iconify/react'

const AgentPanel = dynamic(() => import('@/components/agent-panel').then((m) => m.AgentPanel), {
Expand All @@ -24,14 +25,14 @@ export function SplitPreviewChat({
}
return 0.5
})
const [url, setUrl] = useState(previewUrl)
const [url, setUrl] = useState(() => safePreviewUrl(previewUrl) ?? 'about:blank')
const [inputUrl, setInputUrl] = useState(previewUrl)
const [isDragging, setIsDragging] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)

useEffect(() => {
setUrl(previewUrl)
setUrl(safePreviewUrl(previewUrl) ?? 'about:blank')
setInputUrl(previewUrl)
}, [previewUrl])

Expand Down Expand Up @@ -74,20 +75,22 @@ export function SplitPreviewChat({

const handleRefresh = useCallback(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.location.reload()
iframeRef.current.src = url
}
}, [])
}, [url])

const handleUrlSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
setUrl(inputUrl)
const next = safePreviewUrl(inputUrl)
if (next) setUrl(next)
},
[inputUrl],
)

const handlePopOut = useCallback(() => {
window.open(url, '_blank', 'width=1200,height=800')
const target = safePreviewUrl(url)
if (target) window.open(target, '_blank', 'noopener,noreferrer,width=1200,height=800')
}, [url])

const previewHeight = `calc(${splitRatio * 100}% - 4px)`
Expand Down
2 changes: 1 addition & 1 deletion components/terminal-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function TerminalPane({
)

const createTerminal = useCallback(
async (initialCommand?: string) => {
async function createTerminal(initialCommand?: string) {
if (!isDesktop) return
if (session.terminalId != null) {
try {
Expand Down
2 changes: 1 addition & 1 deletion components/views/kanban-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,7 @@ function saveKanbanData(data: KanbanData) {
}

function genId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
return `${prefix}-${Date.now()}-${crypto.randomUUID()}`
}

function getDueDateStatus(dueDate?: number): 'overdue' | 'soon' | 'ok' | null {
Expand Down
11 changes: 3 additions & 8 deletions context/gateway-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,7 @@ import {

// ─── Connection state machine ────────────────────────────────────────────────
export type ConnectionStatus =
| 'disconnected'
| 'connecting'
| 'authenticating'
| 'connected'
| 'error'
'disconnected' | 'connecting' | 'authenticating' | 'connected' | 'error'

interface PendingRequest {
resolve: (payload: unknown) => void
Expand Down Expand Up @@ -114,7 +110,7 @@ export function GatewayProvider({ children }: { children: React.ReactNode }) {

// Core connect logic
const doConnect = useCallback(
(url: string, credential: string) => {
function doConnect(url: string, credential: string) {
cleanup()
intentionalDisconnectRef.current = false
setError(null)
Expand Down Expand Up @@ -249,8 +245,7 @@ export function GatewayProvider({ children }: { children: React.ReactNode }) {
setSnapshot(snap)

const authInfo = payload.auth as
| { deviceToken?: string; role?: string; scopes?: string[] }
| undefined
{ deviceToken?: string; role?: string; scopes?: string[] } | undefined
const scopes =
authInfo?.scopes ??
(Array.isArray(payload.scopes) ? (payload.scopes as string[]) : []) ??
Expand Down
28 changes: 14 additions & 14 deletions context/local-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,20 +171,6 @@ export function LocalProvider({ children }: { children: ReactNode }) {

const webDirHandleRef = useRef<FileSystemDirectoryHandle | null>(null)

useEffect(() => {
const hasTauriFS = isTauri() && !isIOSTauri()
setDesktop(hasTauriFS)
setLocalMode(true)

if (hasTauriFS) {
const recent = getRecentFolders()
if (recent.length > 0) {
setRootPathState(recent[0])
loadTreeTauri(recent[0])
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps

// ── Tauri tree loader ──
const loadTreeTauri = useCallback(async (root: string) => {
const tree = await tauriInvoke<FileEntry[]>('local_read_tree', { root })
Expand Down Expand Up @@ -215,6 +201,20 @@ export function LocalProvider({ children }: { children: ReactNode }) {
}
}, [])

useEffect(() => {
const hasTauriFS = isTauri() && !isIOSTauri()
setDesktop(hasTauriFS)
setLocalMode(true)

if (hasTauriFS) {
const recent = getRecentFolders()
if (recent.length > 0) {
setRootPathState(recent[0])
loadTreeTauri(recent[0])
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps

// ── Web tree loader ──
const loadTreeWeb = useCallback(async (handle: FileSystemDirectoryHandle) => {
const tree = await webReadTree(handle)
Expand Down
Loading