fix(#337): Sanitize OAuth callback error parameter to prevent open redirect

- Validate error against allowlist of OAuth error codes
- Unknown errors map to generic message
- Encode all URL parameters

Refs #337

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 15:58:14 -06:00
parent 45a795d29e
commit 7cb7a4f543
2 changed files with 102 additions and 3 deletions

View File

@@ -71,6 +71,66 @@ describe("CallbackPage", (): void => {
});
});
it("should sanitize unknown error codes to prevent open redirect", async (): Promise<void> => {
// Malicious error parameter that could be used for XSS or redirect attacks
mockSearchParams.set("error", "<script>alert('xss')</script>");
render(<CallbackPage />);
await waitFor(() => {
// Should replace unknown error with generic authentication_error
expect(mockPush).toHaveBeenCalledWith("/login?error=authentication_error");
});
});
it("should sanitize URL-like error codes to prevent open redirect", async (): Promise<void> => {
// Attacker tries to inject a URL-like value
mockSearchParams.set("error", "https://evil.com/phishing");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login?error=authentication_error");
});
});
it("should allow valid OAuth 2.0 error codes", async (): Promise<void> => {
const validErrors = [
"access_denied",
"invalid_request",
"unauthorized_client",
"server_error",
"login_required",
"consent_required",
];
for (const errorCode of validErrors) {
mockPush.mockClear();
mockSearchParams.clear();
mockSearchParams.set("error", errorCode);
const { unmount } = render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/login?error=${errorCode}`);
});
unmount();
}
});
it("should encode special characters in error parameter", async (): Promise<void> => {
// Even valid errors should be encoded in the URL
mockSearchParams.set("error", "session_failed");
render(<CallbackPage />);
await waitFor(() => {
// session_failed doesn't need encoding but the function should still call encodeURIComponent
expect(mockPush).toHaveBeenCalledWith("/login?error=session_failed");
});
});
it("should handle refresh session errors gracefully", async (): Promise<void> => {
const mockRefreshSession = vi.fn().mockRejectedValue(new Error("Session error"));
vi.mocked(useAuth).mockReturnValue({