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:
- the
host= parameter of the forwarded header,
- the first value of
x-forwarded-host,
- the
host header,
- 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.
Summary
Three places build "the public URL of this request" by assigning the public host and then clearing the port unconditionally:
Per the URL spec, assigning
hosta value that includes a port sets the port, and assigning one without a port leaves the previous port in place. So theport = ""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.getPublicHostFromRequestcan 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 OAuthredirect_uriand the auth relay are built from.Environment
upstream/main@96032013e2313891f816fcf8400412b44cf158a2nodefor the URL semantics and vitest for the existing tests. Not measured against a deployed instancev24.11.0The three sites
request.ts:14(getPublicUrlFromRequest,:9-15)proxy.ts:67(attachProxyUrl,:62-77)x-urlheader set on every request through the middlewaregetOriginFromHeader→ every provider'sredirect_uriauth-redirect.ts:70(rewriteAuthRedirectToPublicHost,:29-76)Locationafter email verification / magic link / password resetThe URL semantics, measured
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:host=parameter of theforwardedheader,x-forwarded-host,hostheader,"localhost:3123".normalizeHostonly 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.tssetsx-url;lib/domain.ts:15,29reads it back and exposesgetOriginFromHeader(); five provider libs build their base URL from it:With the port gone, that base URL is
http://localhostinstead ofhttp://localhost:3123, and theredirect_urisent to the provider does not match the one registered.The auth relay.
app/api/auth/[...all]/route.ts:89takesgetPublicUrlFromRequest(request)intoresolveRelayTarget(url, callbackURL)(:107), whose first test isif (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 byisAllowedOrigin(oauth-referer.ts:16, which compares againstnew URL(env.NEXT_PUBLIC_BUILDER_URL).origin— that one does keep its port). The request falls through toFALLBACK_REDIRECT.app/integrations/[...integration]/callback.ts:258takes the same value into the sameresolveRelayTarget(: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
:3000must 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:
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:67andauth-redirect.ts:70.We have this running on our fork (
proxy.tsandrequest.ts) with tests covering four cases throughx-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]:3123keeps its port — plus the same four end-to-end throughproxy()itself, reading back thex-urlit sets. The four pre-existing assertions inpackages/utils/__tests__/request.test.tsstill pass unchanged (8 passedin that file,4 passedin the newapps/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
app/integrations/whatsapp/callback/route.ts:86also callsgetPublicUrlFromRequest, but only readssearchParamsfrom the result (:87-89); itstargetOrigincomes fromstate.referer. We could not establish that the stripped port affects that file, and we are not claiming it does.x-forwarded-hostwith an internal port. That would be an argument for keeping the current behaviour in some configuration, and we have not ruled it out.Related issues
We searched open and closed issues and PRs for
proxy port,x-forwarded-hostandport, and found nothing describing this.