Skip to content

Commit 9b8df93

Browse files
committed
fix(desktop): DNS-check agent subresources that are readable or execute
The agent partition's onBeforeRequest ran the resolving guard for mainFrame and subFrame only; everything else fell to isBlockedRequestUrl, which sees literal IPs and returns false for any hostname by design. A public name with a static private A record therefore reached internal services from a page the agent was steered to — no rebinding required. The vectors that matter are the ones whose response comes back or runs: a WebSocket to an internal server reads data frames cross-origin because such servers commonly ignore Origin, and a script or xhr response executes in the page or is readable. Subresources now take isBlockedSubresourceUrl, with the verdict cached per host (30s TTL, bounded at 256 entries, oldest evicted) so this is not a lookup per asset. Images and fonts keep the synchronous path: high volume, not readable cross-origin, leaving the load/error timing oracle as the accepted residual. The exemption is expressed as what skips the check, not what gets it, so a resource type Chromium labels unexpectedly fails safe into the checked path — fetch is `xhr` on some versions and `other` on others, and an allowlist that missed the label in use would silently reopen the hole. Resolver errors fail closed, matching checkAgentUrl, and that verdict is not cached so a transient failure does not stick. The deliberate loopback carve-out is unchanged — isBlockedAddress already exempts it on both paths.
1 parent 73570c7 commit 9b8df93

3 files changed

Lines changed: 234 additions & 5 deletions

File tree

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import {
2525
panelWindow,
2626
} from '@/main/browser-agent/panel'
2727
import { registerAgentWebContents } from '@/main/browser-agent/registry'
28-
import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
28+
import {
29+
checkAgentUrl,
30+
isBlockedRequestUrl,
31+
isBlockedSubresourceUrl,
32+
subresourceNeedsResolution,
33+
} from '@/main/browser-agent/url-guard'
2934

3035
const logger = createLogger('BrowserAgentSession')
3136

@@ -297,8 +302,18 @@ function configureAgentPartition(ses: Session): void {
297302
// iframes) get the full DNS-resolving check — the one seam every navigation
298303
// passes through, including page-initiated ones the driver never sees (server
299304
// redirects, link clicks, location.href, meta-refresh) — so an internal host
300-
// can't slip in that way. Subresources take the cheap synchronous literal-IP
301-
// backstop instead of a DNS lookup per asset.
305+
// can't slip in that way.
306+
//
307+
// Subresources that come back readable or that execute get the resolving
308+
// check too, cached per host: a literal-IP backstop alone let a public
309+
// hostname with a private A record reach internal services, and a WebSocket
310+
// to one is a cross-origin read primitive because internal servers commonly
311+
// ignore Origin. Only images and fonts keep the cheap synchronous path —
312+
// they are the high-volume types and are not readable cross-origin, leaving a
313+
// load/error timing oracle as the accepted residual. The list is expressed as
314+
// what is exempt rather than what is checked, so a resource type Chromium
315+
// labels differently than expected fails safe into the checked path; see
316+
// subresourceNeedsResolution.
302317
ses.webRequest.onBeforeRequest((details, callback) => {
303318
if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
304319
void checkAgentUrl(details.url)
@@ -316,7 +331,16 @@ function configureAgentPartition(ses: Session): void {
316331
})
317332
return
318333
}
319-
callback({ cancel: isBlockedRequestUrl(details.url) })
334+
if (!subresourceNeedsResolution(details.resourceType)) {
335+
callback({ cancel: isBlockedRequestUrl(details.url) })
336+
return
337+
}
338+
void isBlockedSubresourceUrl(details.url)
339+
.then((blocked) => callback({ cancel: blocked }))
340+
.catch((error) => {
341+
logger.error('Agent subresource SSRF check failed; cancelling request', { error })
342+
callback({ cancel: true })
343+
})
320344
})
321345
ses.on('will-download', (_event, item) => {
322346
const filename = item.getFilename()

apps/desktop/src/main/browser-agent/url-guard.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ vi.mock('node:dns/promises', () => ({
66
default: { lookup: mockLookup },
77
}))
88

9-
import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
9+
import {
10+
checkAgentUrl,
11+
clearHostVerdictCache,
12+
isBlockedRequestUrl,
13+
isBlockedSubresourceUrl,
14+
subresourceNeedsResolution,
15+
} from '@/main/browser-agent/url-guard'
1016

1117
describe('checkAgentUrl', () => {
1218
beforeEach(() => {
@@ -120,3 +126,104 @@ describe('isBlockedRequestUrl', () => {
120126
expect(isBlockedRequestUrl('::::')).toBe(false)
121127
})
122128
})
129+
130+
describe('isBlockedSubresourceUrl', () => {
131+
beforeEach(() => {
132+
vi.clearAllMocks()
133+
clearHostVerdictCache()
134+
mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
135+
})
136+
137+
it('blocks a public hostname whose A record points at a private address', async () => {
138+
mockLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }])
139+
140+
await expect(isBlockedSubresourceUrl('https://10-0-0-5.evil.example/probe.json')).resolves.toBe(
141+
true
142+
)
143+
})
144+
145+
it('catches what the synchronous literal-IP backstop cannot', async () => {
146+
mockLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }])
147+
const url = 'https://10-0-0-5.evil.example/probe.json'
148+
149+
// The gap this guard exists to close: the sync check only sees literals, so
150+
// a hostname with a private A record sailed through it.
151+
expect(isBlockedRequestUrl(url)).toBe(false)
152+
await expect(isBlockedSubresourceUrl(url)).resolves.toBe(true)
153+
})
154+
155+
it('blocks a websocket to a privately-resolving host', async () => {
156+
mockLookup.mockResolvedValue([{ address: '172.16.4.4', family: 4 }])
157+
158+
await expect(isBlockedSubresourceUrl('ws://internal.evil.example/socket')).resolves.toBe(true)
159+
})
160+
161+
it('allows a hostname that resolves publicly', async () => {
162+
await expect(isBlockedSubresourceUrl('https://example.com/app.js')).resolves.toBe(false)
163+
})
164+
165+
it('blocks IPv6-mapped and link-local literals without resolving', async () => {
166+
await expect(isBlockedSubresourceUrl('http://169.254.169.254/latest/meta-data')).resolves.toBe(
167+
true
168+
)
169+
await expect(isBlockedSubresourceUrl('http://[::ffff:10.0.0.5]/x')).resolves.toBe(true)
170+
expect(mockLookup).not.toHaveBeenCalled()
171+
})
172+
173+
it('keeps the deliberate loopback carve-out', async () => {
174+
await expect(isBlockedSubresourceUrl('http://127.0.0.1:3000/x')).resolves.toBe(false)
175+
expect(mockLookup).not.toHaveBeenCalled()
176+
})
177+
178+
it('resolves a host once and reuses the verdict', async () => {
179+
await isBlockedSubresourceUrl('https://example.com/a.js')
180+
await isBlockedSubresourceUrl('https://example.com/b.js')
181+
await isBlockedSubresourceUrl('https://example.com/c.js')
182+
183+
expect(mockLookup).toHaveBeenCalledTimes(1)
184+
})
185+
186+
it('fails closed when the host does not resolve, without caching that', async () => {
187+
mockLookup.mockRejectedValueOnce(new Error('ENOTFOUND'))
188+
await expect(isBlockedSubresourceUrl('https://flaky.example/a.js')).resolves.toBe(true)
189+
190+
mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
191+
await expect(isBlockedSubresourceUrl('https://flaky.example/a.js')).resolves.toBe(false)
192+
})
193+
194+
it('ignores a malformed url rather than blocking every request', async () => {
195+
await expect(isBlockedSubresourceUrl('not a url')).resolves.toBe(false)
196+
})
197+
198+
it('bounds the verdict cache', async () => {
199+
for (let i = 0; i < 300; i++) {
200+
await isBlockedSubresourceUrl(`https://host-${i}.example/a.js`)
201+
}
202+
mockLookup.mockClear()
203+
204+
// The earliest hosts were evicted, so they resolve again; the newest do not.
205+
await isBlockedSubresourceUrl('https://host-0.example/a.js')
206+
await isBlockedSubresourceUrl('https://host-299.example/a.js')
207+
expect(mockLookup).toHaveBeenCalledTimes(1)
208+
})
209+
})
210+
211+
describe('subresourceNeedsResolution', () => {
212+
it('exempts only the high-volume, non-readable types', () => {
213+
expect(subresourceNeedsResolution('image')).toBe(false)
214+
expect(subresourceNeedsResolution('font')).toBe(false)
215+
})
216+
217+
it('checks every type that is readable or executes', () => {
218+
for (const type of ['xhr', 'webSocket', 'media', 'script', 'stylesheet', 'object', 'ping']) {
219+
expect(subresourceNeedsResolution(type)).toBe(true)
220+
}
221+
})
222+
223+
it('checks an unrecognised label rather than exempting it', () => {
224+
// fetch is `xhr` on some Chromium versions and `other` on others; a label
225+
// this code has never heard of must not be the one that skips the check.
226+
expect(subresourceNeedsResolution('other')).toBe(true)
227+
expect(subresourceNeedsResolution('someFutureType')).toBe(true)
228+
})
229+
})

apps/desktop/src/main/browser-agent/url-guard.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,104 @@ export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
129129
* lookup on every subresource. Hostnames pass here (they are classified at
130130
* navigation time by {@link checkAgentUrl}).
131131
*/
132+
/**
133+
* Subresource types that keep the cheap synchronous literal-IP check.
134+
*
135+
* Images and fonts are the high-volume types and are not readable
136+
* cross-origin, so the residual for them is a load/error timing oracle — a
137+
* documented, accepted trade against a DNS lookup per asset.
138+
*/
139+
const LITERAL_ONLY_RESOURCE_TYPES: ReadonlySet<string> = new Set(['image', 'font'])
140+
141+
/**
142+
* Whether a subresource needs the DNS-resolving check rather than the literal-IP
143+
* backstop.
144+
*
145+
* Expressed as what is exempt rather than what is checked, so a resource type
146+
* Chromium labels differently than expected fails safe into the checked path —
147+
* `fetch` surfaces as `xhr` or `other` depending on version, and an allowlist
148+
* that missed the label in use would silently reopen the hole.
149+
*/
150+
export function subresourceNeedsResolution(resourceType: string): boolean {
151+
return !LITERAL_ONLY_RESOURCE_TYPES.has(resourceType)
152+
}
153+
154+
/**
155+
* How long a host's resolved classification is reused. Deliberately short: a
156+
* DNS rebind should not stay authorized past roughly the life of a page view.
157+
*/
158+
const HOST_VERDICT_TTL_MS = 30_000
159+
160+
/**
161+
* Ceiling on the cache. A hostile page can name unlimited hostnames, so this is
162+
* bounded rather than left to grow; the oldest entry is evicted first.
163+
*/
164+
const MAX_HOST_VERDICTS = 256
165+
166+
const hostVerdicts = new Map<string, { blocked: boolean; expiry: number }>()
167+
168+
function rememberHostVerdict(host: string, blocked: boolean): void {
169+
if (hostVerdicts.size >= MAX_HOST_VERDICTS) {
170+
const oldest = hostVerdicts.keys().next()
171+
if (!oldest.done) hostVerdicts.delete(oldest.value)
172+
}
173+
hostVerdicts.set(host, { blocked, expiry: Date.now() + HOST_VERDICT_TTL_MS })
174+
}
175+
176+
/** Drops every cached host classification. */
177+
export function clearHostVerdictCache(): void {
178+
hostVerdicts.clear()
179+
}
180+
181+
/**
182+
* DNS-resolving guard for the agent partition's readable and executable
183+
* subresources.
184+
*
185+
* {@link isBlockedRequestUrl} only sees literal IPs, so a public hostname whose
186+
* A record points at an RFC1918 or link-local address reached internal services
187+
* from a page the agent was steered to — no rebinding needed, a static record
188+
* was enough. The vectors that matter are the ones where the response comes
189+
* back or runs: `new WebSocket('ws://internal/…')` reads data frames
190+
* cross-origin because internal servers commonly ignore `Origin`, and a script
191+
* or xhr response either executes in the page or is readable.
192+
*
193+
* Fails closed on a resolver error, for the same reason {@link checkAgentUrl}
194+
* does: an unresolved host cannot be confirmed public, and Chromium resolves
195+
* independently. That verdict is not cached, so a transient failure does not
196+
* stick.
197+
*/
198+
export async function isBlockedSubresourceUrl(rawUrl: string): Promise<boolean> {
199+
let hostname: string
200+
try {
201+
hostname = new URL(rawUrl).hostname
202+
} catch {
203+
return false
204+
}
205+
const host = unwrapIpv6Brackets(hostname)
206+
if (!host) return false
207+
if (isIpLiteral(host)) return isBlockedAddress(host)
208+
209+
const cached = hostVerdicts.get(host)
210+
if (cached && Date.now() < cached.expiry) return cached.blocked
211+
212+
let blocked: boolean
213+
try {
214+
const resolved = await resolveHost(host)
215+
blocked = resolved.some(({ address }) => isBlockedAddress(address))
216+
} catch (error) {
217+
logger.warn('Agent subresource host did not resolve; blocking', {
218+
host,
219+
error: getErrorMessage(error),
220+
})
221+
return true
222+
}
223+
rememberHostVerdict(host, blocked)
224+
if (blocked) {
225+
logger.warn('Blocked agent subresource resolving to private IP', { host })
226+
}
227+
return blocked
228+
}
229+
132230
export function isBlockedRequestUrl(rawUrl: string): boolean {
133231
try {
134232
// isPrivateIpHost strips IPv6 brackets itself; unwrap again for the

0 commit comments

Comments
 (0)