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'.
This commit is contained in:
trinhthiminh650-tech
2026-08-15 01:37:39 +08:00
parent 6f092a6b1a
commit 8014255541
8 changed files with 129 additions and 7 deletions
+23 -3
View File
@@ -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}
+50 -2
View File
@@ -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<string>()
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 }
}
+6
View File
@@ -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) => ({
+6
View File
@@ -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<keyof typeof zh, string> = {
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.',
+3
View File
@@ -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),
+1
View File
@@ -95,6 +95,7 @@ export const PrefsSchema: z<SidebarPrefs> = 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.
+29 -2
View File
@@ -176,6 +176,26 @@ export interface SidebarSettingsFace {
update(patch: Record<string, unknown>, 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<string>()
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)
+11
View File
@@ -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: {},