From 6f092a6b1a84a97790e83f0a1bc0aad6acf0d552 Mon Sep 17 00:00:00 2001 From: trinhthiminh650-tech Date: Sat, 15 Aug 2026 01:02:02 +0800 Subject: [PATCH 1/2] fix(browser): probe embed signals via GET fallback when HEAD omits them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some servers (e.g. aliyun consoles) answer HEAD without the X-Frame-Options / Content-Security-Policy headers that only their GET response carries. The embeddability probe then wrongly reported the site as embeddable, leaving the plain iframe to surface the browser's misleading "refused to connect" error (the site actually forbids framing via frame-ancestors). Retry once as GET when both embed signals are absent (the body is discarded — only headers matter), mirroring the existing 405/501 HEAD fallback. The client then shows the friendly embed-refusal panel with an "open in browser" action instead of the misleading connection error. --- src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/index.ts b/src/index.ts index f9e0622..554c995 100644 --- a/src/index.ts +++ b/src/index.ts @@ -393,6 +393,17 @@ function buildApi( if (response.status === 405 || response.status === 501) { response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal }) } + // Some servers (e.g. aliyun consoles) answer HEAD without the + // X-Frame-Options / CSP headers that only their GET response + // carries. Without those signals the embeddability check below + // would wrongly report the site as embeddable and the plain iframe + // would surface the browser's misleading "refused to connect". + // Retry once as GET when both signals are absent (body discarded). + const hasEmbedSignals = response.headers.get('content-security-policy') !== null + || response.headers.get('x-frame-options') !== null + if (!hasEmbedSignals && response.status !== 405 && response.status !== 501) { + response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal }) + } const csp = response.headers.get('content-security-policy') const frameAncestors = extractFrameAncestors(csp) const xFrameOptions = response.headers.get('x-frame-options') From 801425554153d9722d6e7918720529eeb9da435a Mon Sep 17 00:00:00 2001 From: trinhthiminh650-tech Date: Sat, 15 Aug 2026 01:37:39 +0800 Subject: [PATCH 2/2] 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: {},