11import dns from 'node:dns/promises'
22import { createLogger } from '@sim/logger'
3- import { isIpLiteral , isPrivateIp , isPrivateIpHost , unwrapIpv6Brackets } from '@sim/security/ssrf'
3+ import {
4+ isIpLiteral ,
5+ isLoopbackIp ,
6+ isPrivateIp ,
7+ isPrivateIpHost ,
8+ unwrapIpv6Brackets ,
9+ } from '@sim/security/ssrf'
410import { getErrorMessage } from '@sim/utils/errors'
511import { parseHttpUrl } from '@/main/navigation'
612
@@ -37,6 +43,25 @@ export interface UrlGuardResult {
3743}
3844
3945const OK : UrlGuardResult = { ok : true }
46+
47+ /**
48+ * Whether an address is off limits to the embedded browser.
49+ *
50+ * Loopback is deliberately allowed: it is the user's own machine, and opening
51+ * a dev server on localhost is one of the most ordinary things to do in this
52+ * panel — the URL bar already assumes `http://` for it. Nothing is given away
53+ * by it either, since the desktop app hands the same agent an unrestricted
54+ * shell on that machine, so a blocked `http://localhost:3000` is one
55+ * `curl http://localhost:3000` away regardless.
56+ *
57+ * Every other private range stays blocked. Those are a different matter: the
58+ * LAN is other people's machines, and `169.254.169.254` is link-local rather
59+ * than loopback, so the cloud-metadata endpoint this guard exists for is
60+ * unaffected.
61+ */
62+ function isBlockedAddress ( ip : string ) : boolean {
63+ return isPrivateIp ( ip ) && ! isLoopbackIp ( ip )
64+ }
4065const BLOCKED : UrlGuardResult = {
4166 ok : false ,
4267 error : 'That address points to a private or internal network and was blocked.' ,
@@ -48,7 +73,8 @@ const BLOCKED: UrlGuardResult = {
4873 * loopback/RFC1918/link-local host (e.g. the `169.254.169.254` cloud-metadata
4974 * endpoint) would let a page's contents be read back through the read/snapshot
5075 * tools. This resolves the host the same way `apps/sim` does for outbound
51- * fetches and blocks any that land on a private/reserved address.
76+ * fetches and blocks any that land on a private/reserved address — except
77+ * loopback, which is allowed (see {@link isBlockedAddress}).
5278 *
5379 * IP literals are classified directly; hostnames are DNS-resolved and every
5480 * returned address is checked. Resolution failure fails CLOSED (blocks): we
@@ -69,7 +95,7 @@ export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
6995
7096 // IP literal: classify directly, no DNS lookup needed.
7197 if ( isIpLiteral ( host ) ) {
72- if ( isPrivateIp ( host ) ) {
98+ if ( isBlockedAddress ( host ) ) {
7399 logger . warn ( 'Blocked agent navigation to private IP literal' , { host } )
74100 return BLOCKED
75101 }
@@ -78,7 +104,7 @@ export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
78104
79105 try {
80106 const resolved = await resolveHost ( host )
81- if ( resolved . some ( ( { address } ) => isPrivateIp ( address ) ) ) {
107+ if ( resolved . some ( ( { address } ) => isBlockedAddress ( address ) ) ) {
82108 logger . warn ( 'Blocked agent navigation resolving to private IP' , { host } )
83109 return BLOCKED
84110 }
@@ -105,8 +131,10 @@ export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
105131 */
106132export function isBlockedRequestUrl ( rawUrl : string ) : boolean {
107133 try {
108- // isPrivateIpHost strips IPv6 brackets itself.
109- return isPrivateIpHost ( new URL ( rawUrl ) . hostname )
134+ // isPrivateIpHost strips IPv6 brackets itself; unwrap again for the
135+ // loopback carve-out, which takes a bare address.
136+ const host = new URL ( rawUrl ) . hostname
137+ return isPrivateIpHost ( host ) && ! isLoopbackIp ( unwrapIpv6Brackets ( host ) )
110138 } catch {
111139 return false
112140 }
0 commit comments