Skip to content

Public URLs drop their port: .port = "" after assigning the public host removes :3123 (and any non-default port) from OAuth redirect_uri and auth redirects #1152

Description

@rawdaymx

Summary

Three places build "the public URL of this request" by assigning the public host and then clearing the port unconditionally:

url.host = getPublicHostFromRequest(request)
url.protocol = getPublicProtocolFromRequest(request)
url.port = ""

Per the URL spec, assigning host a value that includes a port sets the port, and assigning one without a port leaves the previous port in place. So the port = "" line is defending against a real leak — the internal port surviving into the public URL — but it also deletes a port the public host legitimately carries.

getPublicHostFromRequest can legitimately return a host with a port from all four of its sources, and its own fallback is "localhost:3123" — a value the very next line then discards.

The result is that on any deployment not served on 80/443 — which includes every pnpm dev — the public URL the app computes for itself is wrong, and that URL is what the OAuth redirect_uri and the auth relay are built from.

Environment

Measured against upstream/main @ 96032013e2313891f816fcf8400412b44cf158a2
How reading the repository at that commit, plus node for the URL semantics and vitest for the existing tests. Not measured against a deployed instance
Node v24.11.0
Date 2026-09-11

The three sites

$ git grep -n '\.port = ""' 96032013e -- apps packages integrations
apps/builder/src/lib/auth-redirect.ts:70
apps/builder/src/proxy.ts:67
packages/utils/src/request.ts:14
Site Builds Feeds
request.ts:14 (getPublicUrlFromRequest, :9-15) the shared "public URL of this request" the auth route and the OAuth callback below
proxy.ts:67 (attachProxyUrl, :62-77) the x-url header set on every request through the middleware getOriginFromHeader → every provider's redirect_uri
auth-redirect.ts:70 (rewriteAuthRedirectToPublicHost, :29-76) the rewritten Location after email verification / magic link / password reset the browser

The URL semantics, measured

$ node -e 'const u=new URL("http://127.0.0.1:3123/x"); u.host="app.example.com"; console.log(u.toString())'
http://app.example.com:3123/x        ← without `port = ""`, the internal port leaks

$ node -e 'const u=new URL("http://127.0.0.1:3123/x"); u.host="localhost:3123"; u.port=""; console.log(u.toString())'
http://localhost/x                   ← with it, a legitimate public port is lost

Both halves are real. That is why we are not proposing to delete the line.

The public host can carry a port — including by default

getPublicHostFromRequest (packages/utils/src/request.ts:40-58) resolves, in order:

  1. the host= parameter of the forwarded header,
  2. the first value of x-forwarded-host,
  3. the host header,
  4. and failing all three, the literal "localhost:3123".

normalizeHost only trims and lowercases; it never strips a port. Every one of those four can carry one, and the fourth always does.

What breaks

OAuth redirect_uri, for five providers. proxy.ts sets x-url; lib/domain.ts:15,29 reads it back and exposes getOriginFromHeader(); five provider libs build their base URL from it:

$ git grep -n 'getOriginFromHeader()' 96032013e -- apps/builder/src/features
features/integration-instagram/libs/oauth-facebook.ts:21
features/integration-instagram/libs/oauth.ts:21
features/integration-messenger/libs/oauth.ts:15
features/integration-tiktok/libs/tiktok.ts:21
features/integration-zalo/libs/zalo.ts:13

With the port gone, that base URL is http://localhost instead of http://localhost:3123, and the redirect_uri sent to the provider does not match the one registered.

The auth relay. app/api/auth/[...all]/route.ts:89 takes getPublicUrlFromRequest(request) into resolveRelayTarget(url, callbackURL) (:107), whose first test is if (url.host === refererUrl.host) return null (oauth-referer.ts:66). With the port stripped from one side only, "localhost" !== "localhost:3123", so the relay fires when it should not, and the origin it relays to is then rejected by isAllowedOrigin (oauth-referer.ts:16, which compares against new URL(env.NEXT_PUBLIC_BUILDER_URL).origin — that one does keep its port). The request falls through to FALLBACK_REDIRECT.

app/integrations/[...integration]/callback.ts:258 takes the same value into the same resolveRelayTarget (:294), with the same consequence.

Why we think this is a defect rather than the intended design

Clearing the port is clearly deliberate, and the leak it prevents is real — behind a reverse proxy, an internal :3000 must not reach a public URL. We are not suggesting the line was gratuitous.

What makes it a defect rather than a trade-off is that the information needed to do both is already in hand at that exact point: the public host string being assigned one line earlier either has a port or does not. Nothing has to be inferred. And the function's own fallback value is a host with a port, which is hard to read as a case the authors intended to discard.

The existing test for this function only asserts the protocol, never the port — so the behaviour is not pinned either way:

$ git show 96032013e:packages/utils/__tests__/request.test.ts | sed -n '58,66p'
describe("getPublicUrlFromRequest", () => {
  test("applies forced HTTPS to the returned public URL", () => {
    process.env.FORCE_PUBLIC_HTTPS = "true"

    expect(
      getPublicUrlFromRequest(new Request("http://internal.test/callback"))
        .protocol,
    ).toBe("https:")
  })
})

$ git show 96032013e:packages/utils/__tests__/request.test.ts | grep -c 'port'
0   (the only match in the file is the word inside `import`)

Suggested fix

Take the port from the public host instead of clearing it, and delegate the parsing to the URL parser rather than searching for a ":" — a bracketed IPv6 literal ([::1]) contains colons that are not port separators, and a malformed port must clear rather than leave the internal one in place.

 export function getPublicUrlFromRequest(request: Request): URL {
   const url = new URL(request.url)
   url.host = getPublicHostFromRequest(request)
   url.protocol = getPublicProtocolFromRequest(request)
-  url.port = ""
+  url.port = getPublicPortFromRequest(request)
   return url
 }

+export function getPublicPortFromRequest(request: Request): string {
+  const host = getPublicHostFromRequest(request)
+  try {
+    return new URL(`http://${host}`).port
+  } catch {
+    return ""
+  }
+}

…and the same substitution at proxy.ts:67 and auth-redirect.ts:70.

We have this running on our fork (proxy.ts and request.ts) with tests covering four cases through x-forwarded-host — public host with a port keeps it; public host without one does not inherit the internal port; [::1] is not mistaken for a port-bearing host; [::1]:3123 keeps its port — plus the same four end-to-end through proxy() itself, reading back the x-url it sets. The four pre-existing assertions in packages/utils/__tests__/request.test.ts still pass unchanged (8 passed in that file, 4 passed in the new apps/builder/__tests__/proxy-public-url.test.ts).

Happy to open the PR. It would cover all three sites — we had only patched two ourselves and found the third while writing this up.

What we did not verify

  • We did not reproduce this against a running deployment. No provider OAuth round-trip was executed; the breakage described under "What breaks" is traced through the code, not observed. If you would rather we ran it before you spend time on it, say so and we will.
  • app/integrations/whatsapp/callback/route.ts:86 also calls getPublicUrlFromRequest, but only reads searchParams from the result (:87-89); its targetOrigin comes from state.referer. We could not establish that the stripped port affects that file, and we are not claiming it does.
  • We did not check whether any deployment relies on the port being stripped — for example a reverse proxy that forwards x-forwarded-host with an internal port. That would be an argument for keeping the current behaviour in some configuration, and we have not ruled it out.
  • We did not test IPv6 or half-open hosts against a live server, only through the URL parser.

Related issues

We searched open and closed issues and PRs for proxy port, x-forwarded-host and port, and found nothing describing this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions