mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Stop redirecting to the address the process bound to
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.
This commit is contained in:
1 parent
4d2fc06fe7
commit
cc8c69aa07
7 files changed
+206
-16
No files matched your search
@@ -49,7 +49,11 @@ describe("GET /api/auth/native/login", () => {
|
||||
it("valid challenge → 302 to the web login page with flow=native + state cookie", async () => {
|
||||
const res = await GET(req());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain("/auth/login?flow=native");
|
||||
// Exact, not `toContain`. The looser form was what let iOS #96 ship: it is
|
||||
// satisfied by "http://0.0.0.0:3000/auth/login?flow=native" just as
|
||||
// happily, so it asserted the path was in there rather than that a host
|
||||
// was not.
|
||||
expect(location).toBe("/auth/login?flow=native");
|
||||
// No custom scheme on the success path.
|
||||
expect(location.startsWith("healthlog://")).toBe(false);
|
||||
|
||||
@@ -81,6 +85,35 @@ describe("GET /api/auth/native/login", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("behind a proxy that drops Host, the redirect still points at the caller's origin", async () => {
|
||||
// What a proxy hop looks like from inside the handler: the request the
|
||||
// route sees names the address the Node process bound to, because the
|
||||
// public Host header never made it through. Building the redirect out of
|
||||
// that URL is how iOS #96 happened, and `ASWebAuthenticationSession`
|
||||
// refuses the resulting address outright.
|
||||
//
|
||||
// Every other test in this file builds `http://localhost/...`, where the
|
||||
// host is harmless and the absolute URL looked correct. That is why the
|
||||
// route shipped broken.
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
`http://0.0.0.0:3000/api/auth/native/login?code_challenge=${VALID_CHALLENGE}`,
|
||||
),
|
||||
);
|
||||
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toBe("/auth/login?flow=native");
|
||||
expect(
|
||||
location,
|
||||
"a relative Location resolves against the address the client actually called",
|
||||
).not.toMatch(/^https?:\/\//);
|
||||
expect(location).not.toContain("0.0.0.0");
|
||||
|
||||
// The state cookie still rides the refusal-free path, since the whole
|
||||
// point is that the success branch keeps working behind a proxy.
|
||||
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rate-limited → scheme error, no state cookie, no DB write", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* carries no code or session, so it leaks nothing.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
@@ -58,9 +59,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// `Session.createdAt` (also DB-side) at completion, with no app/DB skew.
|
||||
const startedAt = await nativeHandoffDbNow();
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/auth/login?flow=native", req.url),
|
||||
);
|
||||
const response = relativeRedirect("/auth/login?flow=native");
|
||||
response.cookies.set(
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
encodeNativeHandoffState({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mintFitbitOAuthStateNonce,
|
||||
} from "@/lib/fitbit/oauth-state";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/**
|
||||
@@ -59,8 +60,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const creds = await getUserFitbitCredentials(user.id);
|
||||
if (!creds) {
|
||||
annotate({ action: { name: "fitbit.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?fitbit=error&reason=nocreds",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,8 +79,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ action: { name: "fitbit.connect.create_failed" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?fitbit=error&reason=connect", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?fitbit=error&reason=connect",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/whoop/oauth-state";
|
||||
import { validateReturnScheme } from "@/lib/whoop/return-scheme";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/**
|
||||
@@ -93,8 +94,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const creds = await getUserWhoopCredentials(userId);
|
||||
if (!creds) {
|
||||
annotate({ action: { name: "whoop.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?whoop=error&reason=nocreds", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?whoop=error&reason=nocreds",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,8 +117,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ action: { name: "whoop.connect.create_failed" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?whoop=error&reason=connect", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?whoop=error&reason=connect",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mintWithingsOAuthStateNonce,
|
||||
} from "@/lib/withings/oauth-state";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/**
|
||||
@@ -64,8 +65,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
annotate({ action: { name: "withings.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?withings=error&reason=nocreds", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?withings=error&reason=nocreds",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,8 +82,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ action: { name: "withings.connect.create_failed" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?withings=error&reason=connect", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?withings=error&reason=connect",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user