diff --git a/apps/deploy-web/src/middleware.spec.ts b/apps/deploy-web/src/middleware.spec.ts index c45bc1c2c9..53d57a4a9a 100644 --- a/apps/deploy-web/src/middleware.spec.ts +++ b/apps/deploy-web/src/middleware.spec.ts @@ -84,6 +84,38 @@ describe("middleware", () => { expect(response.headers.get("location")).toBe("http://localhost/deployments"); }); + it("redirects to the requested relative path when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=%2Fdeployments%3Ftab%3Dactive" }); + + expect(response.headers.get("location")).toBe("http://localhost/deployments?tab=active"); + }); + + it("ignores an absolute return url when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=https%3A%2F%2Fevil.example%2Fphish" }); + + expect(response.headers.get("location")).toBe("http://localhost/"); + }); + + it("ignores a protocol relative return url when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=%2F%2Fevil.example%2Fphish" }); + + expect(response.headers.get("location")).toBe("http://localhost/"); + }); + + it("ignores a backslash prefixed return url when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=%2F%5Cevil.example%2Fphish" }); + + expect(response.headers.get("location")).toBe("http://localhost/"); + }); + + it("keeps a same-origin absolute return url with a protocol-relative path on the request origin", () => { + const { response } = setup({ path: "/maintenance?return=http%3A%2F%2Flocalhost%2F%2Fevil.example%2Fphish" }); + + const location = new URL(response.headers.get("location") ?? ""); + expect(location.host).toBe("localhost"); + expect(location.hostname).not.toBe("evil.example"); + }); + function setup(input: { path: string }) { const request = new NextRequest(new URL(`http://localhost${input.path}`)); const response = middleware(request); diff --git a/apps/deploy-web/src/middleware.ts b/apps/deploy-web/src/middleware.ts index e8bd013171..ec1a12990c 100644 --- a/apps/deploy-web/src/middleware.ts +++ b/apps/deploy-web/src/middleware.ts @@ -41,10 +41,10 @@ export function middleware(request: NextRequest) { setContentSecurityPolicyHeaders(redirectResponse, contentSecurityPolicyHeaderName, contentSecurityPolicy, contentSecurityPolicyReportHeaders); return redirectResponse; } else if (!isMaintenanceMode && pathname.startsWith(maintenancePage)) { - const returnPath = getReturnPath(request); - logger.info({ message: `Redirecting from maintenance page to ${returnPath}` }); + const returnUrl = getReturnPath(request); + logger.info({ message: `Redirecting from maintenance page to ${returnUrl.pathname}${returnUrl.search}` }); - const redirectResponse = NextResponse.redirect(new URL(returnPath, request.url), 307); // 307 - temporary redirect + const redirectResponse = NextResponse.redirect(returnUrl, 307); // 307 - temporary redirect setContentSecurityPolicyHeaders(redirectResponse, contentSecurityPolicyHeaderName, contentSecurityPolicy, contentSecurityPolicyReportHeaders); return redirectResponse; } @@ -82,14 +82,21 @@ function setContentSecurityPolicyHeaders( } function getReturnPath(request: NextRequest) { + const requestUrl = new URL(request.url); try { const returnParam = request.nextUrl.searchParams.get("return"); const returnPath = returnParam ? decodeURIComponent(returnParam) : "/"; - - return returnPath; + const returnUrl = new URL(returnPath, requestUrl); + const isSameOrigin = returnUrl.origin === requestUrl.origin; + + // Return the validated same-origin URL object rather than a pathname string. + // A same-origin absolute return (e.g. `http://host//evil.example/phish`) yields a + // pathname of `//evil.example/phish`; re-parsing that string against the request URL + // treats it as a protocol-relative URL and escapes to an external origin (CWE-601). + return isSameOrigin ? returnUrl : new URL("/", requestUrl); } catch (error) { logger.error({ message: "Failed to get return path", error }); - return "/"; + return new URL("/", requestUrl); } } diff --git a/apps/stats-web/src/middleware.spec.ts b/apps/stats-web/src/middleware.spec.ts new file mode 100644 index 0000000000..002c4dbdfa --- /dev/null +++ b/apps/stats-web/src/middleware.spec.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it } from "vitest"; + +import { middleware } from "./middleware"; + +describe("middleware", () => { + it("redirects to the requested relative path when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=%2Fgraph%2Fdaily-akt-spent" }); + + expect(response.headers.get("location")).toBe("http://localhost/graph/daily-akt-spent"); + }); + + it("ignores an absolute return url when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=https%3A%2F%2Fevil.example%2Fphish" }); + + expect(response.headers.get("location")).toBe("http://localhost/"); + }); + + it("ignores a protocol relative return url when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=%2F%2Fevil.example%2Fphish" }); + + expect(response.headers.get("location")).toBe("http://localhost/"); + }); + + it("ignores a backslash prefixed return url when leaving the maintenance page", () => { + const { response } = setup({ path: "/maintenance?return=%2F%5Cevil.example%2Fphish" }); + + expect(response.headers.get("location")).toBe("http://localhost/"); + }); + + function setup(input: { path: string }) { + const request = new NextRequest(new URL(`http://localhost${input.path}`)); + const response = middleware(request); + return { request, response }; + } +}); diff --git a/apps/stats-web/src/middleware.ts b/apps/stats-web/src/middleware.ts index 35a6759c93..4a64668095 100644 --- a/apps/stats-web/src/middleware.ts +++ b/apps/stats-web/src/middleware.ts @@ -64,8 +64,11 @@ function getReturnPath(request: NextRequest) { try { const returnParam = request.nextUrl.searchParams.get("return"); const returnPath = returnParam ? decodeURIComponent(returnParam) : "/"; + const requestUrl = new URL(request.url); + const returnUrl = new URL(returnPath, requestUrl); + const isSameOrigin = returnUrl.origin === requestUrl.origin; - return returnPath; + return isSameOrigin ? `${returnUrl.pathname}${returnUrl.search}${returnUrl.hash}` : "/"; } catch (error) { logger.error({ message: "Failed to get return path", error }); return "/";