Skip to content

Commit 423942a

Browse files
committed
fix(theme): allow LAN access when theme dev binds to a wildcard host
1 parent 035c16b commit 423942a

3 files changed

Lines changed: 118 additions & 18 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {createHostValidationHandler} from './host-validation.js'
2+
3+
import {beforeEach, describe, expect, test, vi} from 'vitest'
4+
import {createEvent} from 'h3'
5+
6+
import {networkInterfaces} from 'node:os'
7+
import {IncomingMessage, ServerResponse} from 'node:http'
8+
import {Socket} from 'node:net'
9+
10+
vi.mock('node:os', async (importOriginal) => {
11+
const actual = await importOriginal<typeof import('node:os')>()
12+
return {...actual, networkInterfaces: vi.fn()}
13+
})
14+
15+
const dispatchHost = async (
16+
handler: ReturnType<typeof createHostValidationHandler>,
17+
host?: string,
18+
): Promise<number> => {
19+
const req = new IncomingMessage(new Socket())
20+
req.url = '/'
21+
if (host !== undefined) req.headers = {host}
22+
const res = new ServerResponse(req)
23+
const event = createEvent(req, res)
24+
25+
await handler(event)
26+
27+
return res.statusCode
28+
}
29+
30+
describe('createHostValidationHandler', () => {
31+
describe('loopback bind', () => {
32+
const handler = createHostValidationHandler('127.0.0.1', 9292)
33+
34+
test.each([['localhost:9292'], ['127.0.0.1:9292'], ['[::1]:9292'], ['LOCALHOST:9292'], ['localhost.:9292']])(
35+
'accepts %s',
36+
async (host) => {
37+
const status = await dispatchHost(handler, host)
38+
expect(status).not.toBe(400)
39+
},
40+
)
41+
42+
test.each([['attacker.com:9292'], ['localhost:1234'], ['127.0.0.1']])('rejects %s', async (host) => {
43+
const status = await dispatchHost(handler, host)
44+
expect(status).toBe(400)
45+
})
46+
47+
test('rejects missing host header', async () => {
48+
const status = await dispatchHost(handler)
49+
expect(status).toBe(400)
50+
})
51+
})
52+
53+
describe('wildcard bind', () => {
54+
beforeEach(() => {
55+
vi.mocked(networkInterfaces).mockReturnValue({
56+
en0: [
57+
{
58+
address: '192.168.1.50',
59+
netmask: '255.255.255.0',
60+
family: 'IPv4',
61+
mac: '00:00:00:00:00:00',
62+
internal: false,
63+
cidr: '192.168.1.50/24',
64+
},
65+
],
66+
} as ReturnType<typeof networkInterfaces>)
67+
})
68+
69+
const buildHandler = () => createHostValidationHandler('0.0.0.0', 9292)
70+
71+
test.each([['192.168.1.50:9292'], ['127.0.0.1:9292'], ['[::1]:9292']])('accepts %s', async (host) => {
72+
const status = await dispatchHost(buildHandler(), host)
73+
expect(status).not.toBe(400)
74+
})
75+
76+
test.each([['192.168.1.50:1234'], ['attacker.com:9292']])('rejects %s', async (host) => {
77+
const status = await dispatchHost(buildHandler(), host)
78+
expect(status).toBe(400)
79+
})
80+
})
81+
82+
describe('non-wildcard LAN bind', () => {
83+
const handler = createHostValidationHandler('192.168.1.50', 9292)
84+
85+
test.each([['192.168.1.50:9292']])('accepts %s', async (host) => {
86+
const status = await dispatchHost(handler, host)
87+
expect(status).not.toBe(400)
88+
})
89+
90+
test.each([['192.168.1.99:9292']])('rejects %s', async (host) => {
91+
const status = await dispatchHost(handler, host)
92+
expect(status).toBe(400)
93+
})
94+
})
95+
})

packages/theme/src/cli/utilities/theme-environment/host-validation.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {createError, defineEventHandler, sendError} from 'h3'
2+
import * as os from 'node:os'
23

34
function createAllowedHostsSet(host: string, port: number): Set<string> {
45
const allowedHosts = new Set<string>()
@@ -7,32 +8,34 @@ function createAllowedHostsSet(host: string, port: number): Set<string> {
78

89
allowedHosts.add(`${normalizedHost}${portSuffix}`)
910

10-
// When binding to localhost variants or 0.0.0.0, allow all localhost forms
1111
const localhostVariants = ['localhost', '127.0.0.1', '::1', '0.0.0.0']
1212
if (localhostVariants.includes(normalizedHost)) {
1313
allowedHosts.add(`localhost${portSuffix}`)
1414
allowedHosts.add(`127.0.0.1${portSuffix}`)
1515
allowedHosts.add(`[::1]${portSuffix}`)
1616
}
1717

18+
// When binding to a wildcard address, the server is also reachable through the machine's
19+
// interface IPs (e.g. a LAN address like 192.168.x.x from another device on the network).
20+
if (normalizedHost === '0.0.0.0' || normalizedHost === '::') {
21+
for (const interfaces of Object.values(os.networkInterfaces())) {
22+
for (const iface of interfaces ?? []) {
23+
const address = iface.address.toLowerCase().split('%')[0]
24+
const isIPv6 = iface.family === 'IPv6'
25+
const formatted = isIPv6 ? `[${address}]` : address
26+
allowedHosts.add(`${formatted}${portSuffix}`)
27+
}
28+
}
29+
}
30+
1831
return allowedHosts
1932
}
2033

2134
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
2235
if (!hostHeader) return undefined
23-
// Lowercase, then strip trailing dot before port (or at end for plain hostname).
24-
// IPv6 brackets: trailing dot would be after `]`, e.g. [::1].:9292
2536
return hostHeader.toLowerCase().replace(/\.(?=:\d|$)/, '')
2637
}
2738

28-
/**
29-
* Creates an h3 event handler that validates the request's Host header
30-
* against an allowlist of configured host/port and localhost variants.
31-
*
32-
* Used to mitigate DNS rebinding attacks on local dev servers.
33-
*
34-
* Returns a 400 Bad Request when the Host header is missing or not in the allowlist.
35-
*/
3639
export function createHostValidationHandler(host: string, port: number) {
3740
const allowedHosts = createAllowedHostsSet(host, port)
3841

@@ -43,11 +46,7 @@ export function createHostValidationHandler(host: string, port: number) {
4346
if (!normalizedHost || !allowedHosts.has(normalizedHost)) {
4447
return sendError(
4548
event,
46-
createError({
47-
statusCode: 400,
48-
statusMessage: 'Bad Request',
49-
message: 'Invalid Host header',
50-
}),
49+
createError({statusCode: 400, statusMessage: 'Bad Request', message: 'Invalid Host header'}),
5150
)
5251
}
5352
})

packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,10 @@ describe('setupDevServer', () => {
300300
})
301301

302302
test('CORS allows the theme editor (Online Store Editor) origin so hot reload works in the editor', async () => {
303-
const {res} = await dispatchEvent(server, '/assets/file2.css', {host: defaultHost, origin: 'https://online-store-web.shopifyapps.com'})
303+
const {res} = await dispatchEvent(server, '/assets/file2.css', {
304+
host: defaultHost,
305+
origin: 'https://online-store-web.shopifyapps.com',
306+
})
304307
expect(res.getHeader('access-control-allow-origin')).toEqual('https://online-store-web.shopifyapps.com')
305308
})
306309

@@ -311,7 +314,10 @@ describe('setupDevServer', () => {
311314
})
312315

313316
test('CORS does not allow unknown origins', async () => {
314-
const {res} = await dispatchEvent(server, '/assets/file2.css', {host: defaultHost, origin: 'https://evil.example.com'})
317+
const {res} = await dispatchEvent(server, '/assets/file2.css', {
318+
host: defaultHost,
319+
origin: 'https://evil.example.com',
320+
})
315321
expect(res.getHeader('access-control-allow-origin')).toBeUndefined()
316322
})
317323

0 commit comments

Comments
 (0)