fix: strip internal container port from redirect URLs
Deploy to VPS / deploy (push) Has been cancelled

The site was redirecting / -> https://rf-flux.com:3000/en, where :3000
is the container's internal port (only "expose"d, not published) — so
the browser saw ERR_CONNECTION_REFUSED.

Root cause: when running behind Nginx in standalone mode, Next.js (via
next-intl in this case) can build absolute redirect URLs that leak the
container's internal PORT/HOSTNAME env into the Location header.

TWO LAYERS OF DEFENCE
1. Nginx (nginx/conf.d/flux.conf)
   - Adds X-Forwarded-Host + X-Forwarded-Port so the upstream knows
     the public port (443) and host
   - proxy_redirect rewrites any Location header that still slips
     through with :3000 back to the public https://$host

2. Middleware (src/proxy.ts)
   - sanitizeRedirectLocation() runs after handleI18nRouting and
     scrubs Location headers that point at internal hostnames (app /
     localhost / 0.0.0.0) or the container port :3000, replacing them
     with the public host derived from x-forwarded-host / host header.

Either layer alone would fix the immediate symptom; together they
also prevent the same class of bug from showing up in any future
redirect path.
This commit is contained in:
2026-05-04 16:32:45 -05:00
parent 5abd3a02f6
commit 62506f10b4
2 changed files with 61 additions and 2 deletions
+8
View File
@@ -175,8 +175,16 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Public-facing host + port so Next.js builds correct absolute
# redirect URLs (without leaking the internal container port 3000).
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
# Strip any leaked container port from upstream redirects, just in
# case Next.js still builds Location headers with :3000.
proxy_redirect ~^https?://[^/:]+:3000(/.*)$ https://$host$1;
} }
} }
+53 -2
View File
@@ -51,9 +51,60 @@ export async function proxy(request: NextRequest) {
// -------------------------------------------------------- // --------------------------------------------------------
// ZONA VERDE: WEB PÚBLICA (Motor de Traducciones i18n) // ZONA VERDE: WEB PÚBLICA (Motor de Traducciones i18n)
// -------------------------------------------------------- // --------------------------------------------------------
// Si el usuario no va al CMS, le pasamos el control a next-intl // Si el usuario no va al CMS, le pasamos el control a next-intl
// para que gestione los prefijos /en/, /it/, /vec/, etc. // para que gestione los prefijos /en/, /it/, /vec/, etc.
return handleI18nRouting(request); const response = handleI18nRouting(request);
// Belt-and-suspenders fix for the "redirect to :3000" bug behind Nginx.
// next-intl can occasionally compose absolute redirect URLs using the
// container's internal port (PORT=3000 from the Dockerfile) instead of
// the public-facing host. We sanitise any Location header before sending
// it back to the browser.
const location = response.headers.get('Location');
if (location) {
const cleaned = sanitizeRedirectLocation(location, request);
if (cleaned !== location) {
response.headers.set('Location', cleaned);
}
}
return response;
}
function sanitizeRedirectLocation(location: string, request: NextRequest): string {
try {
const forwardedHost =
request.headers.get('x-forwarded-host') || request.headers.get('host');
const forwardedProto =
request.headers.get('x-forwarded-proto') ||
(request.nextUrl.protocol ? request.nextUrl.protocol.replace(':', '') : 'https');
// Relative redirect — leave alone.
if (!/^https?:\/\//i.test(location)) return location;
const url = new URL(location);
const internalPorts = new Set(['3000', '80']);
const isInternalHost = url.hostname === '0.0.0.0' || url.hostname === 'localhost' || url.hostname === 'app';
const isInternalPort = url.port && internalPorts.has(url.port);
if (forwardedHost && (isInternalHost || isInternalPort)) {
const [hostOnly] = forwardedHost.split(':');
url.hostname = hostOnly;
url.port = '';
url.protocol = `${forwardedProto}:`;
return url.toString();
}
// Same host, just a stray internal port — strip it.
if (url.port === '3000') {
url.port = '';
return url.toString();
}
return location;
} catch {
return location;
}
} }
// -------------------------------------------------------- // --------------------------------------------------------