From 62506f10b4b4f2dad2b3d982a892a328f66f69d3 Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Mon, 4 May 2026 16:32:45 -0500 Subject: [PATCH] fix: strip internal container port from redirect URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- nginx/conf.d/flux.conf | 8 ++++++ src/proxy.ts | 55 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/nginx/conf.d/flux.conf b/nginx/conf.d/flux.conf index a5ad8fd..bfcab13 100644 --- a/nginx/conf.d/flux.conf +++ b/nginx/conf.d/flux.conf @@ -175,8 +175,16 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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_set_header Upgrade $http_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; } } diff --git a/src/proxy.ts b/src/proxy.ts index 0573645..88cdec2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -51,9 +51,60 @@ export async function proxy(request: NextRequest) { // -------------------------------------------------------- // 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. - 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; + } } // --------------------------------------------------------