From 9bb820f9914a96eaa3f692a58de4a445274bcd1e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 22 May 2026 13:39:21 +0000 Subject: [PATCH] fix(github): use public URL for OAuth + manifest callback redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind Railway's reverse proxy, Next.js's request.url reports the internal bind address (e.g. http://0.0.0.0:8080/...), not the public URL. Using `new URL(path, request.url)` to build redirect Location headers therefore sent users to the bind address — visible to the admin during the App-manifest "App created" redirect. Adds lib/request-url.ts with publicBaseUrlFromHeaders() and publicUrl() helpers that read x-forwarded-host + x-forwarded-proto (Railway's proxy adds these), ignoring 0.0.0.0 / 127.0.0.1 / localhost so they can never be baked into a Location. Patched both callbacks to use publicUrl(): - /api/github/setup-callback: post-manifest redirect to /admin/github/setup/done - /api/github/callback: post-install redirect to /settings/integrations/github The /admin/github/setup manifest builder already had its own equivalent fix in PR #8 (publicBaseUrl()); the callback redirects were missed. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/github/callback/route.ts | 13 +++---- app/api/github/setup-callback/route.ts | 15 +++----- lib/request-url.ts | 47 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 lib/request-url.ts diff --git a/app/api/github/callback/route.ts b/app/api/github/callback/route.ts index 6543e08b..8049d1ed 100644 --- a/app/api/github/callback/route.ts +++ b/app/api/github/callback/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { getInstallation } from "@/lib/github/app"; import { upsertInstallation } from "@/lib/github/installations"; +import { publicUrl } from "@/lib/request-url"; export const runtime = "nodejs"; @@ -23,7 +24,7 @@ export async function GET(request: NextRequest) { if (!installationId) { return NextResponse.redirect( - new URL(`${SETTINGS_URL}?error=missing_installation_id`, request.url), + publicUrl(request.headers, `${SETTINGS_URL}?error=missing_installation_id`), ); } @@ -36,7 +37,7 @@ export async function GET(request: NextRequest) { // Park them at login with this callback as the redirect. const back = `${SETTINGS_URL}?installation_id=${installationId}`; return NextResponse.redirect( - new URL(`/login?redirect=${encodeURIComponent(back)}`, request.url), + publicUrl(request.headers, `/login?redirect=${encodeURIComponent(back)}`), ); } @@ -44,7 +45,7 @@ export async function GET(request: NextRequest) { // don't own — there's nothing for us to persist yet. if (setupAction === "request") { return NextResponse.redirect( - new URL(`${SETTINGS_URL}?notice=install_requested`, request.url), + publicUrl(request.headers, `${SETTINGS_URL}?notice=install_requested`), ); } @@ -60,14 +61,14 @@ export async function GET(request: NextRequest) { } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; return NextResponse.redirect( - new URL( + publicUrl( + request.headers, `${SETTINGS_URL}?error=${encodeURIComponent("install_callback:" + msg)}`, - request.url, ), ); } return NextResponse.redirect( - new URL(`${SETTINGS_URL}?connected=1`, request.url), + publicUrl(request.headers, `${SETTINGS_URL}?connected=1`), ); } diff --git a/app/api/github/setup-callback/route.ts b/app/api/github/setup-callback/route.ts index ccba5a69..e9099aa2 100644 --- a/app/api/github/setup-callback/route.ts +++ b/app/api/github/setup-callback/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import { convertAppManifest } from "@/lib/github/app"; +import { publicUrl } from "@/lib/request-url"; export const runtime = "nodejs"; @@ -13,10 +14,7 @@ export async function GET(request: NextRequest) { const code = new URL(request.url).searchParams.get("code"); if (!code) { return NextResponse.redirect( - new URL( - "/admin/github/setup?error=missing_code", - request.url, - ), + publicUrl(request.headers, "/admin/github/setup?error=missing_code"), ); } try { @@ -35,17 +33,14 @@ export async function GET(request: NextRequest) { owner: result.owner.login, }); return NextResponse.redirect( - new URL( - `/admin/github/setup/done#${params.toString()}`, - request.url, - ), + publicUrl(request.headers, `/admin/github/setup/done#${params.toString()}`), ); } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; return NextResponse.redirect( - new URL( + publicUrl( + request.headers, `/admin/github/setup?error=${encodeURIComponent(msg)}`, - request.url, ), ); } diff --git a/lib/request-url.ts b/lib/request-url.ts new file mode 100644 index 00000000..f4cb5943 --- /dev/null +++ b/lib/request-url.ts @@ -0,0 +1,47 @@ +// Behind Railway's reverse proxy, Next.js's `request.url` reports the +// internal bind address (e.g. http://0.0.0.0:8080/...) instead of the +// public URL. That's correct per HTTP semantics — the Host header IS +// 0.0.0.0:8080 from the upstream perspective — but it breaks any code +// that uses `request.url` to build a public Location header on a +// redirect, because the browser then gets sent to the bind address. +// +// This helper picks the correct base by preferring x-forwarded-host + +// x-forwarded-proto (the values Railway's proxy adds), falling back to +// the raw Host header. It ignores 0.0.0.0 / 127.0.0.1 hostnames so they +// can never get baked into a Location header. + +import { env } from "@/lib/env"; + +interface HeaderSource { + get(name: string): string | null; +} + +export function publicBaseUrlFromHeaders(headers: HeaderSource): string { + const fwdHost = headers.get("x-forwarded-host"); + const fwdProto = headers.get("x-forwarded-proto"); + const rawHost = headers.get("host"); + + const candidate = fwdHost ?? rawHost; + const proto = fwdProto ?? "https"; + + if ( + candidate && + !candidate.startsWith("0.0.0.0") && + !candidate.startsWith("127.0.0.1") && + !candidate.startsWith("localhost") + ) { + return `${proto}://${candidate}`.replace(/\/$/, ""); + } + // Last-ditch fallback to the env (dev case where headers are sparse). + return env.siteUrl.replace(/\/$/, ""); +} + +/** Build an absolute URL anchored at the public base. Use this instead + * of `new URL(path, request.url)` when constructing redirect Location + * values — `request.url` carries the internal host behind a proxy. */ +export function publicUrl(headers: HeaderSource, path: string): string { + const base = publicBaseUrlFromHeaders(headers); + // path may include a fragment (#...) — preserve verbatim, don't pass + // through URL() which would percent-encode the fragment payload. + return `${base}${path.startsWith("/") ? "" : "/"}${path}`; +}