feat: 内置 HTML 预览器 + 沙箱浏览器 tab(v0.5.0)
- html viewer:.html/.htm 沙箱 iframe 预览(相对资源经 /sidebar/html 路径编码路由加载,CSP sandbox + 信任围栏 + cwd 边界),预览/编辑切换 - browser tab:+ 菜单多开(browser:<n>),地址栏后退/前进/刷新/在浏览器中打开(15px 图标),URL 与标题按会话持久化(patchTab + nextBrowser) - 安全:iframe 一律不透明源沙箱(无 allow-same-origin/top-navigation、no-referrer、权限策略全禁、禁 srcdoc);地址栏仅 http(s)、拒绝 localhost 等本机地址;界面实时沙箱状态(关闭红色警示)+ 临时解锁/恢复 - 设置:htmlViewerNoSandbox / htmlViewerDefaultUnsafe / browserNoSandbox / browserInterceptLinks 四个声明式二级设置(默认安全) - 外链拦截:聊天/界面 http(s) 外链默认在侧边栏打开(openTab seed.url),Ctrl/Cmd 点击放行 - 嵌入拒绝面板:browser.probe 路由探测 X-Frame-Options/frame-ancestors,被拒站点显示原因 + 在浏览器中打开/仍然加载 - 设置页可滚动;内置 7 tab + 9 viewer;新增设计文档;268 个测试全绿
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Pure URL policy for the built-in browser tab: normalize user input into
|
||||
* an http(s) URL, and refuse destinations that would be dangerous to embed
|
||||
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
|
||||
*
|
||||
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
|
||||
* is the primary security boundary; this module is the address-bar gate on
|
||||
* top of it: only http/https may be navigated, and loopback addresses are
|
||||
* refused so a browsed page cannot probe local services by user action.
|
||||
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
|
||||
* itself in the sidebar (debugging, mirroring); the sandbox still renders
|
||||
* it in an opaque origin with no same-origin privileges, exactly like any
|
||||
* other site.
|
||||
*/
|
||||
|
||||
/** Why a navigation attempt was refused. */
|
||||
export type BrowserBlockReason = 'scheme' | 'loopback'
|
||||
|
||||
/** Result of normalizing one address-bar input. */
|
||||
export type BrowserNavigateResult =
|
||||
| { kind: 'ok'; url: string }
|
||||
| { kind: 'blocked'; reason: BrowserBlockReason }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
/** One browser.probe wire result (host fetch of the target's headers). */
|
||||
export interface BrowserProbeResult {
|
||||
reachable: boolean
|
||||
/** The final (post-redirect) URL; present when reachable. */
|
||||
url?: string
|
||||
status?: number
|
||||
xFrameOptions?: string
|
||||
/** The CSP frame-ancestors source list; present when the directive exists. */
|
||||
frameAncestors?: string[]
|
||||
}
|
||||
|
||||
/** Embeddability verdict of one probe. */
|
||||
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
|
||||
|
||||
/**
|
||||
* Decide whether a site can render inside the sidebar iframe. The signals
|
||||
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
|
||||
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
|
||||
* not allow `*` ('self' here means the SITE's own origin — never ours, so
|
||||
* it also blocks the sidebar). A site we could not reach yields 'unknown'
|
||||
* and the plain iframe stays.
|
||||
*/
|
||||
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
|
||||
if (probe.reachable !== true) return 'unknown'
|
||||
const xfo = probe.xFrameOptions?.trim().toUpperCase()
|
||||
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
|
||||
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
|
||||
return 'embeddable'
|
||||
}
|
||||
|
||||
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
|
||||
const parts = host.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one address-bar input against the navigation policy.
|
||||
* @param input - raw user text.
|
||||
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
|
||||
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
|
||||
* is let through BEFORE the loopback check — its host is normally loopback.
|
||||
*/
|
||||
/** Schemes that must never reach the iframe, even without `//` (javascript:,
|
||||
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
|
||||
* they parse as hosts below. */
|
||||
const FORBIDDEN_SCHEMES = new Set([
|
||||
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
|
||||
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
|
||||
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
|
||||
])
|
||||
|
||||
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed === '') return { kind: 'invalid' }
|
||||
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
|
||||
// would match a naive scheme regex (dots are legal in schemes), so a
|
||||
// scheme prefix is only honored when it is http(s) or a known-forbidden
|
||||
// scheme; anything else is treated as a host and gets https://.
|
||||
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
|
||||
let withScheme: string
|
||||
if (schemeMatch === null) {
|
||||
withScheme = `https://${trimmed}`
|
||||
} else {
|
||||
const scheme = schemeMatch[1]!.toLowerCase()
|
||||
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
|
||||
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
|
||||
else withScheme = `https://${trimmed}`
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(withScheme)
|
||||
} catch {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
// The protocol backstop: any URL that still parses to a non-http(s)
|
||||
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
|
||||
// refused here.
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
|
||||
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
|
||||
// the sidebar; the sandbox renders it in an opaque origin like any other
|
||||
// site). It must be checked before the loopback gate because its host is
|
||||
// normally loopback.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
|
||||
}
|
||||
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
|
||||
return { kind: 'ok', url: url.href }
|
||||
}
|
||||
Reference in New Issue
Block a user