This commit is contained in:
@@ -4,6 +4,17 @@ type RouteContext = {
|
||||
params: Promise<{ path?: string[] }> | { path?: string[] }
|
||||
}
|
||||
|
||||
const SPOOFABLE_FORWARDING_HEADERS = new Set([
|
||||
'forwarded',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-proto',
|
||||
'x-real-ip',
|
||||
'cf-connecting-ip',
|
||||
'true-client-ip',
|
||||
'x-client-ip',
|
||||
])
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
'connection',
|
||||
'content-encoding',
|
||||
@@ -51,7 +62,8 @@ function getSetCookieHeaders(headers: Headers): string[] {
|
||||
function copyRequestHeaders(request: Request): Headers {
|
||||
const headers = new Headers()
|
||||
request.headers.forEach((value, key) => {
|
||||
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value)
|
||||
const normalized = key.toLowerCase()
|
||||
if (!HOP_BY_HOP_HEADERS.has(normalized) && !SPOOFABLE_FORWARDING_HEADERS.has(normalized)) headers.set(key, value)
|
||||
})
|
||||
return headers
|
||||
}
|
||||
@@ -59,7 +71,8 @@ function copyRequestHeaders(request: Request): Headers {
|
||||
function copyResponseHeaders(upstream: Response): Headers {
|
||||
const headers = new Headers()
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value)
|
||||
const normalized = key.toLowerCase()
|
||||
if (!HOP_BY_HOP_HEADERS.has(normalized) && !SPOOFABLE_FORWARDING_HEADERS.has(normalized)) headers.set(key, value)
|
||||
})
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('dashboard middleware', () => {
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/dashboard/sign-in?redirect=%2Fdashboard%2Freservations' })
|
||||
})
|
||||
|
||||
it('uses trusted forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
||||
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
||||
|
||||
const response = middleware(request('http://dashboard:3001/dashboard/billing', {
|
||||
@@ -98,7 +98,7 @@ describe('dashboard middleware', () => {
|
||||
},
|
||||
}) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.customer.example/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
||||
})
|
||||
|
||||
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
||||
@@ -119,7 +119,7 @@ describe('dashboard middleware', () => {
|
||||
|
||||
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard' })
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard' })
|
||||
})
|
||||
|
||||
it('allows public dashboard auth pages without a token', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000'
|
||||
const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard`
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
function toDashboardAppPath(pathname: string): string {
|
||||
@@ -29,26 +30,12 @@ function deduplicatePublicDashboardPath(pathname: string): string | null {
|
||||
return normalized || DASHBOARD_BASE_PATH
|
||||
}
|
||||
|
||||
function resolveProxyUrl(req: NextRequest, pathname: string): URL {
|
||||
const forwardedHost = req.headers.get('x-forwarded-host')
|
||||
const forwardedProto = req.headers.get('x-forwarded-proto')
|
||||
const websiteOrigin = new URL(WEBSITE_URL)
|
||||
|
||||
const url = new URL(pathname, req.nextUrl.origin)
|
||||
if (forwardedHost && !isInternalHost(forwardedHost)) {
|
||||
url.host = forwardedHost
|
||||
url.protocol = (forwardedProto ?? 'http') + ':'
|
||||
if (!hasExplicitPort(forwardedHost) || isInternalAppPort(url.port)) url.port = ''
|
||||
} else if (!isInternalHost(req.nextUrl.host)) {
|
||||
url.host = req.nextUrl.host
|
||||
url.protocol = req.nextUrl.protocol
|
||||
if (!hasExplicitPort(req.nextUrl.host) || isInternalAppPort(url.port)) url.port = ''
|
||||
} else {
|
||||
url.host = websiteOrigin.host
|
||||
url.protocol = websiteOrigin.protocol
|
||||
if (!hasExplicitPort(websiteOrigin.host) || isInternalAppPort(url.port)) url.port = ''
|
||||
}
|
||||
return url
|
||||
function resolveProxyUrl(_req: NextRequest, pathname: string): URL {
|
||||
const canonicalDashboard = new URL(DASHBOARD_PUBLIC_URL)
|
||||
const publicPath = pathname.startsWith(DASHBOARD_BASE_PATH)
|
||||
? pathname
|
||||
: toPublicDashboardPath(pathname)
|
||||
return new URL(publicPath, canonicalDashboard.origin)
|
||||
}
|
||||
|
||||
function hasExplicitPort(host: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user