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:
2026-04-14 20:20:04 +02:00
parent 6f9f46b2d0
commit f0eca1959b
9 changed files with 272 additions and 65 deletions

View File

@@ -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"];

View File

@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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
View 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;
}