Fix localhost download redirects behind reverse proxies#498
Fix localhost download redirects behind reverse proxies#498jamiepine merged 1 commit intojamiepine:mainfrom
Conversation
Prefer x-forwarded host/proto for redirect URL construction so users are not sent to internal localhost origins. Fixes jamiepine#496
📝 WalkthroughWalkthroughA targeted fix that corrects download redirect URLs by properly detecting the public origin through proxy/CDN headers ( Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Fixes incorrect redirect origins for the /download/[platform] route when the app is deployed behind a reverse proxy/CDN, preventing redirects to internal upstream origins (e.g. localhost).
Changes:
- Added a
getPublicOrigin(request)helper that prefersx-forwarded-proto+x-forwarded-host. - Updated download and linux-install redirects to use the computed public origin instead of
request.url.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function getPublicOrigin(request: NextRequest): string { | ||
| const forwardedHost = request.headers.get('x-forwarded-host'); | ||
| const forwardedProto = request.headers.get('x-forwarded-proto'); | ||
|
|
||
| if (forwardedHost && forwardedProto) { | ||
| // Behind reverse proxies/CDNs, request.url can be an internal origin | ||
| // (for example localhost:8080). Prefer forwarded headers so redirects | ||
| // keep users on the public domain. | ||
| return `${forwardedProto}://${forwardedHost}`; | ||
| } |
| const forwardedHost = request.headers.get('x-forwarded-host'); | ||
| const forwardedProto = request.headers.get('x-forwarded-proto'); | ||
|
|
||
| if (forwardedHost && forwardedProto) { | ||
| // Behind reverse proxies/CDNs, request.url can be an internal origin | ||
| // (for example localhost:8080). Prefer forwarded headers so redirects | ||
| // keep users on the public domain. | ||
| return `${forwardedProto}://${forwardedHost}`; | ||
| } |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@landing/src/app/download/`[platform]/route.ts:
- Around line 18-30: The getPublicOrigin function currently trusts
x-forwarded-host and x-forwarded-proto directly; fix it by normalizing and
validating these headers before use: extract the first comma-separated value
from request.headers.get('x-forwarded-host') and
request.headers.get('x-forwarded-proto'), trim them, ensure proto is exactly
"http" or "https", and validate the host against a configured allowlist (from an
env var) of trusted public hosts; only when both normalized proto and host pass
validation should you return `${proto}://${host}`, otherwise fall back to new
URL(request.url).origin. Ensure you reference and update getPublicOrigin and the
header reads to perform these checks and rejection logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c59ff1e7-6ae2-49c9-b7a8-1acfeb785f90
📒 Files selected for processing (1)
landing/src/app/download/[platform]/route.ts
| function getPublicOrigin(request: NextRequest): string { | ||
| const forwardedHost = request.headers.get('x-forwarded-host'); | ||
| const forwardedProto = request.headers.get('x-forwarded-proto'); | ||
|
|
||
| if (forwardedHost && forwardedProto) { | ||
| // Behind reverse proxies/CDNs, request.url can be an internal origin | ||
| // (for example localhost:8080). Prefer forwarded headers so redirects | ||
| // keep users on the public domain. | ||
| return `${forwardedProto}://${forwardedHost}`; | ||
| } | ||
|
|
||
| return new URL(request.url).origin; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Look for existing forwarded-header handling, trusted-host config,
# or canonical public-origin configuration relevant to download redirects.
set -euo pipefail
rg -n -C3 '\b(x-forwarded-host|x-forwarded-proto|Forwarded|trustedHosts|allowedHosts|DOWNLOAD_PUBLIC_HOSTS|NEXT_PUBLIC_SITE|PUBLIC_URL|SITE_URL|BASE_URL)\b' \
--glob '!**/node_modules/**' \
--glob '!**/.next/**' \
--glob '!**/dist/**' \
--glob '!**/build/**' || true
fd -i '^(next\.config\.(js|mjs|ts)|middleware\.(ts|js)|vercel\.json|Caddyfile|.*nginx.*|.*traefik.*|docker-compose.*|.*\.conf)$' \
-x sh -c 'printf "\n--- %s ---\n" "$1"; sed -n "1,180p" "$1"' sh {}Repository: jamiepine/voicebox
Length of output: 3009
🏁 Script executed:
cat -n landing/src/app/download/[platform]/route.tsRepository: jamiepine/voicebox
Length of output: 2100
🏁 Script executed:
fd -type f -name "middleware*" landing/ || echo "No middleware files found"Repository: jamiepine/voicebox
Length of output: 275
🏁 Script executed:
rg -n "getPublicOrigin" landing/src/app/download/[platform]/route.ts -A 10 -B 2Repository: jamiepine/voicebox
Length of output: 1160
Validate forwarded headers before using them as a redirect origin.
Lines 19–20 and 26 read and use x-forwarded-host and x-forwarded-proto headers directly without validation. If an upstream reverse proxy misconfigures or fails to strip client-supplied forwarded headers, this becomes an open redirect vulnerability—attackers can craft requests with x-forwarded-host: attacker.com to redirect users to arbitrary domains. Additionally, comma-separated proxy chains are not handled, and invalid protocols are not restricted.
Normalize the headers to extract the first value, restrict to http/https only, and require an allowlist of trusted public hosts via environment configuration.
🛡️ Proposed hardening
+const ALLOWED_PUBLIC_HOSTS = new Set(
+ (process.env.DOWNLOAD_PUBLIC_HOSTS ?? '')
+ .split(',')
+ .map((host) => host.trim().toLowerCase())
+ .filter(Boolean),
+);
+
+function firstForwardedValue(value: string | null): string | null {
+ return value?.split(',')[0]?.trim() || null;
+}
+
function getPublicOrigin(request: NextRequest): string {
- const forwardedHost = request.headers.get('x-forwarded-host');
- const forwardedProto = request.headers.get('x-forwarded-proto');
+ const forwardedHost = firstForwardedValue(request.headers.get('x-forwarded-host'));
+ const forwardedProto = firstForwardedValue(request.headers.get('x-forwarded-proto'));
+ const fallbackOrigin = new URL(request.url).origin;
if (forwardedHost && forwardedProto) {
// Behind reverse proxies/CDNs, request.url can be an internal origin
// (for example localhost:8080). Prefer forwarded headers so redirects
// keep users on the public domain.
- return `${forwardedProto}://${forwardedHost}`;
+ try {
+ const origin = new URL(`${forwardedProto}://${forwardedHost}`);
+ const isHttpOrigin = origin.protocol === 'https:' || origin.protocol === 'http:';
+ const isAllowedHost = ALLOWED_PUBLIC_HOSTS.has(origin.host.toLowerCase());
+
+ if (isHttpOrigin && isAllowedHost) {
+ return origin.origin;
+ }
+ } catch {
+ // Fall back below when forwarded headers are malformed.
+ }
}
- return new URL(request.url).origin;
+ return fallbackOrigin;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function getPublicOrigin(request: NextRequest): string { | |
| const forwardedHost = request.headers.get('x-forwarded-host'); | |
| const forwardedProto = request.headers.get('x-forwarded-proto'); | |
| if (forwardedHost && forwardedProto) { | |
| // Behind reverse proxies/CDNs, request.url can be an internal origin | |
| // (for example localhost:8080). Prefer forwarded headers so redirects | |
| // keep users on the public domain. | |
| return `${forwardedProto}://${forwardedHost}`; | |
| } | |
| return new URL(request.url).origin; | |
| } | |
| const ALLOWED_PUBLIC_HOSTS = new Set( | |
| (process.env.DOWNLOAD_PUBLIC_HOSTS ?? '') | |
| .split(',') | |
| .map((host) => host.trim().toLowerCase()) | |
| .filter(Boolean), | |
| ); | |
| function firstForwardedValue(value: string | null): string | null { | |
| return value?.split(',')[0]?.trim() || null; | |
| } | |
| function getPublicOrigin(request: NextRequest): string { | |
| const forwardedHost = firstForwardedValue(request.headers.get('x-forwarded-host')); | |
| const forwardedProto = firstForwardedValue(request.headers.get('x-forwarded-proto')); | |
| const fallbackOrigin = new URL(request.url).origin; | |
| if (forwardedHost && forwardedProto) { | |
| // Behind reverse proxies/CDNs, request.url can be an internal origin | |
| // (for example localhost:8080). Prefer forwarded headers so redirects | |
| // keep users on the public domain. | |
| try { | |
| const origin = new URL(`${forwardedProto}://${forwardedHost}`); | |
| const isHttpOrigin = origin.protocol === 'https:' || origin.protocol === 'http:'; | |
| const isAllowedHost = ALLOWED_PUBLIC_HOSTS.has(origin.host.toLowerCase()); | |
| if (isHttpOrigin && isAllowedHost) { | |
| return origin.origin; | |
| } | |
| } catch { | |
| // Fall back below when forwarded headers are malformed. | |
| } | |
| } | |
| return fallbackOrigin; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@landing/src/app/download/`[platform]/route.ts around lines 18 - 30, The
getPublicOrigin function currently trusts x-forwarded-host and x-forwarded-proto
directly; fix it by normalizing and validating these headers before use: extract
the first comma-separated value from request.headers.get('x-forwarded-host') and
request.headers.get('x-forwarded-proto'), trim them, ensure proto is exactly
"http" or "https", and validate the host against a configured allowlist (from an
env var) of trusted public hosts; only when both normalized proto and host pass
validation should you return `${proto}://${host}`, otherwise fall back to new
URL(request.url).origin. Ensure you reference and update getPublicOrigin and the
header reads to perform these checks and rejection logic.
Fixes incorrect download redirects that can point users to internal origins like
https://localhost:8080/download?...in production.What changed
landing/src/app/download/[platform]/route.tsgetPublicOrigin(request)helper that prefers:x-forwarded-protox-forwarded-hostrequest.urlorigin when behind a proxy/CDN.Why
In some deployments,
request.urlcarries an internal upstream origin (for example localhost). Using forwarded headers ensures end-users stay on the public domain.Notes
Fixes #496
Summary by CodeRabbit