Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions packages/devframe/src/rpc/transports/ws-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,41 @@ function pathMatches(a: string, b: string): boolean {
return strip(a) === strip(b)
}

/**
* Whether `hostname` names a loopback host: `localhost` (or any `*.localhost`
* subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the
* `127.0.0.0/8` loopback block.
*
* The IPv4 case is matched **structurally** — the whole hostname must be a
* canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare
* `startsWith('127.')` prefix check would also accept an attacker-controlled
* DNS name that merely *begins* with `127.` (`127.attacker.example`,
* `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat
* the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding /
* cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps
* genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting
* those DNS names.
*/
export function isLoopbackHostname(hostname: string): boolean {
const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets
return h === 'localhost' || h === '127.0.0.1' || h === '::1'
|| h.endsWith('.localhost') || h.startsWith('127.')
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1')
return true
return isLoopbackIPv4(h)
}

/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */
function isLoopbackIPv4(hostname: string): boolean {
const octets = hostname.split('.')
if (octets.length !== 4 || !octets.every(isDecimalOctet))
return false
return Number(octets[0]) === 127
}

/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */
function isDecimalOctet(part: string): boolean {
if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0'))
return false
return Number(part) <= 255
}

/**
Expand Down
14 changes: 14 additions & 0 deletions packages/devframe/src/rpc/transports/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,22 @@ describe('ws origin check', () => {
expect(isLoopbackHostname('foo.localhost')).toBe(true)
expect(isLoopbackHostname('evil.example')).toBe(false)

// DNS-rebinding bypass: a hostname that merely *starts* with `127.` is an
// attacker-controlled DNS name, not a loopback IPv4 literal, and must be
// rejected so it can't defeat the loopback origin gate.
expect(isLoopbackHostname('127.attacker.example')).toBe(false)
expect(isLoopbackHostname('127.0.0.1.attacker.example')).toBe(false)
expect(isLoopbackHostname('127.0.0.evil')).toBe(false)
expect(isLoopbackHostname('127.0.0')).toBe(false)
expect(isLoopbackHostname('127.0.0.256')).toBe(false)
expect(isLoopbackHostname('1270.0.0.1')).toBe(false)
expect(isLoopbackHostname('127x0x0x1')).toBe(false)

expect(isAllowedOrigin(undefined, [])).toBe(true)
expect(isAllowedOrigin('http://localhost:5173', [])).toBe(true)
expect(isAllowedOrigin('http://127.0.0.1:5173', [])).toBe(true)
expect(isAllowedOrigin('http://127.attacker.example', [])).toBe(false)
expect(isAllowedOrigin('http://127.0.0.1.attacker.example', [])).toBe(false)
expect(isAllowedOrigin('http://evil.example', [])).toBe(false)
expect(isAllowedOrigin('http://evil.example', ['http://evil.example'])).toBe(true)
})
Expand Down
Loading