diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 2772579e..9b0f336d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -29,3 +29,9 @@ **Vulnerability:** Found `TestAdminButton` containing hardcoded test credentials was statically imported into the login form, including the credentials in the production JavaScript bundle. **Learning:** Static imports of development-only components include their code (and secrets) in production bundles unless tree-shaken, which is unreliable for side-effect imports or complex components. **Prevention:** Use `next/dynamic` to lazily load development-only components, ensuring their code is split into a separate chunk that is never requested in production unless the component is rendered. + +## 2026-01-25 - Sensitive Information Exposure in User Invitation + +**Vulnerability:** Admin invitation actions (`inviteUser`, `resendInvite`) were throwing raw error messages (including potential SMTP error details) which were then displayed to the user via toast notifications. +**Learning:** Even admin-only actions must be secure against information disclosure. Relying on `throw new Error(details)` in Server Actions often propagates the details to the client unless intercepted. +**Prevention:** Always wrap external service calls (like Email, DB) in `try/catch` blocks in Server Actions. Log the full error securely on the server, and throw a generic, sanitized error message to the client. diff --git a/src/app/(app)/admin/users/actions.ts b/src/app/(app)/admin/users/actions.ts index cbb2f8b4..72297101 100644 --- a/src/app/(app)/admin/users/actions.ts +++ b/src/app/(app)/admin/users/actions.ts @@ -8,6 +8,7 @@ import { revalidatePath } from "next/cache"; import { sendInviteEmail } from "~/lib/email/invite"; import { requireSiteUrl } from "~/lib/url"; import { inviteUserSchema, updateUserRoleSchema } from "./schema"; +import { log } from "~/lib/logger"; async function verifyAdmin(userId: string): Promise { const currentUserProfile = await db.query.userProfiles.findFirst({ @@ -34,33 +35,59 @@ export async function updateUserRole( throw new Error("Unauthorized"); } - await verifyAdmin(user.id); + try { + await verifyAdmin(user.id); - // Validate input - const validated = updateUserRoleSchema.parse({ userId, newRole, userType }); + // Validate input + const validated = updateUserRoleSchema.parse({ userId, newRole, userType }); - // Constraint: Admin cannot demote themselves - if ( - validated.userType === "active" && - validated.userId === user.id && - validated.newRole !== "admin" - ) { - throw new Error("Admins cannot demote themselves"); - } + // Constraint: Admin cannot demote themselves + if ( + validated.userType === "active" && + validated.userId === user.id && + validated.newRole !== "admin" + ) { + throw new Error("Admins cannot demote themselves"); + } - if (validated.userType === "active") { - await db - .update(userProfiles) - .set({ role: validated.newRole }) - .where(eq(userProfiles.id, validated.userId)); - } else { - await db - .update(invitedUsers) - .set({ role: validated.newRole }) - .where(eq(invitedUsers.id, validated.userId)); - } + if (validated.userType === "active") { + await db + .update(userProfiles) + .set({ role: validated.newRole }) + .where(eq(userProfiles.id, validated.userId)); + } else { + await db + .update(invitedUsers) + .set({ role: validated.newRole }) + .where(eq(invitedUsers.id, validated.userId)); + } + + revalidatePath("/admin/users"); + } catch (error) { + // Allow specific validation/permission errors to pass through + if ( + error instanceof Error && + (error.message === "Unauthorized" || + error.message.startsWith("Forbidden") || + error.message === "Admins cannot demote themselves") + ) { + throw error; + } - revalidatePath("/admin/users"); + // Validation errors from Zod (if they propagate as Error) + if (error instanceof Error && error.constructor.name === "ZodError") { + throw error; + } + + log.error( + { + action: "updateUserRole", + error: error instanceof Error ? error.message : "Unknown", + }, + "Failed to update user role" + ); + throw new Error("An unexpected error occurred while updating user role"); + } } export async function inviteUser( @@ -75,79 +102,116 @@ export async function inviteUser( throw new Error("Unauthorized"); } - await verifyAdmin(user.id); + try { + await verifyAdmin(user.id); - const rawData = { - firstName: formData.get("firstName"), - lastName: formData.get("lastName"), - email: formData.get("email"), - role: formData.get("role"), - sendInvite: formData.get("sendInvite") === "true", - }; + const rawData = { + firstName: formData.get("firstName"), + lastName: formData.get("lastName"), + email: formData.get("email"), + role: formData.get("role"), + sendInvite: formData.get("sendInvite") === "true", + }; - const validated = inviteUserSchema.parse(rawData); + const validated = inviteUserSchema.parse(rawData); - // Check if user already exists - const existingUser = await db.query.authUsers.findFirst({ - where: eq(authUsers.email, validated.email), - }); - - if (existingUser) { - throw new Error("A user with this email already exists and is active."); - } + // Check if user already exists + const existingUser = await db.query.authUsers.findFirst({ + where: eq(authUsers.email, validated.email), + }); - const existingInvited = await db.query.invitedUsers.findFirst({ - where: eq(invitedUsers.email, validated.email), - }); + if (existingUser) { + throw new Error("A user with this email already exists and is active."); + } - if (existingInvited) { - throw new Error("This user has already been invited."); - } + const existingInvited = await db.query.invitedUsers.findFirst({ + where: eq(invitedUsers.email, validated.email), + }); - // Create invited user - const [newInvited] = await db - .insert(invitedUsers) - .values({ - firstName: validated.firstName, - lastName: validated.lastName, - email: validated.email, - role: validated.role, - }) - .returning(); - - if (!newInvited) { - throw new Error("Failed to create invited user"); - } + if (existingInvited) { + throw new Error("This user has already been invited."); + } - if (validated.sendInvite) { - // Security: Use configured site URL to prevent Host Header Injection - const siteUrl = requireSiteUrl("invite-user"); + // Create invited user + const [newInvited] = await db + .insert(invitedUsers) + .values({ + firstName: validated.firstName, + lastName: validated.lastName, + email: validated.email, + role: validated.role, + }) + .returning(); + + if (!newInvited) { + throw new Error("Failed to create invited user"); + } - const currentUser = await db.query.userProfiles.findFirst({ - where: eq(userProfiles.id, user.id), - }); + if (validated.sendInvite) { + // Security: Use configured site URL to prevent Host Header Injection + const siteUrl = requireSiteUrl("invite-user"); + + const currentUser = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.id, user.id), + }); + + const emailResult = await sendInviteEmail({ + to: validated.email, + firstName: validated.firstName, + inviterName: currentUser?.name ?? "An administrator", + siteUrl, + }); + + if (!emailResult.success) { + // Log the full error but don't expose it to the client + log.error( + { + action: "inviteUser", + email: validated.email, + error: emailResult.error, + }, + "Failed to send invitation email" + ); + throw new Error("Failed to send invitation email"); + } + + await db + .update(invitedUsers) + .set({ inviteSentAt: new Date() }) + .where(eq(invitedUsers.id, newInvited.id)); + } - const emailResult = await sendInviteEmail({ - to: validated.email, - firstName: validated.firstName, - inviterName: currentUser?.name ?? "An administrator", - siteUrl, - }); + revalidatePath("/admin/users"); + return { ok: true, userId: newInvited.id }; + } catch (error) { + // Allow known errors to propagate + if ( + error instanceof Error && + (error.message === "Unauthorized" || + error.message.startsWith("Forbidden") || + error.message === + "A user with this email already exists and is active." || + error.message === "This user has already been invited." || + error.message === "Failed to send invitation email") + ) { + throw error; + } - if (!emailResult.success) { - throw new Error( - `Failed to send invitation email: ${String(emailResult.error)}` - ); + // Validation errors from Zod (if they propagate as Error) + if (error instanceof Error && error.constructor.name === "ZodError") { + throw error; } - await db - .update(invitedUsers) - .set({ inviteSentAt: new Date() }) - .where(eq(invitedUsers.id, newInvited.id)); + log.error( + { + action: "inviteUser", + error: error instanceof Error ? error.message : "Unknown", + stack: error instanceof Error ? error.stack : undefined, + }, + "Invite user failed" + ); + throw new Error("An unexpected error occurred while inviting the user"); } - - revalidatePath("/admin/users"); - return { ok: true, userId: newInvited.id }; } export async function resendInvite(userId: string): Promise<{ ok: boolean }> { @@ -160,41 +224,71 @@ export async function resendInvite(userId: string): Promise<{ ok: boolean }> { throw new Error("Unauthorized"); } - await verifyAdmin(user.id); + try { + await verifyAdmin(user.id); - const invited = await db.query.invitedUsers.findFirst({ - where: eq(invitedUsers.id, userId), - }); + const invited = await db.query.invitedUsers.findFirst({ + where: eq(invitedUsers.id, userId), + }); - if (!invited) { - throw new Error("Invited user not found"); - } + if (!invited) { + throw new Error("Invited user not found"); + } - // Security: Use configured site URL to prevent Host Header Injection - const siteUrl = requireSiteUrl("resend-invite"); + // Security: Use configured site URL to prevent Host Header Injection + const siteUrl = requireSiteUrl("resend-invite"); - const currentUser = await db.query.userProfiles.findFirst({ - where: eq(userProfiles.id, user.id), - }); + const currentUser = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.id, user.id), + }); - const emailResult = await sendInviteEmail({ - to: invited.email, - firstName: invited.firstName, - inviterName: currentUser?.name ?? "An administrator", - siteUrl, - }); + const emailResult = await sendInviteEmail({ + to: invited.email, + firstName: invited.firstName, + inviterName: currentUser?.name ?? "An administrator", + siteUrl, + }); - if (!emailResult.success) { - throw new Error( - `Failed to send invitation email: ${String(emailResult.error)}` - ); - } + if (!emailResult.success) { + log.error( + { + action: "resendInvite", + userId, + email: invited.email, + error: emailResult.error, + }, + "Failed to resend invitation email" + ); + throw new Error("Failed to send invitation email"); + } - await db - .update(invitedUsers) - .set({ inviteSentAt: new Date() }) - .where(eq(invitedUsers.id, userId)); + await db + .update(invitedUsers) + .set({ inviteSentAt: new Date() }) + .where(eq(invitedUsers.id, userId)); + + revalidatePath("/admin/users"); + return { ok: true }; + } catch (error) { + if ( + error instanceof Error && + (error.message === "Unauthorized" || + error.message.startsWith("Forbidden") || + error.message === "Invited user not found" || + error.message === "Failed to send invitation email") + ) { + throw error; + } - revalidatePath("/admin/users"); - return { ok: true }; + log.error( + { + action: "resendInvite", + userId, + error: error instanceof Error ? error.message : "Unknown", + stack: error instanceof Error ? error.stack : undefined, + }, + "Resend invite failed" + ); + throw new Error("An unexpected error occurred while resending the invite"); + } } diff --git a/src/test/integration/admin/user-management.test.ts b/src/test/integration/admin/user-management.test.ts index 5b2fd589..be2ed6aa 100644 --- a/src/test/integration/admin/user-management.test.ts +++ b/src/test/integration/admin/user-management.test.ts @@ -224,7 +224,7 @@ describe("Admin User Management Integration", () => { formData.append("sendInvite", "true"); await expect(inviteUser(formData)).rejects.toThrow( - "Failed to send invitation email: SMTP Error" + "Failed to send invitation email" ); // Verify user was still created (or should we rollback? Currently it's not in a transaction with email) @@ -279,7 +279,7 @@ describe("Admin User Management Integration", () => { .returning(); await expect(resendInvite(ucUser.id)).rejects.toThrow( - "Failed to send invitation email: Network Error" + "Failed to send invitation email" ); }); });