Files
apes-sidebar/src/client/browser.ts
T
Menghuan1918 302c20109f 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 个测试全绿
2026-08-11 19:28:48 +08:00

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 }
}