fix(ui): remove insecure ?token= URL handler from LoginPage to close session-fixation

This commit is contained in:
michelligabriele 2026-04-30 23:39:03 +02:00
parent 256e05e474
commit 05a89b2c92
No known key found for this signature in database
2 changed files with 81 additions and 15 deletions

View file

@ -289,4 +289,85 @@ describe("LoginPage", () => {
expect(ssoButton).toBeInTheDocument();
expect(ssoButton).toBeDisabled();
});
describe("URL ?token= legacy path is rejected (security regression test)", () => {
const originalLocation = window.location;
beforeEach(() => {
Object.defineProperty(window, "location", {
value: {
...originalLocation,
href: "http://localhost:3000/ui/login?token=attacker.jwt.value",
pathname: "/ui/login",
search: "?token=attacker.jwt.value",
},
writable: true,
});
document.cookie =
"token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax";
});
afterEach(() => {
Object.defineProperty(window, "location", {
value: originalLocation,
writable: true,
});
});
it("must not set a token cookie or redirect to /ui/?login=success when ?token= is in the URL", async () => {
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
auto_redirect_to_sso: false,
server_root_path: "/",
proxy_base_url: null,
sso_configured: false,
},
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument();
});
expect(document.cookie).not.toContain("token=attacker.jwt.value");
expect(mockReplace).not.toHaveBeenCalledWith("/ui/?login=success");
});
it("must not overwrite an existing valid session cookie when ?token= is in the URL", async () => {
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
auto_redirect_to_sso: false,
server_root_path: "/",
proxy_base_url: null,
sso_configured: false,
},
isLoading: false,
});
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue("legitimate-session-jwt");
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith("/ui");
});
expect(document.cookie).not.toContain("token=attacker.jwt.value");
expect(mockReplace).not.toHaveBeenCalledWith("/ui/?login=success");
});
});
});

View file

@ -66,21 +66,6 @@ function LoginPageContent() {
return;
}
// Backwards compat: handle direct token in URL (legacy flow)
const urlToken = params.get("token");
if (urlToken && !isJwtExpired(urlToken)) {
document.cookie = `token=${urlToken}; path=/; SameSite=Lax`;
params.delete("token");
const cleanSearch = params.toString();
window.history.replaceState(
null,
"",
window.location.pathname + (cleanSearch ? `?${cleanSearch}` : ""),
);
router.replace("/ui/?login=success");
return;
}
// If switching workers on a control plane, clear the old token and show login
const switchingWorker = params.has("worker");
if (switchingWorker && uiConfig?.is_control_plane) {