chore: prepare GoodBuddy 0.8.5

This commit is contained in:
lofyer
2026-08-07 10:45:01 +08:00
parent e20cb447af
commit 17e66a3369
54 changed files with 1306 additions and 1640 deletions
+2 -2
View File
@@ -252,7 +252,7 @@ export class BrowserModelTools {
const input = browserNavigateInputSchema.parse(argumentsValue)
const target = canonicalizeBrowserUrl(input.url)
const label = navigationLabel(target)
description = `将在隔离浏览器中访问 ${label}仅允许公开 HTTP(S) 地址。`
description = `将在隔离浏览器中访问 ${label}支持可由当前设备连接的 HTTP(S) 地址。`
argumentSummary = label
scopeKey = `model:browser:navigate:${target.origin}`
} else if (name === 'browser_snapshot') {
@@ -279,7 +279,7 @@ export class BrowserModelTools {
scopeKey = `model:browser:select:${randomUUID()}`
} else if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
description = `${currentOrigin} 返回浏览器历史记录中的上一页。目标仍需通过 URL 安全策略。`
description = `${currentOrigin} 返回浏览器历史记录中的上一页。`
argumentSummary = `当前来源:${currentOrigin}`
scopeKey = `model:browser:back:${randomUUID()}`
} else {
-2
View File
@@ -542,7 +542,6 @@ export class BrowserService {
)
const finalTarget = await this.policy.validateRedirect(
result.url,
target.origin,
effectiveSignal
)
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
@@ -681,7 +680,6 @@ export class BrowserService {
)
const finalTarget = await this.policy.validateRedirect(
result.url,
target.origin,
effectiveSignal
)
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
+22 -98
View File
@@ -1,63 +1,36 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setIntranetCompatibilityReader } from '../intranet-compatibility-policy'
import { describe, expect, it, vi } from 'vitest'
import {
BrowserUrlPolicy,
canonicalizeBrowserUrl,
isPublicBrowserAddress
canonicalizeBrowserUrl
} from './browser-url-policy'
const signal = new AbortController().signal
beforeEach(() => {
setIntranetCompatibilityReader(() => false)
})
afterEach(() => {
setIntranetCompatibilityReader(() => true)
})
describe('BrowserUrlPolicy', () => {
it.each([
'file:///etc/passwd',
'data:text/html,hello',
'javascript:alert(1)',
'ssh://example.com',
'https://user:secret@example.com/',
'http://localhost/',
'http://printer/',
'http://service.local/',
'http://metadata.google.internal/',
'http://169.254.169.254/latest/meta-data/',
'http://[::1]/'
])('rejects unsafe URL %s', (url) => {
'ssh://example.com'
])('rejects non-HTTP URL %s', (url) => {
expect(() => canonicalizeBrowserUrl(url)).toThrow()
})
it.each([
'0.0.0.0',
'10.0.0.1',
'100.64.0.1',
'127.0.0.1',
'169.254.169.254',
'172.20.1.1',
'192.168.1.1',
'192.0.2.1',
'224.0.0.1',
'::',
'::1',
'::ffff:127.0.0.1',
'fc00::1',
'fe80::1',
'ff02::1',
'2001:db8::1'
])('classifies %s as non-public', (address) => {
expect(isPublicBrowserAddress(address)).toBe(false)
'http://localhost:8080/admin',
'http://printer/status',
'http://service.local/health',
'http://10.0.0.1/api',
'http://192.168.1.20/status',
'http://[::1]:3000/',
'https://example.com/'
])('accepts intranet and public target %s', (url) => {
expect(() => canonicalizeBrowserUrl(url)).not.toThrow()
})
it('accepts canonical public HTTP(S) URLs and strips fragments', async () => {
it('accepts canonical HTTP(S) URLs and strips fragments', async () => {
const resolver = vi.fn(async () => [
{ address: '93.184.216.34', family: 4 as const },
{ address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 as const }
{ address: '93.184.216.34', family: 4 as const }
])
const policy = new BrowserUrlPolicy(resolver)
@@ -75,33 +48,7 @@ describe('BrowserUrlPolicy', () => {
)
})
it('rejects empty, private, malformed, and mixed DNS answers', async () => {
for (const answers of [
[],
[{ address: '10.0.0.2', family: 4 as const }],
[
{ address: '93.184.216.34', family: 4 as const },
{ address: '127.0.0.1', family: 4 as const }
],
[{ address: 'not-an-address', family: 4 as const }]
]) {
const policy = new BrowserUrlPolicy(async () => answers)
await expect(policy.validate('https://example.com', signal)).rejects.toThrow(
'混合地址'
)
}
})
it('allows intranet names and private addresses only in compatibility mode', async () => {
setIntranetCompatibilityReader(() => true)
expect(() => canonicalizeBrowserUrl('http://printer/status')).not.toThrow()
expect(() =>
canonicalizeBrowserUrl('https://service.internal/health')
).not.toThrow()
expect(() =>
canonicalizeBrowserUrl('http://192.168.1.20/status')
).not.toThrow()
it('resolves intranet hostnames to their private addresses', async () => {
const policy = new BrowserUrlPolicy(async () => [
{ address: '10.20.30.40', family: 4 }
])
@@ -113,52 +60,29 @@ describe('BrowserUrlPolicy', () => {
})
})
it('keeps metadata, link-local and mixed DNS answers blocked in compatibility mode', async () => {
setIntranetCompatibilityReader(() => true)
expect(() =>
canonicalizeBrowserUrl('http://metadata.google.internal/latest')
).toThrow()
expect(() =>
canonicalizeBrowserUrl('http://169.254.169.254/latest/meta-data')
).toThrow()
expect(() =>
canonicalizeBrowserUrl('http://user:secret@printer/status')
).toThrow()
const mixedPolicy = new BrowserUrlPolicy(async () => [
{ address: '10.20.30.40', family: 4 },
{ address: '93.184.216.34', family: 4 }
])
it('rejects a host that resolves to no address', async () => {
const policy = new BrowserUrlPolicy(async () => [])
await expect(
mixedPolicy.validate('http://printer/status', signal)
).rejects.toThrow('混合地址')
const linkLocalPolicy = new BrowserUrlPolicy(async () => [
{ address: '169.254.10.20', family: 4 }
])
await expect(
linkLocalPolicy.validate('http://printer/status', signal)
).rejects.toThrow('混合地址')
policy.validate('https://example.com', signal)
).rejects.toThrow('无法解析')
})
it('validates redirects and keeps them on the approved origin', async () => {
it('validates redirects without restricting their destination origin', async () => {
const policy = new BrowserUrlPolicy(async () => [
{ address: '93.184.216.34', family: 4 }
])
await expect(
policy.validateRedirect(
'https://example.com/next',
'https://example.com',
signal
)
).resolves.toMatchObject({ origin: 'https://example.com' })
await expect(
policy.validateRedirect(
'https://other.example/next',
'https://example.com',
signal
)
).rejects.toThrow('超出已批准来源')
).resolves.toMatchObject({ origin: 'https://other.example' })
})
it('honors cancellation before and after DNS resolution', async () => {
+10 -300
View File
@@ -1,6 +1,5 @@
import { lookup as dnsLookup } from 'node:dns/promises'
import { isIP } from 'node:net'
import { isIntranetCompatibilityEnabled } from '../intranet-compatibility-policy'
export type BrowserResolvedAddress = {
address: string
@@ -18,241 +17,6 @@ export type ValidatedBrowserUrl = {
addresses: readonly BrowserResolvedAddress[]
}
const LOCAL_HOST_SUFFIXES = [
'.home',
'.internal',
'.lan',
'.local',
'.localdomain',
'.localhost'
]
const BLOCKED_HOSTS = new Set([
'instance-data',
'instance-data.ec2.internal',
'metadata',
'metadata.aws.internal',
'metadata.google.internal'
])
const ALWAYS_BLOCKED_HOST_SUFFIXES = ['.invalid', '.test']
function ipv4Number(address: string): number | undefined {
if (isIP(address) !== 4) {
return undefined
}
const octets = address.split('.').map(Number)
if (octets.length !== 4) {
return undefined
}
return (
(((octets[0] ?? 0) << 24) |
((octets[1] ?? 0) << 16) |
((octets[2] ?? 0) << 8) |
(octets[3] ?? 0)) >>>
0
)
}
function inIpv4Range(value: number, base: number, prefix: number): boolean {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
return (value & mask) === (base & mask)
}
function isPublicIpv4(address: string): boolean {
const value = ipv4Number(address)
if (value === undefined) {
return false
}
const blocked: Array<[number, number]> = [
[0x00000000, 8],
[0x0a000000, 8],
[0x64400000, 10],
[0x7f000000, 8],
[0xa9fe0000, 16],
[0xac100000, 12],
[0xc0000000, 24],
[0xc0000200, 24],
[0xc0586300, 24],
[0xc0a80000, 16],
[0xc6120000, 15],
[0xc6336400, 24],
[0xcb007100, 24],
[0xe0000000, 4],
[0xf0000000, 4]
]
return !blocked.some(([base, prefix]) =>
inIpv4Range(value, base, prefix)
)
}
function expandIpv6(address: string): readonly number[] | undefined {
const withoutZone = address.toLowerCase().split('%', 1)[0] ?? ''
if (isIP(withoutZone) !== 6) {
return undefined
}
let normalized = withoutZone
const ipv4Match = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/u)
if (ipv4Match) {
const ipv4 = ipv4Number(ipv4Match[1] ?? '')
if (ipv4 === undefined) {
return undefined
}
normalized = normalized.replace(
ipv4Match[1] ?? '',
`${((ipv4 >>> 16) & 0xffff).toString(16)}:${(ipv4 & 0xffff).toString(16)}`
)
}
const halves = normalized.split('::')
if (halves.length > 2) {
return undefined
}
const left = (halves[0] ?? '').split(':').filter(Boolean)
const right = (halves[1] ?? '').split(':').filter(Boolean)
const missing = 8 - left.length - right.length
if (
(halves.length === 1 && missing !== 0) ||
(halves.length === 2 && missing < 1)
) {
return undefined
}
const groups = [
...left,
...Array.from({ length: Math.max(0, missing) }, () => '0'),
...right
].map((group) => Number.parseInt(group, 16))
return groups.length === 8 &&
groups.every((group) => Number.isInteger(group) && group <= 0xffff)
? groups
: undefined
}
function ipv6Prefix(
groups: readonly number[],
expected: readonly number[],
prefixBits: number
): boolean {
let remaining = prefixBits
for (let index = 0; remaining > 0; index += 1) {
const bits = Math.min(16, remaining)
const mask = (0xffff << (16 - bits)) & 0xffff
if (((groups[index] ?? 0) & mask) !== ((expected[index] ?? 0) & mask)) {
return false
}
remaining -= bits
}
return true
}
function isPublicIpv6(address: string): boolean {
const groups = expandIpv6(address)
if (!groups) {
return false
}
if (groups.slice(0, 5).every((group) => group === 0)) {
const sixth = groups[5] ?? 0
if (sixth === 0xffff) {
const mapped = `${(groups[6] ?? 0) >>> 8}.${(groups[6] ?? 0) & 0xff}.${(groups[7] ?? 0) >>> 8}.${(groups[7] ?? 0) & 0xff}`
return isPublicIpv4(mapped)
}
if (sixth === 0) {
return false
}
}
const blocked: Array<[readonly number[], number]> = [
[[0, 0, 0, 0, 0, 0, 0, 0], 128],
[[0, 0, 0, 0, 0, 0, 0, 1], 128],
[[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96],
[[0x64, 0xff9b, 1, 0, 0, 0, 0, 0], 48],
[[0x100, 0, 0, 0, 0, 0, 0, 0], 64],
[[0x2001, 0, 0, 0, 0, 0, 0, 0], 32],
[[0x2001, 2, 0, 0, 0, 0, 0, 0], 48],
[[0x2001, 0x10, 0, 0, 0, 0, 0, 0], 28],
[[0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28],
[[0x2001, 0xdb8, 0, 0, 0, 0, 0, 0], 32],
[[0x2002, 0, 0, 0, 0, 0, 0, 0], 16],
[[0x3fff, 0, 0, 0, 0, 0, 0, 0], 20],
[[0x5f00, 0, 0, 0, 0, 0, 0, 0], 16],
[[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7],
[[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10],
[[0xfec0, 0, 0, 0, 0, 0, 0, 0], 10],
[[0xff00, 0, 0, 0, 0, 0, 0, 0], 8]
]
return !blocked.some(([prefix, bits]) =>
ipv6Prefix(groups, prefix, bits)
)
}
export function isPublicBrowserAddress(address: string): boolean {
const family = isIP(address.split('%', 1)[0] ?? '')
return family === 4
? isPublicIpv4(address)
: family === 6
? isPublicIpv6(address)
: false
}
function isIntranetBrowserIpv4(address: string): boolean {
const value = ipv4Number(address)
if (value === undefined || address === '100.100.100.200') {
return false
}
return [
[0x0a000000, 8],
[0x64400000, 10],
[0x7f000000, 8],
[0xac100000, 12],
[0xc0a80000, 16]
].some(([base, prefix]) =>
inIpv4Range(value, base ?? 0, prefix ?? 0)
)
}
function isIntranetBrowserIpv6(address: string): boolean {
const groups = expandIpv6(address)
if (!groups) {
return false
}
if (groups.slice(0, 5).every((group) => group === 0)) {
const sixth = groups[5] ?? 0
if (sixth === 0xffff) {
const mapped = `${(groups[6] ?? 0) >>> 8}.${(groups[6] ?? 0) & 0xff}.${(groups[7] ?? 0) >>> 8}.${(groups[7] ?? 0) & 0xff}`
return isIntranetBrowserIpv4(mapped)
}
if (
sixth === 0 &&
groups[6] === 0 &&
groups[7] === 1
) {
return true
}
}
const awsMetadata = [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254]
return (
ipv6Prefix(groups, [0xfc00, 0, 0, 0, 0, 0, 0, 0], 7) &&
!ipv6Prefix(groups, awsMetadata, 128)
)
}
export function isIntranetBrowserAddress(address: string): boolean {
const normalized = address.split('%', 1)[0] ?? ''
const family = isIP(normalized)
return family === 4
? isIntranetBrowserIpv4(normalized)
: family === 6
? isIntranetBrowserIpv6(normalized)
: false
}
function browserAddressClass(
address: string
): 'public' | 'intranet' | 'blocked' {
if (isPublicBrowserAddress(address)) {
return 'public'
}
return isIntranetBrowserAddress(address) ? 'intranet' : 'blocked'
}
export function canonicalizeBrowserUrl(input: string): URL {
if (input !== input.trim() || input.length === 0 || input.length > 8_192) {
throw new Error('浏览器 URL 无效')
@@ -266,49 +30,8 @@ export function canonicalizeBrowserUrl(input: string): URL {
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('浏览器仅支持 HTTP(S) URL')
}
if (url.username || url.password || !url.hostname || url.origin === 'null') {
throw new Error('浏览器 URL 不允许包含凭据或无效来源')
}
const rawHostname = url.hostname.toLowerCase()
const hostname = (
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname
).replace(/\.$/u, '')
if (
hostname !== (
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname
) ||
BLOCKED_HOSTS.has(hostname) ||
ALWAYS_BLOCKED_HOST_SUFFIXES.some(
(suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix)
) ||
(
!isIntranetCompatibilityEnabled() &&
(
(!hostname.includes('.') && isIP(hostname) === 0) ||
LOCAL_HOST_SUFFIXES.some(
(suffix) =>
hostname === suffix.slice(1) || hostname.endsWith(suffix)
)
)
)
) {
throw new Error('浏览器 URL 不允许访问本机或内部名称')
}
if (
isIP(hostname) !== 0 &&
(
browserAddressClass(hostname) === 'blocked' ||
(
!isIntranetCompatibilityEnabled() &&
!isPublicBrowserAddress(hostname)
)
)
) {
throw new Error('浏览器 URL 不允许访问私有或保留地址')
if (!url.hostname || url.origin === 'null') {
throw new Error('浏览器 URL 缺少有效主机名')
}
url.hash = ''
return url
@@ -378,6 +101,11 @@ export class BrowserUrlPolicy {
})
}
/**
* Resolves the target up front so the filtering proxy connects to the exact
* addresses seen here instead of re-resolving, which keeps a host from
* pointing at a different machine between approval and connection.
*/
async validate(
input: string | URL,
signal: AbortSignal
@@ -399,21 +127,8 @@ export class BrowserUrlPolicy {
} as const]
: await this.resolve(url.hostname, signal)
signal.throwIfAborted()
const addressClasses = addresses.map((entry) =>
entry.family === isIP(entry.address)
? browserAddressClass(entry.address)
: 'blocked'
)
if (
addresses.length === 0 ||
addressClasses.includes('blocked') ||
new Set(addressClasses).size !== 1 ||
(
!isIntranetCompatibilityEnabled() &&
addressClasses.some((addressClass) => addressClass !== 'public')
)
) {
throw new Error('浏览器目标解析到私有、保留或混合地址')
if (addresses.length === 0) {
throw new Error('浏览器目标无法解析到任何地址')
}
return {
url,
@@ -424,13 +139,8 @@ export class BrowserUrlPolicy {
async validateRedirect(
input: string,
approvedOrigin: string,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
const target = await this.validate(input, signal)
if (target.origin !== approvedOrigin) {
throw new Error('浏览器重定向超出已批准来源')
}
return target
return this.validate(input, signal)
}
}
@@ -227,7 +227,7 @@ describe('ElectronBrowserSession', () => {
await session.dispose()
})
it('allows only the explicitly approved top-level origin', async () => {
it('allows HTTP(S) top-level navigation and cross-origin redirects', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
policy: harness.policy,
@@ -256,7 +256,7 @@ describe('ElectronBrowserSession', () => {
foreignEvent,
'https://attacker.example/'
)
expect(foreignEvent.preventDefault).toHaveBeenCalled()
expect(foreignEvent.preventDefault).not.toHaveBeenCalled()
harness.setCurrentUrl('https://attacker.example/')
harness.contentEvents.emit(
@@ -264,14 +264,15 @@ describe('ElectronBrowserSession', () => {
{},
'https://attacker.example/'
)
expect(harness.webContents.stop).toHaveBeenCalled()
expect(session.getCurrentOrigin()).toBeUndefined()
expect(harness.webContents.stop).not.toHaveBeenCalled()
expect(session.getCurrentOrigin()).toBe('https://attacker.example')
await expect(
session.validateRedirect(
'https://attacker.example/',
'http://10.0.0.25/admin',
new AbortController().signal
)
).rejects.toThrow('超出已批准来源')
).resolves.toBeUndefined()
expect(session.getApprovedOrigin()).toBe('http://10.0.0.25')
await session.dispose()
})
+9 -14
View File
@@ -386,13 +386,13 @@ export class ElectronBrowserSession {
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.isApprovedUrl(url)) {
if (!url || !this.updateOriginFromUrl(url)) {
event.preventDefault()
}
})
this.listen(contents, 'will-redirect', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
if (!url || !this.isApprovedUrl(url)) {
if (!url || !this.updateOriginFromUrl(url)) {
event.preventDefault()
}
})
@@ -415,7 +415,7 @@ export class ElectronBrowserSession {
callback()
})
this.listen(contents, 'did-navigate', (_event: unknown, url: string) => {
if (url && !this.isApprovedUrl(url)) {
if (url && !this.updateOriginFromUrl(url)) {
contents.stop()
}
})
@@ -455,12 +455,10 @@ export class ElectronBrowserSession {
}
}
private isApprovedUrl(input: string): boolean {
private updateOriginFromUrl(input: string): boolean {
try {
return (
this.approvedOrigin !== undefined &&
canonicalizeBrowserUrl(input).origin === this.approvedOrigin
)
this.approvedOrigin = canonicalizeBrowserUrl(input).origin
return true
} catch {
return false
}
@@ -483,8 +481,7 @@ export class ElectronBrowserSession {
return undefined
}
try {
const origin = canonicalizeBrowserUrl(current).origin
return origin === this.approvedOrigin ? origin : undefined
return canonicalizeBrowserUrl(current).origin
} catch {
return undefined
}
@@ -512,10 +509,8 @@ export class ElectronBrowserSession {
}
async validateRedirect(url: string, signal: AbortSignal): Promise<void> {
if (!this.approvedOrigin) {
throw new Error('浏览器没有已批准来源')
}
await this.policy.validateRedirect(url, this.approvedOrigin, signal)
const target = await this.policy.validateRedirect(url, signal)
this.approvedOrigin = target.origin
}
async dispose(): Promise<void> {