Skip to content

fix(auth): resend-invite returns 400 behind a reverse proxy - #122

Open
KubaBir wants to merge 2 commits into
mainfrom
jb/fix-resend-invite-origin
Open

KubaBir wants to merge 2 commits into
mainfrom
jb/fix-resend-invite-origin

Conversation

@KubaBir

@KubaBir KubaBir commented Sep 8, 2026

Copy link
Copy Markdown

Problem

POST /api/auth/users/resend-invite answers 400 {"error":"Invalid request origin"} on any deployment behind a reverse proxy in production. Creating a user still sends the invite email. Only the "Resend invite" button breaks. We hit this on Edube prod (Cloud Run, APP_URL=https://edube.me) and locally under Conductor, and carried a patch-package workaround until now. Present in 0.6.7 and 0.7.0.

Log line from prod:

Origin check rejected string input
requestUrl=https://localhost:3000/api/auth/users/resend-invite
allowedOrigins=['https://edube.me']

Cause

The route passed req.url to getSecurityEmailBaseUrl. Every other caller (reset.ts, requestRedirect.ts, customer_accounts/api/signup.ts, onboarding) passes req.

The origin check in @open-mercato/shared/lib/url can read two things. Given a Request it also reads Host, X-Forwarded-Host and X-Forwarded-Proto, finds the public origin there, and then tolerates a loopback URL origin. Given a string it only has the URL origin.

Behind a proxy the URL origin is never the public host. Next.js builds a route handler's request URL from its own listen hostname and port (next-server.js, ${protocol}://${this.fetchHostname}:${this.port}${req.url}), so under next start it is https://localhost:3000/.... With NODE_ENV=production and a non-loopback APP_URL, no exemption applies and the route maps the throw to 400.

Fix

One word in packages/core/src/modules/auth/api/users/resend-invite/route.ts:

-    base = getSecurityEmailBaseUrl(req.url)
+    base = getSecurityEmailBaseUrl(req)

Interface change (review follow-up)

The helper took Request | string | undefined. A string can never satisfy the check behind a proxy, so the overload existed only to be misused. The second commit narrows getSecurityEmailBaseUrl, toSecurityEmailUrl and assertAllowedAppOrigin to Request | undefined and drops the string branches. Passing a string is now a compile error. Callers with no request at all (the CLI user command, the onboarding ready email) still pass nothing.

These helpers are not in the BACKWARD_COMPATIBILITY.md signature table. A third-party caller passing a string would fail to compile after this change, and would have been getting 400s in production anyway.

Verification

Added a case to resend-invite.route.test.ts: production mode, APP_URL=https://app.example, request URL https://localhost:3000/..., headers host: app.example and x-forwarded-proto: https. It returned 400 and logged the exact warning above before the change. It passes now.

  • resend-invite route suite: 14 passed, shared url suites: 54 passed, signup source check: 3 passed
  • eslint on changed files: clean
  • tsc on shared and onboarding: 0 errors; core shows only its pre-existing unrelated errors, none in the origin-check callers

Labels

skip-qa: no UI file touched, no schema or API change, regression test in the same PR. risk-low: one call-site change aligning with every sibling route.

🤖 Generated with Claude Code

`POST /api/auth/users/resend-invite` answered `400 Invalid request origin`
behind any reverse proxy in production. Creating a user sent the invite
fine; only the resend path broke.

The route handed `getSecurityEmailBaseUrl` the URL string instead of the
request. With a string the origin check only sees the URL origin, and
Next.js builds a route handler's URL from its own listen host and port,
so behind a proxy that origin is `https://localhost:3000`, never the
public host. The loopback exemption needs the `Host` / `X-Forwarded-*`
headers to find an allowed origin, and a string carries none, so the
check threw.

Pass the request, as every other caller already does. Add a route test
for the proxy case: production mode, public `APP_URL`, loopback request
URL, public host in the headers. It returned 400 before this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@KubaBir KubaBir added bug Something isn't working review Ready for code review priority-medium Normal product priority risk-low skip-qa QA not required labels Sep 8, 2026

@jtomaszewski jtomaszewski left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it just here? perhaps getSecurityEmailBaseUrl shouldnt accept url as argument if that causes bugs?

@KubaBir

KubaBir commented Sep 8, 2026

Copy link
Copy Markdown
Author

Is it just here? perhaps getSecurityEmailBaseUrl shouldnt accept url as argument if that causes bugs?

It accepts either string or the full request. The other callers used the full req object so the function can get the proper url from the headers. This invocation was the only instance which passed the raw (incorrect) url string - in this case the url string was incorrect because of the proxy we have

Behind a proxy the URL origin is never the public host. Next.js builds a route handler's request URL from its own listen hostname and port (next-server.js, ${protocol}://${this.fetchHostname}:${this.port}${req.url}), so under next start it is https://localhost:3000/.... With NODE_ENV=production and a non-loopback APP_URL, no exemption applies and the route maps the throw to 400.

@jtomaszewski

Copy link
Copy Markdown

yeah then change the interface of getSecurityEmailBaseUrl so it doesnt allow for incorrect invocation.

`getSecurityEmailBaseUrl`, `toSecurityEmailUrl` and `assertAllowedAppOrigin`
took `Request | string | undefined`. A string input can never pass the
check behind a reverse proxy, because the public host only ever arrives
in the headers, so the string overload existed only to be misused. The
resend-invite route was the one caller that did.

Narrow the input to `Request | undefined` and drop the string branches.
Callers with no request context, such as the CLI user command and the
onboarding ready email, still pass nothing. Passing a string is now a
compile error.

Requested in review by jtomaszewski.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@KubaBir

KubaBir commented Sep 9, 2026

Copy link
Copy Markdown
Author

Done in 4ffc50c. RequestInput is now Request | undefined, the string branches are gone, and passing a string no longer compiles. The no-argument form stays for callers without a request (CLI user command, onboarding ready email). Shared and onboarding typecheck clean, url and route suites green.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

No description provided.

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

Labels

bug Something isn't working priority-medium Normal product priority review Ready for code review risk-low skip-qa QA not required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants