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

@@ -14,6 +14,7 @@ import {
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/requests/[id]/approve
@@ -133,7 +134,7 @@ export async function POST(
} catch (e: any) {
console.error("Failed to create tenant:", e);
return NextResponse.json(
{ error: `Failed to create tenant: ${e.message}` },
{ error: safeError(e, "Failed to create tenant") },
{ status: 500 }
);
}

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenant, deleteTenant } from "@/lib/k8s";
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/tenants/[name]/delete
@@ -42,7 +43,7 @@ export async function POST(
} catch (e: any) {
console.error("Failed to delete tenant:", e);
return NextResponse.json(
{ error: `Failed to delete tenant: ${e.message}` },
{ error: safeError(e, "Failed to delete tenant") },
{ status: 500 }
);
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/tenants/[name]/suspend
@@ -35,7 +36,7 @@ export async function POST(
} catch (e: any) {
console.error("Failed to update tenant suspend state:", e);
return NextResponse.json(
{ error: `Failed to update tenant: ${e.message}` },
{ error: safeError(e, "Failed to update tenant") },
{ status: 500 }
);
}

View File

@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
const MAX_WORKSPACE_FILE_SIZE = 10_000;
export async function GET(
_req: NextRequest,
@@ -27,7 +32,7 @@ export async function GET(
return NextResponse.json(tenant);
} catch (e: any) {
return NextResponse.json(
{ error: e.message },
{ error: safeError(e, "Failed to fetch tenant") },
{ status: e.statusCode || 500 }
);
}
@@ -61,20 +66,130 @@ export async function PATCH(
}
const specPatch: Record<string, any> = {};
if (body.packages !== undefined) specPatch.packages = body.packages;
if (body.workspaceFiles !== undefined)
// ── Validate packages against catalog ──
if (body.packages !== undefined) {
if (!Array.isArray(body.packages) || body.packages.length > 10) {
return NextResponse.json(
{ error: "Invalid packages: must be an array of at most 10 items" },
{ status: 400 }
);
}
for (const pkg of body.packages) {
if (typeof pkg !== "string" || !getPackageDef(pkg)) {
return NextResponse.json(
{ error: `Unknown package: ${pkg}` },
{ status: 400 }
);
}
}
specPatch.packages = body.packages;
}
// ── Validate workspaceFiles ──
if (body.workspaceFiles !== undefined) {
if (
typeof body.workspaceFiles !== "object" ||
body.workspaceFiles === null ||
Array.isArray(body.workspaceFiles)
) {
return NextResponse.json(
{ error: "Invalid workspaceFiles: must be an object" },
{ status: 400 }
);
}
for (const [key, value] of Object.entries(body.workspaceFiles)) {
if (!ALLOWED_WORKSPACE_FILES.includes(key)) {
return NextResponse.json(
{
error: `Invalid workspace file: ${key}. Allowed: ${ALLOWED_WORKSPACE_FILES.join(", ")}`,
},
{ status: 400 }
);
}
if (
typeof value !== "string" ||
value.length > MAX_WORKSPACE_FILE_SIZE
) {
return NextResponse.json(
{
error: `Workspace file ${key} must be a string of at most ${MAX_WORKSPACE_FILE_SIZE} characters`,
},
{ status: 400 }
);
}
}
specPatch.workspaceFiles = body.workspaceFiles;
if (body.displayName !== undefined)
}
// ── Simple string fields ──
if (body.displayName !== undefined) {
if (
typeof body.displayName !== "string" ||
body.displayName.length < 1 ||
body.displayName.length > 100
) {
return NextResponse.json(
{ error: "displayName must be 1-100 characters" },
{ status: 400 }
);
}
specPatch.displayName = body.displayName;
if (body.agentName !== undefined) specPatch.agentName = body.agentName;
if (body.channelUsers !== undefined)
}
if (body.agentName !== undefined) {
if (
typeof body.agentName !== "string" ||
body.agentName.length < 1 ||
body.agentName.length > 50
) {
return NextResponse.json(
{ error: "agentName must be 1-50 characters" },
{ status: 400 }
);
}
specPatch.agentName = body.agentName;
}
// ── channelUsers (basic shape validation) ──
if (body.channelUsers !== undefined) {
if (
typeof body.channelUsers !== "object" ||
body.channelUsers === null ||
Array.isArray(body.channelUsers)
) {
return NextResponse.json(
{ error: "Invalid channelUsers: must be an object" },
{ status: 400 }
);
}
for (const [channel, users] of Object.entries(body.channelUsers)) {
if (typeof channel !== "string" || channel.length > 50) {
return NextResponse.json(
{ error: `Invalid channel name: ${channel}` },
{ status: 400 }
);
}
if (
!Array.isArray(users) ||
(users as any[]).some(
(u: any) => typeof u !== "string" || u.length > 100
)
) {
return NextResponse.json(
{ error: `Invalid user IDs for channel ${channel}` },
{ status: 400 }
);
}
}
specPatch.channelUsers = body.channelUsers;
}
const updated = await patchTenantSpec(name, specPatch);
return NextResponse.json(updated);
} catch (e: any) {
return NextResponse.json(
{ error: e.message },
{ error: safeError(e, "Failed to update tenant") },
{ status: e.statusCode || 500 }
);
}

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants, getTenant, createTenant } from "@/lib/k8s";
import type { PiecedTenantSpec } from "@/types";
import { listTenants } from "@/lib/k8s";
export async function GET() {
const user = await getSessionUser();
@@ -20,37 +19,3 @@ export async function GET() {
);
return NextResponse.json(own);
}
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = (await request.json()) as {
name: string;
spec: PiecedTenantSpec;
};
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
return NextResponse.json(
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
{ status: 400 }
);
}
const existing = await getTenant(body.name);
if (existing) {
return NextResponse.json(
{ error: "Tenant already exists" },
{ status: 409 }
);
}
const tenant = await createTenant(body.name, body.spec, {
"pieced.ch/zitadel-org-id": user.orgId,
});
return NextResponse.json(tenant, { status: 201 });
}

View File

@@ -1,20 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* GET /api/usage
*
* Customers: teamId is resolved server-side from the tenant matching the
* user's orgId. No client-supplied teamId accepted.
* Platform admins: may pass ?teamId=... to inspect any tenant's usage.
*/
export async function GET(req: NextRequest) {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const teamId = req.nextUrl.searchParams.get("teamId");
if (!teamId)
return NextResponse.json({ error: "teamId required" }, { status: 400 });
let teamId: string | null = null;
if (user.isPlatform) {
// Admins may pass a specific teamId to inspect any tenant
teamId = req.nextUrl.searchParams.get("teamId") ?? null;
}
// For customers (or admins without explicit teamId): resolve from their tenant
if (!teamId) {
const tenants = await listTenants();
const orgTenant = tenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (!orgTenant?.status?.litellmTeamId) {
return NextResponse.json(
{ error: "No active tenant found for your organization" },
{ status: 404 }
);
}
teamId = orgTenant.status.litellmTeamId;
}
// Month param: YYYY-MM, defaults to current month
const now = new Date();
const monthParam = req.nextUrl.searchParams.get("month")
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const monthParam =
req.nextUrl.searchParams.get("month") ||
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const [year, month] = monthParam.split("-").map(Number);
const startDate = new Date(year, month - 1, 1);
@@ -30,18 +59,28 @@ export async function GET(req: NextRequest) {
const allRequests: any[] = [];
let page = 1;
while (true) {
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100);
const result = await getTeamSpendLogsV2(
teamId,
startStr,
endStr,
page,
100
);
allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break;
page++;
}
// Aggregate by day
const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {};
const byDay: Record<
string,
{ inputTokens: number; outputTokens: number; spend: number }
> = {};
for (const r of allRequests) {
const day = (r.startTime || r.endTime || "").slice(0, 10);
if (!day) continue;
if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
if (!byDay[day])
byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
byDay[day].inputTokens += r.prompt_tokens || 0;
byDay[day].outputTokens += r.completion_tokens || 0;
byDay[day].spend += r.spend || 0;
@@ -51,8 +90,14 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d }));
const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0);
const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0);
const totalInput = allRequests.reduce(
(s, r) => s + (r.prompt_tokens || 0),
0
);
const totalOutput = allRequests.reduce(
(s, r) => s + (r.completion_tokens || 0),
0
);
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
return NextResponse.json({
@@ -79,6 +124,9 @@ export async function GET(req: NextRequest) {
});
} catch (e: any) {
console.error("Usage fetch error:", e.message);
return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 });
return NextResponse.json(
{ error: safeError(e, "Failed to fetch usage") },
{ status: 500 }
);
}
}
}