Skip to content

Commit 035c16b

Browse files
committed
fix(theme): validate host header for app extension dev server
1 parent 631b28e commit 035c16b

4 files changed

Lines changed: 181 additions & 53 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {createError, defineEventHandler, sendError} from 'h3'
2+
3+
function createAllowedHostsSet(host: string, port: number): Set<string> {
4+
const allowedHosts = new Set<string>()
5+
const portSuffix = `:${port}`
6+
const normalizedHost = host.toLowerCase().trim()
7+
8+
allowedHosts.add(`${normalizedHost}${portSuffix}`)
9+
10+
// When binding to localhost variants or 0.0.0.0, allow all localhost forms
11+
const localhostVariants = ['localhost', '127.0.0.1', '::1', '0.0.0.0']
12+
if (localhostVariants.includes(normalizedHost)) {
13+
allowedHosts.add(`localhost${portSuffix}`)
14+
allowedHosts.add(`127.0.0.1${portSuffix}`)
15+
allowedHosts.add(`[::1]${portSuffix}`)
16+
}
17+
18+
return allowedHosts
19+
}
20+
21+
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
22+
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
25+
return hostHeader.toLowerCase().replace(/\.(?=:\d|$)/, '')
26+
}
27+
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+
*/
36+
export function createHostValidationHandler(host: string, port: number) {
37+
const allowedHosts = createAllowedHostsSet(host, port)
38+
39+
return defineEventHandler((event) => {
40+
const hostHeader = event.node.req.headers.host
41+
const normalizedHost = normalizeHostHeader(hostHeader)
42+
43+
if (!normalizedHost || !allowedHosts.has(normalizedHost)) {
44+
return sendError(
45+
event,
46+
createError({
47+
statusCode: 400,
48+
statusMessage: 'Bad Request',
49+
message: 'Invalid Host header',
50+
}),
51+
)
52+
}
53+
})
54+
}

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

Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,13 @@ import {getHtmlHandler} from './html.js'
33
import {getAssetsHandler} from './local-assets.js'
44
import {getProxyHandler} from './proxy.js'
55
import {reconcileAndPollThemeEditorChanges} from './remote-theme-watcher.js'
6+
import {createHostValidationHandler} from './host-validation.js'
67
import {uploadTheme} from '../theme-uploader.js'
78
import {renderTasksToStdErr} from '../theme-ui.js'
89
import {renderThrownError} from '../errors.js'
910
import {promiseWithResolvers} from '../../polyfills/promiseWithResolvers.js'
1011

11-
import {
12-
createApp,
13-
defineEventHandler,
14-
defineLazyEventHandler,
15-
toNodeListener,
16-
handleCors,
17-
sendError,
18-
createError,
19-
} from 'h3'
12+
import {createApp, defineEventHandler, defineLazyEventHandler, toNodeListener, handleCors} from 'h3'
2013
import {fetchChecksums} from '@shopify/cli-kit/node/themes/api'
2114

2215
import {createServer} from 'node:http'
@@ -136,31 +129,6 @@ interface DevelopmentServerInstance {
136129
close: () => Promise<void>
137130
}
138131

139-
function createAllowedHostsSet(host: string, port: string): Set<string> {
140-
const allowedHosts = new Set<string>()
141-
const portSuffix = `:${port}`
142-
const normalizedHost = host.toLowerCase().trim()
143-
144-
allowedHosts.add(`${normalizedHost}${portSuffix}`)
145-
146-
// When binding to localhost variants or 0.0.0.0, allow all localhost forms
147-
const localhostVariants = ['localhost', '127.0.0.1', '::1', '0.0.0.0']
148-
if (localhostVariants.includes(normalizedHost)) {
149-
allowedHosts.add(`localhost${portSuffix}`)
150-
allowedHosts.add(`127.0.0.1${portSuffix}`)
151-
allowedHosts.add(`[::1]${portSuffix}`)
152-
}
153-
154-
return allowedHosts
155-
}
156-
157-
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
158-
if (!hostHeader) return undefined
159-
// Lowercase, then strip trailing dot before port (or at end for plain hostname).
160-
// IPv6 brackets: trailing dot would be after `]`, e.g. [::1].:9292
161-
return hostHeader.toLowerCase().replace(/\.(?=:\d|$)/, '')
162-
}
163-
164132
function createDevelopmentServer(theme: Theme, ctx: DevServerContext, initialWork: Promise<void>) {
165133
const app = createApp()
166134
const allowedOrigins = [
@@ -169,26 +137,8 @@ function createDevelopmentServer(theme: Theme, ctx: DevServerContext, initialWor
169137
// Required for HMR with the theme editor
170138
'https://online-store-web.shopifyapps.com',
171139
]
172-
const allowedHosts = createAllowedHostsSet(ctx.options.host, ctx.options.port)
173140

174-
// Host header validation
175-
app.use(
176-
defineEventHandler((event) => {
177-
const hostHeader = event.node.req.headers.host
178-
const normalizedHost = normalizeHostHeader(hostHeader)
179-
180-
if (!normalizedHost || !allowedHosts.has(normalizedHost)) {
181-
return sendError(
182-
event,
183-
createError({
184-
statusCode: 400,
185-
statusMessage: 'Bad Request',
186-
message: 'Invalid Host header',
187-
}),
188-
)
189-
}
190-
}),
191-
)
141+
app.use(createHostValidationHandler(ctx.options.host, ctx.options.port))
192142

193143
app.use(
194144
defineLazyEventHandler(async () => {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {createDevelopmentExtensionServer} from './theme-ext-server.js'
2+
import {DevServerContext} from '../theme-environment/types.js'
3+
import {emptyThemeExtFileSystem, emptyThemeFileSystem} from '../theme-fs-empty.js'
4+
5+
import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils'
6+
import {buildTheme} from '@shopify/cli-kit/node/themes/factories'
7+
import {describe, expect, test} from 'vitest'
8+
import {createEvent} from 'h3'
9+
10+
import {IncomingMessage, ServerResponse} from 'node:http'
11+
import {Socket} from 'node:net'
12+
13+
describe('createDevelopmentExtensionServer', () => {
14+
const decoder = new TextDecoder()
15+
16+
const createH3Event = (options: {url: string; headers?: Record<string, string>}) => {
17+
const req = new IncomingMessage(new Socket())
18+
req.url = options.url
19+
if (options.headers) req.headers = options.headers
20+
const res = new ServerResponse(req)
21+
return createEvent(req, res)
22+
}
23+
24+
const dispatchEvent = async (
25+
server: ReturnType<typeof createDevelopmentExtensionServer>,
26+
url: string,
27+
headers?: Record<string, string>,
28+
): Promise<{res: ServerResponse; status: number; body: string | Buffer}> => {
29+
const event = createH3Event({url, headers})
30+
const {res} = event.node
31+
let body = ''
32+
const resWrite = res.write.bind(res)
33+
res.write = (chunk) => {
34+
body ??= ''
35+
body += decoder.decode(chunk)
36+
return resWrite(chunk)
37+
}
38+
const resEnd = res.end.bind(res)
39+
res.end = (content) => {
40+
if (!body) body = content ?? ''
41+
return resEnd(content)
42+
}
43+
44+
await server.dispatch(event)
45+
46+
if (!body && '_data' in res) {
47+
// eslint-disable-next-line require-atomic-updates
48+
body = await new Response(res._data as ReadableStream).text()
49+
}
50+
51+
return {res, status: res.statusCode, body}
52+
}
53+
54+
const developmentTheme = buildTheme({id: 1, name: 'Theme', role: DEVELOPMENT_THEME_ROLE})!
55+
56+
const defaultServerContext: DevServerContext = {
57+
session: {
58+
storefrontToken: 'shptka_test_token_123',
59+
token: '',
60+
storeFqdn: 'my-store.myshopify.com',
61+
sessionCookies: {_shopify_essential: 'test-cookie-value'},
62+
},
63+
lastRequestedPath: '',
64+
localThemeFileSystem: emptyThemeFileSystem(),
65+
localThemeExtensionFileSystem: emptyThemeExtFileSystem(),
66+
directory: 'tmp',
67+
type: 'theme-extension',
68+
options: {
69+
ignore: [],
70+
only: [],
71+
noDelete: false,
72+
host: '127.0.0.1',
73+
port: 9293,
74+
liveReload: 'hot-reload',
75+
open: false,
76+
themeEditorSync: false,
77+
errorOverlay: 'default',
78+
},
79+
}
80+
81+
describe('DNS rebinding protection', () => {
82+
const context = {...defaultServerContext}
83+
const server = createDevelopmentExtensionServer(developmentTheme, context)
84+
85+
test.each([
86+
['localhost:9293', 'localhost variant'],
87+
['127.0.0.1:9293', 'IPv4 loopback'],
88+
['[::1]:9293', 'IPv6 loopback'],
89+
['LOCALHOST:9293', 'case insensitive'],
90+
['localhost.:9293', 'trailing dot with port'],
91+
])('accepts %s (%s)', async (host) => {
92+
const response = await dispatchEvent(server, '/wpm@something', {host})
93+
expect(response.status).not.toBe(400)
94+
expect(response.status).toBe(204)
95+
})
96+
97+
test.each([
98+
['attacker.com:9293', 'attacker domain'],
99+
['poc.mzero.cloud:9293', 'DNS rebinding domain'],
100+
['localhost:1234', 'wrong port'],
101+
])('rejects %s (%s)', async (host) => {
102+
const response = await dispatchEvent(server, '/', {host})
103+
expect(response.status).toBe(400)
104+
})
105+
106+
test('rejects requests with missing Host header', async () => {
107+
const response = await dispatchEvent(server, '/')
108+
expect(response.status).toBe(400)
109+
})
110+
111+
test('accepts requests when --host flag is uppercase (LOCALHOST)', async () => {
112+
const uppercaseHostContext = {
113+
...defaultServerContext,
114+
options: {...defaultServerContext.options, host: 'LOCALHOST'},
115+
}
116+
const uppercaseServer = createDevelopmentExtensionServer(developmentTheme, uppercaseHostContext)
117+
const response = await dispatchEvent(uppercaseServer, '/wpm@something', {host: 'localhost:9293'})
118+
expect(response.status).not.toBe(400)
119+
expect(response.status).toBe(204)
120+
})
121+
})
122+
})

packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {getHtmlHandler} from '../theme-environment/html.js'
44
import {getAssetsHandler} from '../theme-environment/local-assets.js'
55
import {getProxyHandler} from '../theme-environment/proxy.js'
66
import {getHotReloadHandler, triggerHotReload} from '../theme-environment/hot-reload/server.js'
7+
import {createHostValidationHandler} from '../theme-environment/host-validation.js'
78
import {emptyThemeFileSystem} from '../theme-fs-empty.js'
89
import {initializeDevServerSession} from '../theme-environment/dev-server-session.js'
910
import {createApp, toNodeListener} from 'h3'
@@ -36,6 +37,7 @@ export async function initializeDevelopmentExtensionServer(theme: Theme, devExt:
3637
export function createDevelopmentExtensionServer(theme: Theme, ctx: DevServerContext) {
3738
const app = createApp()
3839

40+
app.use(createHostValidationHandler(ctx.options.host, ctx.options.port))
3941
app.use(getHotReloadHandler(theme, ctx))
4042
app.use(getAssetsHandler(theme, ctx))
4143
app.use(getProxyHandler(theme, ctx))

0 commit comments

Comments
 (0)