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:
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user