mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Reported from the iOS side, iOS #96. `GET /api/auth/native/login` answers location: https://0.0.0.0:3000/auth/login?flow=native behind a proxy that does not pass `Host` through. `NextResponse.redirect` needs an absolute URL, the route built one with `new URL(path, req.url)`, and `req.url` there is the address the Node process bound to. That is the primary onboarding path. ASWebAuthenticationSession follows the redirect, iOS refuses the restricted port, and the person lands on a browser error page with no way forward. Distributed builds carry no associated domains on purpose, so the browser handoff is also how passkeys are meant to work; without it a new account is down to a password and a confirmation code. Two things hid it. Every error branch of that route builds the fixed healthlog:// scheme with no host at all, so the route behaved correctly on every failure and broke only when it was supposed to succeed. And its own test built a request whose host matched, so the absolute URL it produced looked right. The assertion there used toContain, which is satisfied by the broken value just as happily as the correct one. The same line was in six more places: the error redirects of the Withings, WHOOP and Fitbit connect routes, which send a browser to /settings/integrations. Behind the same proxy those land on the same unreachable address, so all seven move to a relative Location. A relative Location is valid per RFC 7231 and the client resolves it against the address it actually called, which is the public one by definition. The alternative is reading X-Forwarded-Host, and that would put one more proxy setting between a self-hoster and a working sign-in. Three checks, as the report asked for. The route test now drives a request whose URL names the bind address, which is what a proxy hop looks like from inside the handler. A structural test freezes the pattern out of the four files and, separately, requires each of them to still reach for the helper, because a file that simply deleted its redirect would pass the first check and fail its users. The helper refuses a protocol-relative value, which is a host in disguise. Verified by breaking it: putting the old call back fails four checks, the last of them reporting the reported value verbatim. Refs iOS #96.
56 lines
2.4 KiB
TypeScript
56 lines
2.4 KiB
TypeScript
/**
|
|
* A redirect that does not guess the host it is being reached on.
|
|
*
|
|
* `NextResponse.redirect()` demands an absolute URL, and the obvious way to
|
|
* satisfy it is `new URL("/somewhere", req.url)`. Behind a reverse proxy that
|
|
* is wrong, and wrong in the way that is hardest to notice: it works on a
|
|
* developer machine, it works in every test that builds its own request, and
|
|
* it fails only on a deployment where the proxy does not pass `Host` through.
|
|
* There `req.url` is the address the Node process bound to, so the redirect
|
|
* comes back as
|
|
*
|
|
* location: https://0.0.0.0:3000/auth/login?flow=native
|
|
*
|
|
* which a browser cannot follow and iOS refuses outright ("the restricted
|
|
* network port is not allowed"). It was found on the native sign-in handoff,
|
|
* the primary onboarding path, where the error branches all built a fixed
|
|
* `healthlog://` scheme with no host at all. So the route behaved correctly on
|
|
* every failure and only broke when it was supposed to succeed.
|
|
*
|
|
* A relative `Location` is valid per RFC 7231 section 7.1.2 and is resolved by
|
|
* the client against the URL it actually requested, which is by definition the
|
|
* public one. That needs no configuration, which matters here: the alternative
|
|
* is reading `X-Forwarded-Host`, and every self-hoster would then have one more
|
|
* proxy setting to get right before sign-in works.
|
|
*
|
|
* Use this for any redirect to a path on this same deployment. An absolute URL
|
|
* is still correct when the target is genuinely elsewhere, such as a provider's
|
|
* OAuth authorise endpoint.
|
|
*/
|
|
import { NextResponse } from "next/server";
|
|
|
|
/**
|
|
* 307 to a path on this deployment, without naming a host.
|
|
*
|
|
* Returns a `NextResponse`, so callers that need to attach cookies to the
|
|
* redirect keep doing exactly that.
|
|
*
|
|
* 307 rather than 302 preserves the method, matching what
|
|
* `NextResponse.redirect()` sends by default; nothing here should silently turn
|
|
* a POST into a GET.
|
|
*/
|
|
export function relativeRedirect(path: string): NextResponse {
|
|
if (!path.startsWith("/") || path.startsWith("//")) {
|
|
// A protocol-relative value ("//evil.example") is a host in disguise, and
|
|
// anything not starting with a slash resolves against the current path
|
|
// rather than the root. Both are caller mistakes worth failing loudly.
|
|
throw new Error(
|
|
`relativeRedirect expects a root-relative path, received: ${path}`,
|
|
);
|
|
}
|
|
return new NextResponse(null, {
|
|
status: 307,
|
|
headers: { Location: path },
|
|
});
|
|
}
|