Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
318 changes: 206 additions & 112 deletions src/app/(app)/admin/users/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const currentUserProfile = await db.query.userProfiles.findFirst({
Expand All @@ -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(
Expand All @@ -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 }> {
Expand All @@ -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");
}
}
4 changes: 2 additions & 2 deletions src/test/integration/admin/user-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
);
});
});
Expand Down