fix(sync): bind sync engine to its user DB, scope instructions/templates per account, harden navigation
This commit is contained in:
parent
1b8fe445f3
commit
9aa7302944
30 changed files with 2614 additions and 386 deletions
46
packages/core/__tests__/url-policy.test.ts
Normal file
46
packages/core/__tests__/url-policy.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
59
packages/core/src/url-policy.ts
Normal file
59
packages/core/src/url-policy.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue