Files
betreuungs-cockpit/src/lib/speicher/datei.ts
T
Florian Wilde e48eda4332 Irrefuehrende Sicherheitszusagen entfernen, OWASP-Durchgang festhalten
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
2026-08-24 09:53:43 +02:00

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 })
}