Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
|
||||
/**
|
||||
* Download-Route für hochgeladene Nachweise/Richtlinien (Epic S1). Liefert die im
|
||||
* Objektspeicher (MinIO/S3) abgelegten Bytes anhand ihres Storage-Keys aus.
|
||||
*
|
||||
* Mandanten-Isolation (mehrschichtig):
|
||||
* 1. Der Key ist mandantenpräfixiert (`<tenantId>/uploads/…`). Er MUSS mit dem
|
||||
* Tenant der aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404
|
||||
* abgewiesen (keine Existenz-Preisgabe).
|
||||
* 2. Zusätzlich (Defense in Depth) muss der Key in einer mandantengebundenen
|
||||
* Referenz vorkommen (`Evidence.fileRef` oder `PolicyRequirement.nachweisLink`);
|
||||
* so werden nur tatsächlich referenzierte Objekte ausgeliefert, keine geratenen.
|
||||
*
|
||||
* Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options:
|
||||
* nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing.
|
||||
*
|
||||
* Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig
|
||||
* über `requireSession` erzwungen (Autorisierung = Mandantenbindung des Keys).
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ key: string[] }> },
|
||||
) {
|
||||
const session = await requireSession();
|
||||
const tenantId = session.user.tenantId;
|
||||
|
||||
const { key: segments } = await params;
|
||||
// Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen.
|
||||
const key = (segments ?? []).join("/");
|
||||
|
||||
// Pfad-Traversal ausschließen und Mandantenpräfix erzwingen.
|
||||
if (
|
||||
!key ||
|
||||
key.includes("..") ||
|
||||
key.includes("\0") ||
|
||||
!key.startsWith(`${tenantId}/`)
|
||||
) {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
// Defense in Depth: Key muss in einer mandantengebundenen Referenz vorkommen.
|
||||
const db = dbForTenant(tenantId);
|
||||
const [evidence, requirement] = await Promise.all([
|
||||
db.evidence.findFirst({ where: { fileRef: key }, select: { id: true } }),
|
||||
db.policyRequirement.findFirst({ where: { nachweisLink: key }, select: { reqId: true } }),
|
||||
]);
|
||||
if (!evidence && !requirement) {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const content = await storage.get(key);
|
||||
if (!content) {
|
||||
// Kein Byte-Backend (Stub) oder Objekt fehlt → 404.
|
||||
return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
}
|
||||
|
||||
const filename = content.filename.replace(/["\\]/g, "_");
|
||||
const headers = new Headers({
|
||||
"Content-Type": content.contentType ?? "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
});
|
||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||
|
||||
return new Response(content.stream, { headers });
|
||||
}
|
||||
Reference in New Issue
Block a user