mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge pull request #821 from MBombeck/fix/proxy-relative-redirects
Stop redirecting to the address the process bound to
This commit is contained in:
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