Zwei Kommentare versprachen Schutz, den es nicht gab. Das ist schlimmer als eine fehlende Zusage, weil man sich darauf verlaesst: - In middleware.ts stand "Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen". Die Middleware prueft keine Anmeldung, sie setzt Kopfzeilen; die Liste steuert allein den Cache-Control-Kopf. Wer den Satz glaubt, verzichtet an einer neuen Seite auf erfordereBenutzer(). Die Liste heisst jetzt OHNE_CACHE_SPERRE und sagt selbst, was sie ist. - In datei.ts hiess es, die Pfadpruefung fange "Symlink-Tricks" ab. resolve() rechnet mit Zeichenketten und folgt keinem Link - dafuer braeuchte es realpath(). Nachgeruestet wird es nicht: Wer dort einen Symlink anlegen kann, hat schon Schreibrechte im Datenverzeichnis. Der Satz musste trotzdem weg. Dazu docs/SICHERHEIT.md: Bedrohungsmodell, der Durchgang entlang der OWASP Top 10 - und ein eigener Abschnitt darueber, was ausdruecklich NICHT geschuetzt ist. Aus dem OWASP-Durchgang kamen keine weiteren Funde. Zwei Stellen sahen danach aus und waren es nicht: Prisma.join() in suche.ts fuegt nur bereits parametrisierte Fragmente zusammen, und der Suchausschnitt mit den <mark>-Markern aus ts_headline wird zerlegt und als React-Kinder gerendert, nicht als HTML - obwohl die Quelle der OCR-Text hochgeladener Dokumente ist. Refs #38
105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
import 'server-only'
|
|
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
import { dirname, join, resolve, sep } from 'node:path'
|
|
import { env } from '@/lib/env'
|
|
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
|
|
import {
|
|
dateiRoutePfad,
|
|
schluesselErfordern,
|
|
zugriffTokenErzeugen,
|
|
TOKEN_STANDARD_SEKUNDEN,
|
|
} from '@/lib/speicher/schluessel'
|
|
|
|
/**
|
|
* Treiber "datei": Ablage in einem Verzeichnis auf dem Server.
|
|
*
|
|
* Standard in der Entwicklung und fuer kleine Installationen, bei denen
|
|
* kein Objektspeicher gewuenscht ist - die Dateien liegen dann auf der
|
|
* verschluesselten Platte des eigenen Servers und verlassen sie nie.
|
|
*
|
|
* Zusaetzlich wird jede Datei anwendungsseitig mit AES-256-GCM
|
|
* verschluesselt. Grund ist derselbe wie bei der Feldverschluesselung: ein
|
|
* kopiertes Backup oder ein versehentlich falsch gerechtetes Verzeichnis
|
|
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
|
|
* Der Schluessel liegt in der Umgebung, nicht neben den Daten.
|
|
*
|
|
* Der Umschlag selbst steht in src/lib/speicher/umschlag.ts - der
|
|
* Objektspeicher benutzt denselben.
|
|
*/
|
|
|
|
function wurzel(): string {
|
|
return resolve(process.cwd(), env.dateiSpeicherPfad)
|
|
}
|
|
|
|
/**
|
|
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
|
|
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
|
|
* streng geprueft, aber diese zweite Sperre kostet nichts.
|
|
*
|
|
* Was sie NICHT abfaengt, obwohl hier lange das Gegenteil stand: Symlinks.
|
|
* resolve() rechnet nur mit Zeichenketten und folgt keinem Link - dafuer
|
|
* braeuchte es realpath(). Ein untergeschobener Symlink im Ablageverzeichnis
|
|
* wuerde also nicht auffallen.
|
|
*
|
|
* Nachgeruestet wird das trotzdem nicht, und zwar aus einem Grund und nicht
|
|
* aus Bequemlichkeit: Wer dort einen Symlink anlegen kann, hat bereits
|
|
* Schreibrechte im Datenverzeichnis des Servers - dann ist der Umweg ueber
|
|
* einen Link die Sorge, die am wenigsten draengt. Der Satz musste
|
|
* verschwinden, weil er eine Sicherheit versprach, die es nie gab.
|
|
*/
|
|
function pfadFuer(schluessel: string): string {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
const basis = wurzel()
|
|
const ziel = resolve(basis, ...geprueft.split('/'))
|
|
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
|
|
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
|
|
}
|
|
return ziel
|
|
}
|
|
|
|
export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
|
|
const pfad = pfadFuer(schluessel)
|
|
await mkdir(dirname(pfad), { recursive: true })
|
|
await writeFile(pfad, verschluesselnBinaer(inhalt, schluessel), { mode: 0o600 })
|
|
}
|
|
|
|
export async function lesen(schluessel: string): Promise<Buffer> {
|
|
const roh = await readFile(pfadFuer(schluessel))
|
|
return entschluesselnBinaer(roh, schluessel)
|
|
}
|
|
|
|
export async function entfernen(schluessel: string): Promise<void> {
|
|
await rm(pfadFuer(schluessel), { force: true })
|
|
}
|
|
|
|
export async function existiert(schluessel: string): Promise<boolean> {
|
|
try {
|
|
const angaben = await stat(pfadFuer(schluessel))
|
|
return angaben.isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zeigt auf die eigene, ebenfalls signierte Route. Diese Route prueft
|
|
* zusaetzlich die Berechtigung des angemeldeten Benutzers und schreibt
|
|
* einen Audit-Eintrag - ein Link allein oeffnet also nichts.
|
|
*/
|
|
export async function signierteUrl(
|
|
schluessel: string,
|
|
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
|
): Promise<string> {
|
|
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
|
|
}
|
|
|
|
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
|
|
export function ablageverzeichnis(): string {
|
|
return wurzel()
|
|
}
|
|
|
|
/** Sorgt dafuer, dass das Verzeichnis existiert (Start, Tests, Skripte). */
|
|
export async function verzeichnisAnlegen(): Promise<void> {
|
|
await mkdir(join(wurzel(), 'betreute'), { recursive: true })
|
|
}
|