Merge pull request #821 from MBombeck/fix/proxy-relative-redirects

Stop redirecting to the address the process bound to
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 11:14:31 +02:00
commit 775efad923
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,
+2 -3
View File
@@ -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({
+5 -4
View File
@@ -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",
);
}
+5 -4
View File
@@ -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",
);
}
+5 -4
View File
@@ -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",
);
}