- 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 个测试全绿
120 lines
5.3 KiB
TypeScript
120 lines
5.3 KiB
TypeScript
/**
|
|
* 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 }
|
|
}
|