fix(portal): security hardening for pilot readiness
- C1: Rewrite /api/usage to resolve teamId server-side from tenant CR; customers can no longer pass arbitrary teamId (IDOR fix) - C2: Remove POST /api/tenants — tenants are only created via admin approval flow - H1: Validate packages against catalog, workspaceFiles against allowlist, and field lengths in PATCH /api/tenants/[name] - H2: Remove full ZITADEL profile claims logging from JWT callback - H3: Add safeError() utility; sanitize all error responses to clients, toggle raw errors via PORTAL_DEBUG_ERRORS=true - H4/H5: Escape HTML entities in all email templates (contactName, companyName, adminNotes)
This commit is contained in:
@@ -39,7 +39,6 @@ export const authConfig: NextAuthConfig = {
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
if (account && profile) {
|
||||
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
|
||||
const claims = profile as unknown as ZitadelClaims;
|
||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
|
||||
|
||||
@@ -42,11 +42,26 @@ function getFrom(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent injection in HTML emails.
|
||||
*/
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export async function sendApprovalEmail(
|
||||
to: string,
|
||||
contactName: string,
|
||||
companyName: string
|
||||
): Promise<void> {
|
||||
const safeName = escapeHtml(contactName);
|
||||
const safeCompany = escapeHtml(companyName);
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
@@ -68,8 +83,8 @@ export async function sendApprovalEmail(
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
|
||||
<p>Hello ${contactName},</p>
|
||||
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Great news! Your onboarding request for <strong>${safeCompany}</strong> has been approved.</p>
|
||||
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
@@ -95,14 +110,18 @@ export async function sendRejectionEmail(
|
||||
companyName: string,
|
||||
adminNotes?: string
|
||||
): Promise<void> {
|
||||
const safeName = escapeHtml(contactName);
|
||||
const safeCompany = escapeHtml(companyName);
|
||||
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
|
||||
|
||||
try {
|
||||
const notesBlock = adminNotes
|
||||
? `\nNote from our team:\n${adminNotes}\n`
|
||||
: "";
|
||||
const notesHtml = adminNotes
|
||||
const notesHtml = safeNotes
|
||||
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
|
||||
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
|
||||
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${adminNotes}</p>
|
||||
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -123,8 +142,8 @@ export async function sendRejectionEmail(
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
|
||||
<p>Hello ${contactName},</p>
|
||||
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${companyName}</strong> at this time.</p>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
|
||||
${notesHtml}
|
||||
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
@@ -145,6 +164,10 @@ export async function sendAdminNotificationEmail(
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||
if (!adminEmail) return;
|
||||
|
||||
const safeCompany = escapeHtml(companyName);
|
||||
const safeName = escapeHtml(contactName);
|
||||
const safeEmail = escapeHtml(contactEmail);
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
@@ -158,6 +181,23 @@ export async function sendAdminNotificationEmail(
|
||||
"",
|
||||
`Review it at https://app.pieced.ch/admin`,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
<h2 style="color: #ffffff; margin-top: 0;">New onboarding request</h2>
|
||||
<p>A new onboarding request has been submitted.</p>
|
||||
<table style="color: #ccc; font-size: 14px; margin: 16px 0;">
|
||||
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Company:</td><td>${safeCompany}</td></tr>
|
||||
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/admin" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
Review Request
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send admin notification email:", err);
|
||||
|
||||
37
src/lib/errors.ts
Normal file
37
src/lib/errors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Error sanitization for API responses.
|
||||
*
|
||||
* By default, returns a generic message to the client and logs the full
|
||||
* error server-side. Set PORTAL_DEBUG_ERRORS=true to return the raw
|
||||
* error message to the client (useful during development/debugging).
|
||||
*/
|
||||
|
||||
const DEBUG_ERRORS = process.env.PORTAL_DEBUG_ERRORS === "true";
|
||||
|
||||
/**
|
||||
* Returns a safe error string for API responses.
|
||||
*
|
||||
* - In debug mode (PORTAL_DEBUG_ERRORS=true): returns the raw e.message
|
||||
* - In production mode: returns the fallback string and logs the real error
|
||||
*
|
||||
* Recognises common HTTP status codes from k8s/vault errors and returns
|
||||
* appropriate short messages even in production mode.
|
||||
*/
|
||||
export function safeError(e: unknown, fallback: string): string {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const statusCode = (err as any).statusCode as number | undefined;
|
||||
|
||||
if (DEBUG_ERRORS) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
// Map well-known status codes to safe messages
|
||||
if (statusCode === 404) return "Not found";
|
||||
if (statusCode === 403) return "Forbidden";
|
||||
if (statusCode === 409) return "Conflict";
|
||||
if (statusCode === 401) return "Unauthorized";
|
||||
|
||||
// Log full error server-side, return generic to client
|
||||
console.error(`${fallback}:`, err.message);
|
||||
return fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user