Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions ui/litellm-dashboard/src/app/login/LoginPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
15 changes: 0 additions & 15 deletions ui/litellm-dashboard/src/app/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading