Skip to content

Commit a155f93

Browse files
committed
fix(security): filter refused DNS records instead of failing the host
validateUrlWithDNS rejected a host outright when any resolved address was private, while createSsrfGuardedLookup in the same file filters the private entries and connects to what remains. Filtering is equally safe — you pin a surviving public address — and rejecting broke a split-horizon resolver that answers with a private record alongside the public one, on a path with ~70 call sites and no operator opt-out. The pin is re-preferred over the surviving set so it can never be an address the filter just refused. Also from the review round: the browser preload's isPasswordField and findIdentifierField now split autocomplete tokens like the agent guards they claim parity with (a WebAuthn `current-password webauthn` field was invisible to credential fill); the subresource verdict cache holds the promise rather than the boolean, so the requests one page fires at a host share a lookup instead of each queueing its own getaddrinfo on the four-slot libuv threadpool that main's fs calls also use; trailing-dot hosts normalize to one cache entry; the resolver carries a distinct DnsTimeoutError so an outage is not reported as a missing host; and clearHostVerdictCache is wired into both the profile wipe and the cache-clear path, since a resolved-host classification is browsing-trail data.
1 parent 042db18 commit a155f93

7 files changed

Lines changed: 195 additions & 54 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ function capturePanelSnapshot(onSettled?: () => void): void {
333333
// picture of the page, so it goes to the window still showing the
334334
// browser or nowhere at all.
335335
if (panelWindow() !== win || win.isDestroyed()) return
336+
// Downscale and JPEG-encode before crossing IPC. capturePage returns a
337+
// device-pixel PNG — on a retina half-window that is millions of pixels,
338+
// and toDataURL's PNG encode is synchronous on the main process, so a
339+
// full-size encode stalls every window's input for the frame. This is a
340+
// placeholder shown under a transient overlay, so a downscaled JPEG is
341+
// indistinguishable and an order of magnitude cheaper to encode and send.
336342
const snapshot: BrowserPanelSnapshot = { dataUrl: encodeSnapshot(image), tabId }
337343
win.webContents.send('browser-agent:panel-snapshot', snapshot)
338344
})

apps/desktop/src/main/ipc.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync } from 'node:fs'
22
import { fileURLToPath } from 'node:url'
3-
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44

55
vi.mock('electron', () => import('@/test/electron-mock'))
66

@@ -170,6 +170,10 @@ describe('registerIpcHandlers', () => {
170170
let deps: IpcDeps
171171

172172
beforeEach(() => {
173+
// Frozen so the input-recency windows cannot lapse mid-test: the gates read
174+
// wall-clock, and a loaded machine pausing between this press and an
175+
// assertion would flip them closed for reasons unrelated to the test.
176+
vi.useFakeTimers()
173177
activeSender.press()
174178
activeChooserSender.press()
175179
vi.mocked(ipcMain.handle).mockClear()
@@ -223,6 +227,10 @@ describe('registerIpcHandlers', () => {
223227
registerIpcHandlers(deps)
224228
})
225229

230+
afterEach(() => {
231+
vi.useRealTimers()
232+
})
233+
226234
it('validates open-external URLs regardless of sender', async () => {
227235
const { invoke } = collectHandlers()
228236
expect(await invoke.get('desktop:open-external')?.(evilEvent, 'https://docs.sim.ai')).toBe(true)

apps/desktop/src/main/updater.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,41 +21,40 @@ export type UpdateChannel = 'latest' | 'beta' | 'alpha'
2121
* staging beta, prod stable — so the environment, not the client, is the
2222
* channel. Returns null for origins that can't host a feed.
2323
*/
24+
export function feedUrlForOrigin(origin: string): string | null {
25+
try {
26+
const url = new URL(origin)
27+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
28+
return null
29+
}
30+
return `${url.origin}/api/desktop/update`
31+
} catch {
32+
return null
33+
}
34+
}
35+
2436
/**
2537
* Where the feed rewrites every manifest entry to. Downloads are constrained to
2638
* this prefix rather than to https alone, so a feed that serves an attacker's
2739
* host cannot get a bundle in front of the user's Download button.
2840
*/
29-
const RELEASE_ASSET_PREFIX = 'https://github.com/simstudioai/sim/releases/download/'
41+
const RELEASE_ASSET_ORIGIN = 'https://github.com'
42+
const RELEASE_ASSET_PATH = '/simstudioai/sim/releases/download/'
3043

3144
/** Whether a manifest url is one of our own release assets. */
32-
export function isReleaseAssetUrl(rawUrl: string): boolean {
45+
function isReleaseAssetUrl(rawUrl: string): boolean {
3346
if (!isSafeExternalUrl(rawUrl)) return false
3447
try {
3548
const url = new URL(rawUrl)
36-
// Compared on the parsed origin, never by prefix on the raw string, so
37-
// `https://github.com.evil.example/…` cannot pass.
38-
return (
39-
url.origin === 'https://github.com' &&
40-
url.pathname.startsWith(new URL(RELEASE_ASSET_PREFIX).pathname)
41-
)
49+
// Compared on the parsed origin and the parsed pathname, never by prefix on
50+
// the raw string: `https://github.com.evil.example/…` must not pass, and
51+
// `URL` has already normalized away any `..` segments by this point.
52+
return url.origin === RELEASE_ASSET_ORIGIN && url.pathname.startsWith(RELEASE_ASSET_PATH)
4253
} catch {
4354
return false
4455
}
4556
}
4657

47-
export function feedUrlForOrigin(origin: string): string | null {
48-
try {
49-
const url = new URL(origin)
50-
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
51-
return null
52-
}
53-
return `${url.origin}/api/desktop/update`
54-
} catch {
55-
return null
56-
}
57-
}
58-
5958
/**
6059
* Maps the running version to its update channel: prerelease builds follow
6160
* their prerelease channel, stable builds only ever see stable releases.
@@ -465,7 +464,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
465464
// an available update.
466465
//
467466
// Constrained to the release host, not merely to https. The feed rewrites
468-
// every entry to an absolute `${RELEASE_ASSET_PREFIX}` URL, so nothing
467+
// every entry to an absolute release-asset URL, so nothing
469468
// legitimate is excluded — while scheme-only validation would still admit
470469
// `https://attacker.example/Sim.dmg`, and the download dialog walks the
471470
// user through installing whatever it hands to the browser. That is a

apps/sim/lib/core/security/input-validation.server.test.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ const { mockResolve } = vi.hoisted(() => ({ mockResolve: vi.fn() }))
77

88
vi.mock('@sim/security/dns', () => ({
99
resolveHostAddresses: mockResolve,
10+
// Mirrors the real rule so a pin taken over a narrowed set behaves as it does
11+
// in production; a stub returning addresses[0] would hide the divergence the
12+
// preference exists for.
13+
preferIpv4: (addresses: string[]) =>
14+
addresses.find((address) => address.includes('.')) ?? addresses[0],
1015
}))
1116

1217
vi.mock('@/lib/core/config/env-flags', () => ({
@@ -17,31 +22,50 @@ vi.mock('@/lib/core/config/env-flags', () => ({
1722

1823
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
1924

20-
/** Shapes a resolver answer the way `resolveHostAddresses` does. */
25+
/**
26+
* Shapes a resolver answer the way `resolveHostAddresses` does, including its
27+
* IPv4-first preference — so `preferred` can differ from `addresses[0]`, which
28+
* is the whole reason the field exists.
29+
*/
2130
function resolved(addresses: string[]) {
22-
return { addresses, preferred: addresses[0] }
31+
const preferred = addresses.find((address) => address.includes('.')) ?? addresses[0]
32+
return { addresses, preferred }
2333
}
2434

2535
describe('validateUrlWithDNS address classification', () => {
2636
beforeEach(() => {
2737
vi.clearAllMocks()
2838
})
2939

30-
it('rejects a host that also publishes a private record', async () => {
31-
// The gap this closes: only one address used to be classified, so a host
32-
// publishing both got through whenever the public record sorted first.
40+
it('drops a private co-record and pins the public one', async () => {
41+
// The gap this closes: one address used to be classified, so which record
42+
// got judged was a matter of resolver order.
3343
mockResolve.mockResolvedValue(resolved(['93.184.216.34', '10.0.0.5']))
3444

3545
const result = await validateUrlWithDNS('https://mixed.example/api')
3646

47+
expect(result.isValid).toBe(true)
48+
expect(result.resolvedIP).toBe('93.184.216.34')
49+
})
50+
51+
it('rejects when every record is private', async () => {
52+
mockResolve.mockResolvedValue(resolved(['10.0.0.5', '192.168.1.9']))
53+
54+
const result = await validateUrlWithDNS('https://internal.example/api')
55+
3756
expect(result.isValid).toBe(false)
3857
expect(result.error).toContain('blocked IP address')
3958
})
4059

41-
it('rejects it regardless of which record comes first', async () => {
42-
mockResolve.mockResolvedValue(resolved(['10.0.0.5', '93.184.216.34']))
60+
it('never pins an address the filter refused', async () => {
61+
// The private record sorts first AND is the IPv4 one, so a pin taken from
62+
// the unfiltered set would land on 10.0.0.5.
63+
mockResolve.mockResolvedValue(resolved(['10.0.0.5', '2606:2800:220:1::248']))
64+
65+
const result = await validateUrlWithDNS('https://mixed.example/api')
4366

44-
expect((await validateUrlWithDNS('https://mixed.example/api')).isValid).toBe(false)
67+
expect(result.isValid).toBe(true)
68+
expect(result.resolvedIP).toBe('2606:2800:220:1::248')
4569
})
4670

4771
it('accepts a host whose every record is public, pinning the preferred one', async () => {
@@ -59,12 +83,15 @@ describe('validateUrlWithDNS address classification', () => {
5983
expect((await validateUrlWithDNS('https://localhost/api')).isValid).toBe(true)
6084
})
6185

62-
it('denies localhost the carve-out when it also resolves off-loopback', async () => {
63-
// `localhost` pointing at the LAN as well is not the case the carve-out was
64-
// written for, and riding it there would reach another machine.
86+
it('drops an off-loopback record from localhost rather than pinning it', async () => {
87+
// The carve-out covers loopback only, so the LAN record is filtered out and
88+
// the pin stays on the machine the carve-out was written for.
6589
mockResolve.mockResolvedValue(resolved(['127.0.0.1', '10.0.0.5']))
6690

67-
expect((await validateUrlWithDNS('https://localhost/api')).isValid).toBe(false)
91+
const result = await validateUrlWithDNS('https://localhost/api')
92+
93+
expect(result.isValid).toBe(true)
94+
expect(result.resolvedIP).toBe('127.0.0.1')
6895
})
6996

7097
it('reports an unresolvable host rather than treating it as public', async () => {

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import http from 'http'
55
import https from 'https'
66
import type { LookupFunction } from 'net'
77
import { createLogger } from '@sim/logger'
8-
import { resolveHostAddresses } from '@sim/security/dns'
8+
import { preferIpv4, resolveHostAddresses } from '@sim/security/dns'
99
import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf'
1010
import { toError } from '@sim/utils/errors'
1111
import { omit } from '@sim/utils/object'
@@ -66,23 +66,26 @@ export async function validateUrlWithDNS(
6666
const isLocalhost = cleanHostname === 'localhost' || isLoopbackIp(cleanHostname)
6767

6868
try {
69-
// Every address is classified, but only the IPv4-preferred one is returned to
70-
// pin: checking a single address let a host publishing both a public and a
71-
// private record through whenever the public one sorted first, which is
72-
// record order rather than policy.
73-
const { addresses, preferred } = await resolveHostAddresses(cleanHostname)
74-
const blocked = addresses.filter((address) => isPrivateIp(address))
75-
76-
// The localhost carve-out still applies only when every address is loopback,
77-
// so `localhost` with an extra RFC1918 record does not ride it.
78-
if (
79-
blocked.length > 0 &&
80-
!(isLocalhost && !isHosted && blocked.every((address) => isLoopbackIp(address)))
81-
) {
69+
// Every address is judged, not just the pinned one: classifying a single
70+
// address let a host publishing both a public and a private record through
71+
// whenever the public one sorted first, which is record order rather than
72+
// policy.
73+
//
74+
// Refused records are filtered rather than failing the whole host, matching
75+
// createSsrfGuardedLookup below. Pinning to a surviving public address is
76+
// just as safe as refusing outright, and rejecting the host would break a
77+
// split-horizon resolver that answers with a private record alongside the
78+
// public one — with no operator opt-out on this path.
79+
const { addresses } = await resolveHostAddresses(cleanHostname)
80+
const usable = addresses.filter(
81+
(address) => !isPrivateIp(address) || (isLocalhost && !isHosted && isLoopbackIp(address))
82+
)
83+
84+
if (usable.length === 0) {
8285
logger.warn('URL resolves to blocked IP address', {
8386
paramName,
8487
hostname,
85-
resolvedIP: blocked[0],
88+
resolvedIP: addresses[0],
8689
})
8790
return {
8891
isValid: false,
@@ -92,7 +95,9 @@ export async function validateUrlWithDNS(
9295

9396
return {
9497
isValid: true,
95-
resolvedIP: preferred,
98+
// Re-preferred over the surviving set so the pin is never an address the
99+
// filter above just refused.
100+
resolvedIP: preferIpv4(usable),
96101
originalHostname: hostname,
97102
}
98103
} catch (error) {
@@ -212,7 +217,6 @@ export async function validateDatabaseHost(
212217

213218
try {
214219
const { addresses, preferred } = await resolveHostAddresses(cleanHost)
215-
const address = preferred
216220
const blockedAddress = addresses.find((candidate) => isPrivateIp(candidate))
217221

218222
if (blockedAddress !== undefined && !isPrivateDatabaseHostsAllowed) {
@@ -229,7 +233,7 @@ export async function validateDatabaseHost(
229233

230234
return {
231235
isValid: true,
232-
resolvedIP: address,
236+
resolvedIP: preferred,
233237
originalHostname: host,
234238
}
235239
} catch (error) {

packages/security/src/dns.test.ts

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

9-
import { resolveHostAddresses } from './dns'
9+
import { DnsTimeoutError, resolveHostAddresses } from './dns'
1010

1111
describe('resolveHostAddresses', () => {
1212
beforeEach(() => {
@@ -56,6 +56,51 @@ describe('resolveHostAddresses', () => {
5656
await expect(resolveHostAddresses('missing.example')).rejects.toThrow('ENOTFOUND')
5757
})
5858

59+
it('clears the deadline timer once the lookup succeeds', async () => {
60+
// A leaked timer holds the event loop open for the full window and is
61+
// invisible to every other assertion here.
62+
vi.useFakeTimers()
63+
try {
64+
mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
65+
66+
await resolveHostAddresses('example.com')
67+
68+
expect(vi.getTimerCount()).toBe(0)
69+
} finally {
70+
vi.useRealTimers()
71+
}
72+
})
73+
74+
it('swallows a lookup that rejects after the deadline already fired', async () => {
75+
// Without the pre-race `.catch`, the loser of the race surfaces as an
76+
// unhandled rejection well after the caller has moved on.
77+
vi.useFakeTimers()
78+
const unhandled = vi.fn()
79+
process.on('unhandledRejection', unhandled)
80+
try {
81+
let failLookup: (error: Error) => void = () => {}
82+
mockLookup.mockReturnValue(
83+
new Promise((_resolve, reject) => {
84+
failLookup = reject
85+
})
86+
)
87+
88+
const pending = resolveHostAddresses('slow.example', { timeoutMs: 1_000 })
89+
const assertion = expect(pending).rejects.toThrow('timed out')
90+
await vi.advanceTimersByTimeAsync(1_000)
91+
await assertion
92+
93+
failLookup(new Error('ENOTFOUND'))
94+
await vi.advanceTimersByTimeAsync(0)
95+
await Promise.resolve()
96+
97+
expect(unhandled).not.toHaveBeenCalled()
98+
} finally {
99+
process.off('unhandledRejection', unhandled)
100+
vi.useRealTimers()
101+
}
102+
})
103+
59104
it('rejects on the deadline instead of waiting for a hung resolver', async () => {
60105
vi.useFakeTimers()
61106
try {
@@ -69,4 +114,18 @@ describe('resolveHostAddresses', () => {
69114
vi.useRealTimers()
70115
}
71116
})
117+
118+
it('reports a deadline distinctly from a missing host', async () => {
119+
vi.useFakeTimers()
120+
try {
121+
mockLookup.mockReturnValue(new Promise(() => {}))
122+
123+
const pending = resolveHostAddresses('slow.example', { timeoutMs: 1_000 })
124+
const assertion = expect(pending).rejects.toBeInstanceOf(DnsTimeoutError)
125+
await vi.advanceTimersByTimeAsync(1_000)
126+
await assertion
127+
} finally {
128+
vi.useRealTimers()
129+
}
130+
})
72131
})

0 commit comments

Comments
 (0)