Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

Our auth emails ignore the request that asked for them, and that is the entire security property

Most of PubTrivia is a quiz that runs in one tab. Four flows are not: the Google sign-in return, the confirmation email after signup, the password reset email, and the team invite link. All four leave the site and have t

Most of PubTrivia is a quiz that runs in one tab. Four flows are not: the Google sign-in return, the confirmation email after signup, the password reset email, and the team invite link. All four leave the site and have to come back, which means something in the codebase has to produce an absolute origin and put it in a URL that a user will later click from their inbox.

There are only three places that origin can come from. A constant. An environment variable. Or the request that is asking.

Picking the third one is how you end up with a password reset link that points at somebody else's domain.

The function

Thirty-one lines, lib/auth-origin.ts, and the interesting half is the first line of the body:

export function authRedirectOrigin(requestHeaders: Headers): string {
    if (process.env.NODE_ENV === 'production') return SITE_URL

    const host = firstValue(requestHeaders.get('x-forwarded-host')) ?? firstValue(requestHeaders.get('host'))
    if (!host) return SITE_URL

    const proto = firstValue(requestHeaders.get('x-forwarded-proto')) ?? 'http'
    return `${proto}://${host}`
}

In production the argument is accepted and then never read. The function takes a Headers object so that every call site looks the same, and in the only environment that sends real email to real people, the headers cannot influence the answer. SITE_URL is derived once, in one file, from NEXT_PUBLIC_WEBSITE_URL with a fallback that follows NODE_ENV, so a production build with the variable missing still says https://pub-trivia.app rather than claiming to live on localhost.

Outside production the request wins, which is the opposite rule, and the reason is a bug rather than a principle.

The bug that produced it

.env.local on my laptop sets NEXT_PUBLIC_WEBSITE_URL to the production URL. Not by accident: the IndexNow submitter script reads the same variable to work out which origin to tell search engines about, and pointing it at localhost would make that script useless.

So SITE_URL on the dev server was https://pub-trivia.app. Every auth redirect was built from SITE_URL. Click "Sign in with Google" on localhost:3000, approve the consent screen, and Google sent the browser to production, where the session cookie was set on the live site. The dev server you were testing stayed signed out, and you were now signed into the real app with the account you were using for test data.

Nothing was broken in production and nothing leaked. It was just impossible to test a sign-in locally, and the failure looked like "Google OAuth is broken" rather than "your environment file is correct and your origin is not".

The fix is the three lines above the production guard. On a dev server the request arrived on localhost:3000, so send it back there. Behind a tunnel, the request arrived on an ngrok hostname, so send it back there instead.

The comma

Forwarded headers are lists. A proxy chain appends rather than replaces, so a request that has been through two hops carries this:

x-forwarded-host: abc.ngrok.app, localhost:3000
x-forwarded-proto: https,http

The client's own value is the first entry. Read the raw header and you get a string with a comma in it, which then gets interpolated into a URL and produces a link nobody can click. One four-line helper handles it, and its doc comment is the whole explanation:

/** Proxies may append to these headers, giving "a, b". The first entry is the client's. */
function firstValue(header: string | null): string | null {
    const value = header?.split(',')[0]?.trim()
    return value ? value : null
}

Note that the protocol list in the real example is https,http with no space while the host list has one. Trimming is not optional.

Four tests, and one of them is the security test

The whole file is pure: headers in, string out, no I/O. So the test is four cases and runs in no measurable time.

it('ignores the request host in production, so a forged Host cannot reach an auth email', () => {
    vi.stubEnv('NODE_ENV', 'production')
    const headers = new Headers({ host: 'evil.example', 'x-forwarded-host': 'evil.example' })
    expect(authRedirectOrigin(headers)).toBe(SITE_URL)
})

That is the one that matters. Host header injection into an auth email is an old trick with a very good payoff: the attacker never has to touch the victim's session, they just have to get a link that a victim already trusts to point somewhere the attacker controls. The password reset email is the ideal vehicle, because the victim is expecting it and is in a hurry.

Our hosting validates the Host header before a request ever reaches the app, so in practice an arbitrary value does not arrive. That is exactly why the line is worth writing: the defence costs one comparison against NODE_ENV, it does not depend on a platform behaviour staying true, and the thing it protects is a link in somebody else's inbox.

The other three tests cover the dev-server path, the tunnel path, and the no-host path falling back to SITE_URL.

The four call sites

Three in app/auth/actions.ts:

redirectTo: `${authRedirectOrigin(await headers())}/forgot-password/reset`,
emailRedirectTo: `${authRedirectOrigin(await headers())}/auth/callback?next=${encodeURIComponent(nextPath)}`,
redirectTo: `${authRedirectOrigin(await headers())}/auth/callback`,

And one route handler, GET /auth/invite, which builds four different redirects from the same origin: to /signup with the invite token preserved when the visitor has no account yet, to an error page with a reason when the token is refused, and to /dashboard when it works.

That route is the one place where the origin is used for a plain redirect rather than for an email. It still goes through the same function, because a redirect built from a request header is the same bug wearing different clothes.

Check it yourself

The property is observable from outside, which is the nice thing about having moved it into a constant. Ask for a page with a forged forwarded host and the page still knows what site it is:

$ curl -s -H 'X-Forwarded-Host: evil.example' https://pub-trivia.app/login \
    | grep -oE 'rel="canonical" href="[^"]*"|og:url" content="[^"]*"'
rel="canonical" href="https://pub-trivia.app/login"
og:url" content="https://pub-trivia.app/login"

The thorough version takes a minute and a throwaway email address. Create an account on the signup page, or ask for a reset on the forgot-password page, and read the link in the message that arrives. It is built from a constant in a file, not from anything you sent, and it will say pub-trivia.app no matter what your client claims to be. The free tier needs no card, so you can get all the way through sign-in and look at what the app does next.

One line of defence, in the one environment where email is real.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.