Skip to content

Commit ecc1588

Browse files
committed
fix(server): enforce site mode behind proxies
1 parent 6f589ee commit ecc1588

2 files changed

Lines changed: 39 additions & 6 deletions

File tree

storage/framework/core/server/src/maintenance.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,13 @@ export async function launch(): Promise<void> {
254254
/**
255255
* Check if an IP address is allowed during maintenance
256256
*/
257-
export function isAllowedIp(ip: string, allowed: string[] = []): boolean {
258-
// Always allow localhost — including when no allowlist is configured. The
259-
// empty-list early-return used to sit above this check, which gated the
260-
// developer's own `buddy dev` session behind the coming-soon page.
257+
export function isAllowedIp(ip: string, allowed: string[] = [], trustLocalhost = true): boolean {
258+
// Localhost is convenient in development, but deployed reverse proxies
259+
// commonly reach the app over loopback. Production/staging callers must
260+
// opt out or every proxied visitor can inherit this bypass when the proxy
261+
// does not forward a client-IP header.
261262
const localhostIps = ['127.0.0.1', '::1', 'localhost']
262-
if (localhostIps.includes(ip)) {
263+
if (trustLocalhost && localhostIps.includes(ip)) {
263264
return true
264265
}
265266

@@ -606,7 +607,9 @@ export async function maintenanceGate(req: Request): Promise<Response | null> {
606607

607608
const cookies = parseCookieHeader(req.headers.get('cookie'))
608609
const hasCookie = !!payload.secret && hasValidBypassCookie(cookies, payload.secret, mode)
609-
const ipAllowed = isAllowedIp(clientIp(req), payload.allowed)
610+
const appEnv = (process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
611+
const trustLocalhost = !['production', 'staging'].includes(appEnv)
612+
const ipAllowed = isAllowedIp(clientIp(req), payload.allowed, trustLocalhost)
610613

611614
if (hasCookie || ipAllowed)
612615
return null

storage/framework/core/server/tests/server.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ describe('server maintenance', () => {
3939
expect(isAllowedIp('localhost', ['10.0.0.1'])).toBe(true)
4040
})
4141

42+
test('isAllowedIp can reject proxy loopback outside development', async () => {
43+
const { isAllowedIp } = await import('../src/maintenance')
44+
expect(isAllowedIp('127.0.0.1', [], false)).toBe(false)
45+
expect(isAllowedIp('::1', [], false)).toBe(false)
46+
expect(isAllowedIp('127.0.0.1', ['127.0.0.1'], false)).toBe(true)
47+
})
48+
4249
test('isAllowedIp checks against allowed list', async () => {
4350
const { isAllowedIp } = await import('../src/maintenance')
4451
expect(isAllowedIp('10.0.0.1', ['10.0.0.1', '10.0.0.2'])).toBe(true)
@@ -126,6 +133,29 @@ describe('server maintenance', () => {
126133
process.env.APP_COMING_SOON_SECRET = previousSecret
127134
})
128135

136+
test('maintenanceGate does not treat a production proxy as a localhost bypass', async () => {
137+
const { maintenanceGate } = await import('../src/maintenance')
138+
const previousMode = process.env.APP_COMING_SOON
139+
const previousAppEnv = process.env.APP_ENV
140+
141+
process.env.APP_COMING_SOON = 'true'
142+
process.env.APP_ENV = 'production'
143+
144+
const response = await maintenanceGate(new Request('http://127.0.0.1/'))
145+
expect(response?.status).toBe(302)
146+
expect(response?.headers.get('Location')).toBe('/coming-soon')
147+
148+
if (previousMode === undefined)
149+
delete process.env.APP_COMING_SOON
150+
else
151+
process.env.APP_COMING_SOON = previousMode
152+
153+
if (previousAppEnv === undefined)
154+
delete process.env.APP_ENV
155+
else
156+
process.env.APP_ENV = previousAppEnv
157+
})
158+
129159
test('maintenanceHtml includes retry info when provided', async () => {
130160
const { maintenanceHtml } = await import('../src/maintenance')
131161
const html = maintenanceHtml({ time: Date.now(), retry: 300 })

0 commit comments

Comments
 (0)