Skip to content

Commit 0972847

Browse files
authored
fix(devframe): reject non-loopback DNS origins beginning with 127. (#319)
1 parent a55f3d5 commit 0972847

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

‎packages/devframe/src/rpc/transports/ws-server.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,41 @@ function pathMatches(a: string, b: string): boolean {
226226
return strip(a) === strip(b)
227227
}
228228

229+
/**
230+
* Whether `hostname` names a loopback host: `localhost` (or any `*.localhost`
231+
* subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the
232+
* `127.0.0.0/8` loopback block.
233+
*
234+
* The IPv4 case is matched **structurally** — the whole hostname must be a
235+
* canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare
236+
* `startsWith('127.')` prefix check would also accept an attacker-controlled
237+
* DNS name that merely *begins* with `127.` (`127.attacker.example`,
238+
* `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat
239+
* the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding /
240+
* cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps
241+
* genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting
242+
* those DNS names.
243+
*/
229244
export function isLoopbackHostname(hostname: string): boolean {
230245
const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets
231-
return h === 'localhost' || h === '127.0.0.1' || h === '::1'
232-
|| h.endsWith('.localhost') || h.startsWith('127.')
246+
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1')
247+
return true
248+
return isLoopbackIPv4(h)
249+
}
250+
251+
/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */
252+
function isLoopbackIPv4(hostname: string): boolean {
253+
const octets = hostname.split('.')
254+
if (octets.length !== 4 || !octets.every(isDecimalOctet))
255+
return false
256+
return Number(octets[0]) === 127
257+
}
258+
259+
/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */
260+
function isDecimalOctet(part: string): boolean {
261+
if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0'))
262+
return false
263+
return Number(part) <= 255
233264
}
234265

235266
/**

‎packages/devframe/src/rpc/transports/ws.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,22 @@ describe('ws origin check', () => {
340340
expect(isLoopbackHostname('foo.localhost')).toBe(true)
341341
expect(isLoopbackHostname('evil.example')).toBe(false)
342342

343+
// DNS-rebinding bypass: a hostname that merely *starts* with `127.` is an
344+
// attacker-controlled DNS name, not a loopback IPv4 literal, and must be
345+
// rejected so it can't defeat the loopback origin gate.
346+
expect(isLoopbackHostname('127.attacker.example')).toBe(false)
347+
expect(isLoopbackHostname('127.0.0.1.attacker.example')).toBe(false)
348+
expect(isLoopbackHostname('127.0.0.evil')).toBe(false)
349+
expect(isLoopbackHostname('127.0.0')).toBe(false)
350+
expect(isLoopbackHostname('127.0.0.256')).toBe(false)
351+
expect(isLoopbackHostname('1270.0.0.1')).toBe(false)
352+
expect(isLoopbackHostname('127x0x0x1')).toBe(false)
353+
343354
expect(isAllowedOrigin(undefined, [])).toBe(true)
344355
expect(isAllowedOrigin('http://localhost:5173', [])).toBe(true)
356+
expect(isAllowedOrigin('http://127.0.0.1:5173', [])).toBe(true)
357+
expect(isAllowedOrigin('http://127.attacker.example', [])).toBe(false)
358+
expect(isAllowedOrigin('http://127.0.0.1.attacker.example', [])).toBe(false)
345359
expect(isAllowedOrigin('http://evil.example', [])).toBe(false)
346360
expect(isAllowedOrigin('http://evil.example', ['http://evil.example'])).toBe(true)
347361
})

0 commit comments

Comments
 (0)