Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
6.1 KiB
TypeScript
160 lines
6.1 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.
|
|
//
|
|
// Lauf: npx tsx scripts/test-tenant-isolation.ts
|
|
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env liegt im Worktree.
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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";
|
|
|
|
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.risk.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 einem Risiko 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 riskA = await prisma.risk.create({
|
|
data: { tenantId: tenantA.id, refNo: 900001, title: "Risiko A (eigen)", likelihood: 3, impact: 3, score: 9 },
|
|
});
|
|
const riskB = await prisma.risk.create({
|
|
data: { tenantId: tenantB.id, refNo: 900001, title: "GEHEIM-B (fremd)", likelihood: 4, impact: 4, score: 16 },
|
|
});
|
|
|
|
const dbA = dbForTenant(tenantA.id);
|
|
|
|
console.log("\n— Fremdzugriff (Mandant A liest Risiko von B) muss scheitern —");
|
|
|
|
// (1) Der ursprüngliche Exploit: select-Projektion OHNE tenantId.
|
|
await expectNull(
|
|
() => dbA.risk.findUnique({ where: { id: riskB.id }, select: { refNo: true, title: true } }),
|
|
"findUnique + select OHNE tenantId → null (F-02-Kernfall)"
|
|
);
|
|
|
|
// (2) select MIT tenantId.
|
|
await expectNull(
|
|
() => dbA.risk.findUnique({ where: { id: riskB.id }, select: { title: true, tenantId: true } }),
|
|
"findUnique + select MIT tenantId → null"
|
|
);
|
|
|
|
// (3) include (tenantId wäre ohnehin enthalten).
|
|
await expectNull(
|
|
() => dbA.risk.findUnique({ where: { id: riskB.id }, include: { riskMeasures: true } }),
|
|
"findUnique + include → null"
|
|
);
|
|
|
|
// (4) ohne select/include.
|
|
await expectNull(
|
|
() => dbA.risk.findUnique({ where: { id: riskB.id } }),
|
|
"findUnique ohne Projektion → null"
|
|
);
|
|
|
|
// (5) findUniqueOrThrow → muss werfen statt fremden Datensatz zu liefern.
|
|
await expectThrow(
|
|
() => dbA.risk.findUniqueOrThrow({ where: { id: riskB.id }, select: { title: true } }),
|
|
"findUniqueOrThrow + select OHNE tenantId → Throw"
|
|
);
|
|
|
|
// (6) Compound-Unique-Key (tenantId_refNo) mit fremdem tenantId, select ohne tenantId.
|
|
await expectThrow(
|
|
() =>
|
|
dbA.risk.findUnique({
|
|
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
|
|
select: { title: true },
|
|
}),
|
|
"findUnique über Compound-Key (fremd) + select → Throw (fail-closed)"
|
|
);
|
|
|
|
// (7) Compound-Unique-Key mit fremdem tenantId, ohne select.
|
|
await expectThrow(
|
|
() =>
|
|
dbA.risk.findUnique({
|
|
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
|
|
}),
|
|
"findUnique über Compound-Key (fremd) ohne select → Throw"
|
|
);
|
|
|
|
console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigenes Risiko A) muss funktionieren —");
|
|
|
|
// (8) skalarer Key + select: Treffer, und tenantId darf NICHT auftauchen (kein Injektions-Leck).
|
|
const own1 = await dbA.risk.findUnique({ where: { id: riskA.id }, select: { title: true } });
|
|
ok(own1?.title === riskA.title, "Eigenzugriff findUnique + select → Treffer");
|
|
ok(own1 !== null && !("tenantId" in (own1 as object)), "Eigenzugriff select {title} → Rückgabe OHNE tenantId");
|
|
|
|
// (9) Compound-Key (eigen) + select: Treffer, injiziertes tenantId wieder entfernt.
|
|
const own2 = await dbA.risk.findUnique({
|
|
where: { tenantId_refNo: { tenantId: tenantA.id, refNo: riskA.refNo } },
|
|
select: { title: true },
|
|
});
|
|
ok(own2?.title === riskA.title, "Eigenzugriff über Compound-Key + select → Treffer");
|
|
ok(own2 !== null && !("tenantId" in (own2 as object)), "Compound-Key select {title} → Rückgabe OHNE tenantId (Injektion bereinigt)");
|
|
|
|
// (10) ohne Projektion: voller Datensatz inkl. tenantId (Normalfall).
|
|
const own3 = await dbA.risk.findUnique({ where: { id: riskA.id } });
|
|
ok(own3?.tenantId === tenantA.id, "Eigenzugriff ohne Projektion → voller Datensatz inkl. tenantId");
|
|
|
|
// (11) findUniqueOrThrow auf eigenen Datensatz → kein Throw.
|
|
const own4 = await dbA.risk.findUniqueOrThrow({ where: { id: riskA.id }, select: { title: true } });
|
|
ok(own4.title === riskA.title, "Eigenzugriff findUniqueOrThrow → Treffer");
|
|
}
|
|
|
|
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);
|
|
});
|