From 801425554153d9722d6e7918720529eeb9da435a Mon Sep 17 00:00:00 2001 From: trinhthiminh650-tech Date: Sat, 15 Aug 2026 01:37:39 +0800 Subject: [PATCH] feat(browser): loopback allowlist with same-origin for local dev servers Add a side-card preference 'browserAllowedLoopback' (comma-separated host or host:port entries) that lets the sidebar browser navigate to explicitly trusted loopback addresses. Bare hosts (localhost, 127.0.0.1) match every port; host:port entries match exactly. Allowlisted local addresses additionally get the 'allow-same-origin' sandbox token in the iframe: local dev servers (Vite etc.) need a real origin for their module/HMR/fetch pipeline and would otherwise render blank in the opaque-origin sandbox. The page stays cross-origin to the GUI and to every other site. The server-side browser.probe route mirrors the same allowlist so probed local servers are not rejected as 'local addresses are not probed'. --- src/client/BrowserView.tsx | 26 +++++++++++++++--- src/client/browser.ts | 52 ++++++++++++++++++++++++++++++++++-- src/client/builtins/tabs.tsx | 6 +++++ src/client/locales.ts | 6 +++++ src/client/prefs.ts | 3 +++ src/config.ts | 1 + src/index.ts | 31 +++++++++++++++++++-- src/prefs-shared.ts | 11 ++++++++ 8 files changed, 129 insertions(+), 7 deletions(-) diff --git a/src/client/BrowserView.tsx b/src/client/BrowserView.tsx index 5fb832f..ec8086d 100644 --- a/src/client/BrowserView.tsx +++ b/src/client/BrowserView.tsx @@ -26,7 +26,7 @@ import { IconWarningOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import { api } from './api.ts' -import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts' +import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts' import { patchTab } from './state.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { t } from './locales.ts' @@ -43,6 +43,26 @@ import css from './sidebar.module.css' 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. + */ +export function iframeSandboxFor(url: string | undefined, allowedLoopback: string): string | undefined { + if (url === undefined) return undefined + 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 @@ -88,7 +108,7 @@ export function BrowserView(props: TabComponentProps) { } const navigateTo = (raw: string): void => { - const result = normalizeBrowserUrl(raw, window.location.origin) + const result = normalizeBrowserUrl(raw, window.location.origin, store.getPrefs().browserAllowedLoopback) if (result.kind === 'ok') { const next = result.url setUrl(next) @@ -210,7 +230,7 @@ export function BrowserView(props: TabComponentProps) { key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`} className={css.browserFrame} src={url} - sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX} + sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback)} referrerPolicy="no-referrer" allow="" title={url} diff --git a/src/client/browser.ts b/src/client/browser.ts index 4d47da9..3aab45e 100644 --- a/src/client/browser.ts +++ b/src/client/browser.ts @@ -68,6 +68,11 @@ export function isLoopbackHostname(hostname: string): boolean { * @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. + * @param allowedLoopback - comma-separated loopback allowlist from the side + * card prefs (`browserAllowedLoopback`): bare hosts (`localhost`, + * `127.0.0.1`) allow every port, `host:port` entries allow exactly that + * authority. Entries are matched case-insensitively; portless entries match + * the host on any port. Empty allowlist keeps the default loopback block. */ /** Schemes that must never reach the iframe, even without `//` (javascript:, * data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here — @@ -78,7 +83,41 @@ const FORBIDDEN_SCHEMES = new Set([ 'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source', ]) -export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult { +/** Parse the loopback allowlist into a matcher predicate over host:port. */ +export function parseLoopbackAllowlist(allowlist: string): (host: string, port: string) => boolean { + const entries = allowlist.split(',').map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '') + const exact = new Set(entries) + const hosts = new Set() + for (const entry of entries) { + if (!entry.includes(':')) hosts.add(entry.replace(/^\[|\]$/g, '')) + } + return (host, port) => { + const key = `${host}:${port}` + if (exact.has(key) || exact.has(host)) return true + return port !== '' && hosts.has(host) + } +} + +/** + * Whether a loopback URL is explicitly allowlisted by the side card prefs + * (`browserAllowedLoopback`). Only allowlisted local addresses may run with + * `allow-same-origin` in the sidebar iframe — needed for local dev servers + * (Vite etc.) whose module/HMR/fetch pipeline requires a real origin, while + * the page stays cross-origin to the GUI and to every other site. + */ +export function isAllowedLoopbackUrl(url: string, allowlist: string): boolean { + if (allowlist.trim() === '') return false + let parsed: URL + try { + parsed = new URL(url) + } catch { + return false + } + if (!isLoopbackHostname(parsed.hostname)) return false + return parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port) +} + +export function normalizeBrowserUrl(input: string, selfOrigin: string, allowedLoopback = ''): BrowserNavigateResult { const trimmed = input.trim() if (trimmed === '') return { kind: 'invalid' } // Distinguish an explicit scheme from a bare host:port. "example.com:8080" @@ -114,6 +153,15 @@ export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserN } catch { // Unparsable selfOrigin (never in practice): fall through to the loopback gate. } - if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' } + if (isLoopbackHostname(url.hostname)) { + // An explicit user allowlist (browserAllowedLoopback) can lift the + // loopback block for trusted local dev servers. The sandbox still + // renders them in an opaque origin — no GUI access, exactly like any + // other browsed site. + if (allowedLoopback.trim() !== '' && parseLoopbackAllowlist(allowedLoopback)(url.hostname, url.port)) { + return { kind: 'ok', url: url.href } + } + return { kind: 'blocked', reason: 'loopback' } + } return { kind: 'ok', url: url.href } } diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 943d593..aa28fa5 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -199,6 +199,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { key: 'browserInterceptLinks', title: () => t('settingsBrowserLinksTitle'), desc: () => t('settingsBrowserLinksDesc'), + }, { + key: 'browserAllowedLoopback', + type: 'text', + title: () => t('settingsBrowserLoopbackTitle'), + desc: () => t('settingsBrowserLoopbackDesc'), + placeholder: t('settingsBrowserLoopbackPlaceholder'), }], }, createTab: (state) => ({ diff --git a/src/client/locales.ts b/src/client/locales.ts index 448171a..658e6d7 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -167,6 +167,9 @@ export const zh = { settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启', settingsBrowserLinksTitle: '聊天区外链在侧边栏打开', settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行', + settingsBrowserLoopbackTitle: '允许访问的本机地址', + settingsBrowserLoopbackDesc: '逗号分隔的本地回环地址白名单(如 localhost:5174 或 127.0.0.1:8080),侧边栏浏览器可访问这些本地服务;默认留空则本机地址全部拦截。沙箱隔离仍然生效,页面无法读取界面数据', + settingsBrowserLoopbackPlaceholder: '例如 localhost:5174, 127.0.0.1:8080', browserOpenExternal: '在浏览器中打开', browserEmbedBlocked: '{host} 拒绝了嵌入请求', browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开', @@ -379,6 +382,9 @@ export const en: Record = { settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites', settingsBrowserLinksTitle: 'Open chat external links in the sidebar', settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses', + settingsBrowserLoopbackTitle: 'Allowed local addresses', + settingsBrowserLoopbackDesc: 'Comma-separated allowlist of loopback addresses (e.g. localhost:5174 or 127.0.0.1:8080) the sidebar browser may visit; empty blocks all local addresses by default. The sandbox still applies — pages cannot read GUI data', + settingsBrowserLoopbackPlaceholder: 'e.g. localhost:5174, 127.0.0.1:8080', browserOpenExternal: 'Open in browser', browserEmbedBlocked: '{host} refused to be embedded', browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 9f993e5..9bf2b15 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -71,6 +71,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean' ? record.browserInterceptLinks : SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks, + browserAllowedLoopback: typeof record.browserAllowedLoopback === 'string' + ? record.browserAllowedLoopback + : SIDEBAR_PREFS_DEFAULTS.browserAllowedLoopback, tabsEnabled: booleanMapOf(record.tabsEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled), pluginSettings: pluginSettingsMapOf(record.pluginSettings), diff --git a/src/config.ts b/src/config.ts index b64198e..2ac563d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -95,6 +95,7 @@ export const PrefsSchema: z = z.object({ htmlViewerDefaultUnsafe: z.boolean().default(false), browserNoSandbox: z.boolean().default(false), browserInterceptLinks: z.boolean().default(true), + browserAllowedLoopback: z.string().default(''), // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or // external): an absent key means enabled, so old documents resolve to {} // (everything on) with no migration. Non-boolean values fail validation. diff --git a/src/index.ts b/src/index.ts index 554c995..2e85d99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -176,6 +176,26 @@ export interface SidebarSettingsFace { update(patch: Record, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }> } +/** + * Parse the browser tab's `browserAllowedLoopback` allowlist into a matcher + * over host:port (same contract as the client-side helper in + * src/client/browser.ts — kept in sync). Bare hosts (`localhost`, + * `127.0.0.1`) match every port; `host:port` entries match exactly. + */ +function parseLoopbackAllowlist(allowlist: string): (host: string, port: string) => boolean { + const entries = allowlist.split(',').map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '') + const exact = new Set(entries) + const hosts = new Set() + for (const entry of entries) { + if (!entry.includes(':')) hosts.add(entry.replace(/^\[|\]$/g, '')) + } + return (host, port) => { + const key = `${host}:${port}` + if (exact.has(key) || exact.has(host)) return true + return port !== '' && hosts.has(host) + } +} + /** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */ function buildApi( ctx: Context, @@ -380,9 +400,16 @@ function buildApi( throw new SidebarError('bad-request', 'only http/https urls can be probed', 400) } // Mirror the browser tab's address-bar policy: loopback stays unreachable - // from the sidebar, so probing it would leak nothing the tab could use. + // from the sidebar (unless the user allowlisted it), so probing it would + // leak nothing the tab could use. if (isLoopbackHostname(parsed.hostname)) { - throw new SidebarError('bad-request', 'local addresses are not probed', 400) + const prefs = getSettings()?.get()?.value as SidebarPrefs | undefined + const allowlist = typeof prefs?.browserAllowedLoopback === 'string' ? prefs.browserAllowedLoopback : '' + const allowed = allowlist.trim() !== '' + && parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port) + if (!allowed) { + throw new SidebarError('bad-request', 'local addresses are not probed', 400) + } } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 8000) diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index a3b6210..1e1959c 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -92,6 +92,16 @@ export interface SidebarPrefs { * bypasses the takeover. */ browserInterceptLinks: boolean + /** + * Comma-separated allowlist of local (loopback) authorities the browser + * tab may navigate to — `localhost`, `127.0.0.1`, `127.0.0.1:5174`, or + * host:port pairs. Empty by default: loopback addresses stay blocked so a + * browsed page cannot probe local services. Each entry is either a bare + * hostname (all ports) or host:port; the GUI's own origin is always + * allowed regardless. The iframe sandbox still renders allowed local + * pages in an opaque origin, exactly like any other site. + */ + browserAllowedLoopback: string /** * Per-tab enable switches, keyed by tab descriptor id (`'explorer'`, * `'my-plugin:db'`). An ABSENT key means enabled — only an explicit @@ -145,6 +155,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {},