Files
craftvia/scripts/test-tenant-isolation.ts
msolarczekandClaude Opus 5 6687ef2e03 Tests: vollständige Suite auch mit RLS_ENFORCED=true grün
- test-tenant-isolation: Compound-Key mit fremdem Mandanten – im Owner-Betrieb Throw
  (Tenant-Guard), unter scharfer RLS liefert die DB null; beides = kein Datenabfluss
- run-tests.ts: lädt .env und leitet RLS_DATABASE_URL (Rolle craftvia_app) aus
  DATABASE_URL ab, wenn RLS_ENFORCED=true und keine URL gesetzt ist

Nachweis: RLS_ENFORCED=true npm run test → 52/52; npm run gate → 52/52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:33:22 +02:00

190 lines
7.5 KiB
TypeScript

// Regressionstest der Mandantentrennung (Sicherheitsbefund F-02).
//
// Prüft den Hybrid-Guard aus `src/server/db.ts` (`dbForTenant`): ein Fremdzugriff
// über `findUnique` darf keinen Datensatz eines anderen Mandanten preisgeben —
// weder mit `select`-Projektion ohne `tenantId` (der ursprünglich ausnutzbare
// Fall), noch mit `include`, ohne `select` oder über einen Compound-Unique-Key.
// Der legitime Eigenzugriff muss unverändert funktionieren.
//
// Fixture: Fundament-Modell `Role` (tenantId + Compound-Unique `tenantId_key`).
//
// Lauf: npx tsx scripts/test-tenant-isolation.ts (lokale Postgres-DB aus .env)
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Erwartet, dass `fn` wirft (z. B. Isolationsverletzung oder NotFound). */
async function expectThrow(fn: () => Promise<unknown>, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Throw (Datenabfluss möglich!)`);
} catch {
ok(true, msg);
}
}
/**
* Compound keys with a foreign tenantId: in owner mode the tenant guard throws (fail-closed);
* with RLS_ENFORCED=true the database already hides the row, so the query returns null.
* Both outcomes mean "no data leak" — anything else fails.
*/
async function expectThrowOrNullUnderRls(fn: () => Promise<unknown>, msg: string) {
if (process.env.RLS_ENFORCED !== "true") return expectThrow(fn, msg);
try {
const r = await fn();
ok(r === null, `${msg} (RLS: null)${r === null ? "" : ` — statt null: ${JSON.stringify(r)}`}`);
} catch {
ok(true, `${msg} (RLS: Throw)`);
}
}
/** Erwartet, dass `fn` `null` liefert (kein Datensatz, kein Abfluss). */
async function expectNull(fn: () => Promise<unknown>, msg: string) {
const r = await fn();
ok(r === null, `${msg}${r === null ? "" : ` — statt null: ${JSON.stringify(r)}`}`);
}
const SLUG_A = "zz-sec-test-a";
const SLUG_B = "zz-sec-test-b";
const KEY = "zz-isolation-role";
async function cleanup() {
const tenants = await prisma.tenant.findMany({
where: { slug: { in: [SLUG_A, SLUG_B] } },
select: { id: true },
});
const ids = tenants.map((t) => t.id);
if (ids.length) {
await prisma.auditLog.deleteMany({ where: { tenantId: { in: ids } } });
await prisma.role.deleteMany({ where: { tenantId: { in: ids } } });
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
}
async function main() {
// Idempotenz: eventuelle Reste eines früheren Laufs entfernen.
await cleanup();
// Zwei Test-Mandanten mit je einer Rolle anlegen (roher Client = ohne Guard).
const tenantA = await prisma.tenant.create({ data: { name: "SEC-Test A", slug: SLUG_A } });
const tenantB = await prisma.tenant.create({ data: { name: "SEC-Test B", slug: SLUG_B } });
const roleA = await prisma.role.create({ data: { tenantId: tenantA.id, key: KEY, name: "Rolle A (eigen)" } });
const roleB = await prisma.role.create({ data: { tenantId: tenantB.id, key: KEY, name: "GEHEIM-B (fremd)" } });
const dbA = dbForTenant(tenantA.id);
console.log("\n— Fremdzugriff (Mandant A liest Rolle von B) muss scheitern —");
// (1) Der ursprüngliche Exploit: select-Projektion OHNE tenantId.
await expectNull(
() => dbA.role.findUnique({ where: { id: roleB.id }, select: { key: true, name: true } }),
"findUnique + select OHNE tenantId → null (F-02-Kernfall)"
);
// (2) select MIT tenantId.
await expectNull(
() => dbA.role.findUnique({ where: { id: roleB.id }, select: { name: true, tenantId: true } }),
"findUnique + select MIT tenantId → null"
);
// (3) include.
await expectNull(
() => dbA.role.findUnique({ where: { id: roleB.id }, include: { rolePermissions: true } }),
"findUnique + include → null"
);
// (4) ohne select/include.
await expectNull(
() => dbA.role.findUnique({ where: { id: roleB.id } }),
"findUnique ohne Projektion → null"
);
// (5) findUniqueOrThrow → muss werfen statt fremden Datensatz zu liefern.
await expectThrow(
() => dbA.role.findUniqueOrThrow({ where: { id: roleB.id }, select: { name: true } }),
"findUniqueOrThrow + select OHNE tenantId → Throw"
);
// (6) Compound-Unique-Key (tenantId_key) mit fremdem tenantId, select ohne tenantId.
await expectThrowOrNullUnderRls(
() =>
dbA.role.findUnique({
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
select: { name: true },
}),
"findUnique über Compound-Key (fremd) + select → Throw (fail-closed)"
);
// (7) Compound-Unique-Key mit fremdem tenantId, ohne select.
await expectThrowOrNullUnderRls(
() =>
dbA.role.findUnique({
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
}),
"findUnique über Compound-Key (fremd) ohne select → Throw"
);
// (7b) Mutation auf fremden Datensatz → Ownership-Vorprüfung wirft.
await expectThrow(
() => dbA.role.update({ where: { id: roleB.id }, data: { name: "gekapert" } }),
"update auf fremde Rolle → Throw (Ownership-Vorprüfung)"
);
const bAfter = await prisma.role.findUnique({ where: { id: roleB.id } });
ok(bAfter?.name === roleB.name, "fremde Rolle bleibt unverändert");
// (7c) findMany liefert nie fremde Zeilen.
const listA = await dbA.role.findMany({ where: { key: KEY } });
ok(listA.length === 1 && listA[0].id === roleA.id, "findMany(key) liefert nur die eigene Rolle");
console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigene Rolle A) muss funktionieren —");
// (8) skalarer Key + select: Treffer, und tenantId darf NICHT auftauchen (kein Injektions-Leck).
const own1 = await dbA.role.findUnique({ where: { id: roleA.id }, select: { name: true } });
ok(own1?.name === roleA.name, "Eigenzugriff findUnique + select → Treffer");
ok(own1 !== null && !("tenantId" in (own1 as object)), "Eigenzugriff select {name} → Rückgabe OHNE tenantId");
// (9) Compound-Key (eigen) + select: Treffer, injiziertes tenantId wieder entfernt.
const own2 = await dbA.role.findUnique({
where: { tenantId_key: { tenantId: tenantA.id, key: roleA.key } },
select: { name: true },
});
ok(own2?.name === roleA.name, "Eigenzugriff über Compound-Key + select → Treffer");
ok(own2 !== null && !("tenantId" in (own2 as object)), "Compound-Key select {name} → Rückgabe OHNE tenantId (Injektion bereinigt)");
// (10) ohne Projektion: voller Datensatz inkl. tenantId (Normalfall).
const own3 = await dbA.role.findUnique({ where: { id: roleA.id } });
ok(own3?.tenantId === tenantA.id, "Eigenzugriff ohne Projektion → voller Datensatz inkl. tenantId");
// (11) findUniqueOrThrow auf eigenen Datensatz → kein Throw.
const own4 = await dbA.role.findUniqueOrThrow({ where: { id: roleA.id }, select: { name: true } });
ok(own4.name === roleA.name, "Eigenzugriff findUniqueOrThrow → Treffer");
// (12) create injiziert den eigenen Mandanten.
const created = await dbA.role.create({ data: { key: `${KEY}-2`, name: "Neu A", tenantId: tenantB.id } });
ok(created.tenantId === tenantA.id, "create über dbForTenant(A) setzt tenantId=A (auch bei fremdem Input)");
}
main()
.then(async () => {
await cleanup();
await prisma.$disconnect();
if (failures > 0) {
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK");
})
.catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});