fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 1b8fe445f3
commit 9aa7302944
30 changed files with 2614 additions and 386 deletions

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isAllowedExternalUrl, isAppOrigin } from '../src/url-policy'
describe('isAllowedExternalUrl', () => {
it('allows https and mailto by default', () => {
expect(isAllowedExternalUrl('https://d3ro.chanpaca.net/app/billing')).toBe(true)
expect(isAllowedExternalUrl('mailto:help@example.com')).toBe(true)
})
it.each([
'file:///C:/Windows/System32/calc.exe',
'javascript:alert(1)',
'search-ms:query=x&crumb=location:\\\\evil\\share',
'ms-msdt:/id PCWDiagnostic',
'\\\\evil.example\\share\\payload.exe',
'//evil.example/x',
'd3ro-voice://auth-callback#access_token=x',
'http://example.com',
'https://user:pass@example.com',
'',
'not a url',
])('rejects %s', (url) => {
expect(isAllowedExternalUrl(url)).toBe(false)
})
it('allows extra exact origins (dev web app) without widening schemes', () => {
const options = { allowOrigins: ['http://localhost:3000'] }
expect(isAllowedExternalUrl('http://localhost:3000/app/billing?tier=pro', options)).toBe(true)
expect(isAllowedExternalUrl('http://localhost:3001/app', options)).toBe(false)
expect(isAllowedExternalUrl('http://evil.example', options)).toBe(false)
})
})
describe('isAppOrigin', () => {
it('matches the packaged file renderer and the dev server origin only', () => {
const origins = ['file://', 'http://localhost:5173']
expect(isAppOrigin('file:///C:/app/resources/app.asar/out/renderer/index.html#/meetings', origins)).toBe(true)
expect(isAppOrigin('http://localhost:5173/#/settings', origins)).toBe(true)
expect(isAppOrigin('http://localhost:5174/', origins)).toBe(false)
expect(isAppOrigin('https://evil.example/', origins)).toBe(false)
})
it('does not treat file: as app origin unless allowed', () => {
expect(isAppOrigin('file:///C:/x.html', ['http://localhost:5173'])).toBe(false)
})
})

View file

@ -0,0 +1,59 @@
// packages/core/src/url-policy.ts
// 외부 URL·내비게이션 정책 SSOT. 순수 함수만 둔다 — Electron 어댑터는 데스크톱 main이 맡는다.
//
// - 앱 밖으로 넘기는 URL(OS 기본 핸들러)은 허용 scheme만 연다. file:, UNC, ms-*, search-ms: 같은
// OS 핸들러는 원격 코드 실행 경로가 되므로 막는다.
// - 앱 창은 앱 자신의 오리진 밖으로 이동하지 않는다(원격 페이지가 preload API를 얻지 못하게).
export interface ExternalUrlPolicyOptions {
/** 허용할 scheme(콜론 포함, 소문자). 기본 https:·mailto: */
allowSchemes?: readonly string[]
/** 추가로 허용할 정확한 오리진(예: 개발용 http://localhost:3000). */
allowOrigins?: readonly string[]
}
export const DEFAULT_EXTERNAL_SCHEMES: readonly string[] = ['https:', 'mailto:']
function parse(url: string): URL | null {
if (typeof url !== 'string' || url.trim() === '' || url.length > 8192) return null
// 백슬래시로 시작하는 UNC(\\server\share)는 URL 파서가 상대 경로로 보지 않도록 먼저 거른다.
if (url.startsWith('\\\\') || url.startsWith('//')) return null
try {
return new URL(url)
} catch {
return null
}
}
/** OS 기본 핸들러(브라우저·메일)로 넘겨도 되는 URL인지. */
export function isAllowedExternalUrl(url: string, options: ExternalUrlPolicyOptions = {}): boolean {
const parsed = parse(url)
if (!parsed) return false
const origin = parsed.origin
if (options.allowOrigins?.some((allowed) => allowed === origin)) return true
const schemes = options.allowSchemes ?? DEFAULT_EXTERNAL_SCHEMES
if (!schemes.includes(parsed.protocol.toLowerCase())) return false
if (parsed.protocol === 'https:') {
// 자격 증명이 박힌 URL(https://user@evil)이나 호스트 없는 URL은 열지 않는다.
if (!parsed.hostname || parsed.username || parsed.password) return false
}
return true
}
/**
* URL이 앱 자신의 페이지인지. appOrigins는 'file://' 또는 'http://localhost:5173' 같은 오리진.
* file: 은 오리진이 'null'이라 scheme으로 비교한다.
*/
export function isAppOrigin(url: string, appOrigins: readonly string[]): boolean {
const parsed = parse(url)
if (!parsed) return false
for (const allowed of appOrigins) {
if (allowed === 'file://') {
if (parsed.protocol === 'file:') return true
continue
}
const allowedParsed = parse(allowed)
if (allowedParsed && allowedParsed.origin === parsed.origin) return true
}
return false
}