fix(#411): sanitize login error messages through parseAuthError — prevent raw error leakage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 15:45:40 -06:00
parent 4d9b75994f
commit 5328390f4c
2 changed files with 43 additions and 10 deletions

View File

@@ -44,9 +44,10 @@ vi.mock("@/lib/auth/fetch-with-retry", () => ({
fetchWithRetry: mockFetchWithRetry, fetchWithRetry: mockFetchWithRetry,
})); }));
// Mock parseAuthError to use the real implementation // Use real parseAuthError implementation — vi.mock required for module resolution in vitest
vi.mock("@/lib/auth/auth-errors", async (importOriginal) => { vi.mock("@/lib/auth/auth-errors", async () => {
return importOriginal(); const actual = await import("../../../lib/auth/auth-errors");
return { ...actual };
}); });
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -317,7 +318,7 @@ describe("LoginPage", (): void => {
}); });
}); });
it("shows error banner on sign-in failure", async (): Promise<void> => { it("sanitizes BetterAuth error messages through parseAuthError", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG); mockFetchConfig(EMAIL_ONLY_CONFIG);
mockSignInEmail.mockResolvedValueOnce({ mockSignInEmail.mockResolvedValueOnce({
error: { message: "Invalid credentials" }, error: { message: "Invalid credentials" },
@@ -334,8 +335,39 @@ describe("LoginPage", (): void => {
await user.type(screen.getByLabelText(/password/i), "wrong"); await user.type(screen.getByLabelText(/password/i), "wrong");
await user.click(screen.getByRole("button", { name: /continue/i })); await user.click(screen.getByRole("button", { name: /continue/i }));
// Raw "Invalid credentials" is mapped through parseAuthError to a PDA-friendly message
await waitFor((): void => { await waitFor((): void => {
expect(screen.getByText("Invalid credentials")).toBeInTheDocument(); expect(
screen.getByText("Authentication didn't complete. Please try again when ready.")
).toBeInTheDocument();
});
expect(mockPush).not.toHaveBeenCalled();
});
it("maps raw DB/server errors to PDA-friendly messages instead of leaking details", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
// Simulate a leaked internal server error from BetterAuth
mockSignInEmail.mockResolvedValueOnce({
error: { message: "Internal server error: connection to DB pool exhausted" },
});
const user = userEvent.setup();
render(<LoginPage />);
await waitFor((): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "wrong");
await user.click(screen.getByRole("button", { name: /continue/i }));
// parseAuthError maps "internal server" keyword to server_error PDA-friendly message
await waitFor((): void => {
expect(
screen.getByText("The service is taking a break. Please try again in a moment.")
).toBeInTheDocument();
}); });
expect(mockPush).not.toHaveBeenCalled(); expect(mockPush).not.toHaveBeenCalled();
@@ -359,9 +391,11 @@ describe("LoginPage", (): void => {
await user.type(screen.getByLabelText(/password/i), "wrong"); await user.type(screen.getByLabelText(/password/i), "wrong");
await user.click(screen.getByRole("button", { name: /continue/i })); await user.click(screen.getByRole("button", { name: /continue/i }));
// When error.message is falsy, parseAuthError receives the raw error object
// which falls through to the "unknown" code PDA-friendly message
await waitFor((): void => { await waitFor((): void => {
expect( expect(
screen.getByText("Unable to sign in. Please check your credentials and try again.") screen.getByText("Authentication didn't complete. Please try again when ready.")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });

View File

@@ -102,11 +102,10 @@ export default function LoginPage(): ReactElement {
const result = await signIn.email({ email, password }); const result = await signIn.email({ email, password });
if (result.error) { if (result.error) {
setError( const parsed = parseAuthError(
typeof result.error.message === "string" result.error.message ? new Error(String(result.error.message)) : result.error
? result.error.message
: "Unable to sign in. Please check your credentials and try again."
); );
setError(parsed.message);
} else { } else {
router.push("/tasks"); router.push("/tasks");
} }