/** * The built-in browser tab: an address bar plus a sandboxed iframe. * * Security model (see browser.ts + the sandbox tokens below): the iframe is * ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited * page can never sit on the GUI's origin, read its storage, or reach * /sidebar/api) and without `allow-top-navigation` (a page must not hijack * the GUI). The address bar only accepts http(s) and refuses loopback / * the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the * sandbox attribute entirely for fully trusted sites — the visited page then * runs with the GUI's own origin and full session access, so a persistent * warning bar renders while it is off. * * The URL is persisted onto the tab (path/title via the patchTab reducer) * so a reload restores the visited page; the back/forward stack only tracks * address-bar navigations (in-frame link clicks are cross-origin and * invisible — a documented limitation). */ import { useEffect, useState } from 'react' import { IconChevronLeftOutline14, IconChevronRightOutline14, IconLinkOutline14, IconRefreshOutline14, IconWarningOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import { VscLinkExternal } from 'react-icons/vsc' import { api } from './api.ts' import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts' import { patchTab } from './state.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { t } from './locales.ts' import type { TabComponentProps } from './service.ts' import css from './sidebar.module.css' /** * The browser iframe sandbox tokens. NO allow-same-origin (opaque origin — * no GUI storage/API access), NO allow-top-navigation (a browsed page must * not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals * keep login flows working; allow-popups-to-escape-sandbox lets OAuth * popups open as normal tabs (they are cross-origin to the GUI either way). */ export const BROWSER_IFRAME_SANDBOX = 'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox' /** allow-same-origin appended for explicitly allowlisted local addresses. */ const BROWSER_IFRAME_SANDBOX_SAME_ORIGIN = `${BROWSER_IFRAME_SANDBOX} allow-same-origin` /** * The sandbox tokens for one URL: allowlisted loopback addresses (local dev * servers the user explicitly trusts) additionally get `allow-same-origin` * so Vite/module/HMR pipelines that need a real origin work; every other * site keeps the opaque-origin sandbox. `allow-same-origin` does NOT give * the page access to the GUI — it stays cross-origin to it and to every * other site — but it does give it its OWN origin privileges (localStorage, * fetch without CORS), so it is only granted for the explicit allowlist. * * The GUI itself is the one hard exception: even when its own host is * allowlisted (a bare-host entry covers every port, so the GUI origin * matches), a page at the GUI's exact origin must never get * `allow-same-origin` — that would make it same-origin with its parent and * hand it the GUI's storage/API (and the ability to shed the sandbox). The * GUI keeps the opaque-origin sandbox no matter what the allowlist says. */ export function iframeSandboxFor(url: string | undefined, allowedLoopback: string, selfOrigin?: string): string | undefined { if (url === undefined) return undefined if (selfOrigin !== undefined) { let parsed: URL try { parsed = new URL(url) } catch { return BROWSER_IFRAME_SANDBOX } if (parsed.origin === selfOrigin) return BROWSER_IFRAME_SANDBOX } return isAllowedLoopbackUrl(url, allowedLoopback) ? BROWSER_IFRAME_SANDBOX_SAME_ORIGIN : BROWSER_IFRAME_SANDBOX } export function BrowserView(props: TabComponentProps) { const { store, tab } = props // The current address (initialized from the persisted tab.path so a // reload restores the visited page). const [url, setUrl] = useState(tab.path) const [input, setInput] = useState(tab.path ?? '') /** Blocked/invalid hint shown under the address bar (null = none). */ const [message, setMessage] = useState(null) /** Address-bar navigation history (in-frame clicks are not tracked). */ const [history, setHistory] = useState(tab.path !== undefined ? [tab.path] : []) const [cursor, setCursor] = useState(tab.path !== undefined ? 0 : -1) /** Bumped on reload to remount the iframe (also remounts on sandbox flip). */ const [reloadKey, setReloadKey] = useState(0) /** TEMPORARY sandbox unlock for THIS surface only (never writes the global * side card setting; lasts until the tab unmounts or the user restores). */ const [localUnlock, setLocalUnlock] = useState(false) const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock /** A site that refuses to be embedded (X-Frame-Options / frame-ancestors): * the probe verdict shown instead of the blank iframe. */ const [embedBlocked, setEmbedBlocked] = useState(null) /** The user asked to load the refused site anyway (keeps the plain iframe). */ const [forceEmbed, setForceEmbed] = useState(false) // Probe every navigation (address bar, history, restored path): when the // target forbids embedding, show the reason + open-in-browser instead of // the browser's cryptic "refused to connect" blank frame. A failed probe // (unreachable) keeps the plain iframe. useEffect(() => { if (url === undefined) return let cancelled = false setEmbedBlocked(null) setForceEmbed(false) void api.browserProbe(url).then((probe) => { if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url) }).catch(() => { /* unreachable: keep the plain iframe */ }) return () => { cancelled = true } }, [url]) const persist = (nextUrl: string): void => { let host = nextUrl try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ } store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host })) } const navigateTo = (raw: string): void => { const result = normalizeBrowserUrl(raw, window.location.origin, store.getPrefs().browserAllowedLoopback) if (result.kind === 'ok') { const next = result.url setUrl(next) setInput(next) setMessage(null) // Push onto the stack, dropping any stale forward entries. setHistory(previous => [...previous.slice(0, cursor + 1), next]) setCursor(previous => previous + 1) setReloadKey(key => key + 1) persist(next) return } setMessage(result.kind === 'invalid' ? t('browserInvalid') : result.reason === 'scheme' ? t('browserBlockedScheme') : t('browserBlockedLoopback')) } const goBack = (): void => { if (cursor <= 0) return const next = history[cursor - 1]! setCursor(cursor - 1) setUrl(next) setInput(next) setReloadKey(key => key + 1) } const goForward = (): void => { if (cursor >= history.length - 1) return const next = history[cursor + 1]! setCursor(cursor + 1) setUrl(next) setInput(next) setReloadKey(key => key + 1) } return (
{ setInput(event.target.value) }} onKeyDown={event => { if (event.key === 'Enter') navigateTo(input) }} />
{message !== null &&
{message}
} { setLocalUnlock(true) }} onRestore={() => { setLocalUnlock(false) }} /> {url === undefined ? (
{t('browserStart')}
) : embedBlocked !== null && !forceEmbed ? ( { window.open(embedBlocked, '_blank', 'noopener') }} onLoadAnyway={() => { setForceEmbed(true) }} /> ) : (