fix(security): Symlinks im Scanner, Exportpfad und Belegnummer-URL absichern

- Scanner: Dateien von der Scan-Freigabe werden nur übernommen, wenn sie
  reguläre Dateien in echten Verzeichnissen sind. chokidar folgt keinen
  Symlinks mehr; beim Kopieren über Mount-Grenzen (/mnt/scans → /mnt/data)
  wird mit O_NOFOLLOW geöffnet und nur exklusiv geschrieben. Bisher holte
  ein Symlink wie scan.pdf -> /proc/self/environ die Zieldatei in den
  Posteingang.
- Export: Dateinamen (enthalten u.a. Barcode-Werte) werden auf ein
  Pfadsegment reduziert – „../“ verlässt den FTP-Zielordner nicht mehr.
- Belegnummer: {Jahr}/{Nummer} werden URL-kodiert in BELEGNUMMER_*_URL
  eingesetzt (Nummer stammt aus Request bzw. gedrucktem Barcode);
  belegnummer/release prüft die Typen im Body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-04 10:46:57 +01:00
1 parent c28475f71f
commit fe338271bf
6 files changed
+260 -28

No files matched your search

@@ -0,0 +1,98 @@
import { constants } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import { pipeline } from 'stream/promises';
/**
* Macht aus einem (teils von außen stammenden) Namen genau ein Pfadsegment:
* Pfadtrenner und Steuerzeichen werden zu `_`, `.`/`..`/leer zu `_`.
* Verhindert, dass z.B. ein Barcode-Wert den Exportpfad verlässt.
*/
export function sichererDateiname(name: string): string {
// eslint-disable-next-line no-control-regex
const bereinigt = name.replace(/[/\\\u0000-\u001f\u007f]/g, '_');
return bereinigt === '' || bereinigt === '.' || bereinigt === '..'
? '_'
: bereinigt;
}
/**
* Ersetzt `{Name}`-Platzhalter in einer URL-Vorlage URL-kodiert, damit ein
* Wert Pfad, Query oder Fragment der konfigurierten URL nicht ändern kann.
*/
export function ersetzeUrlPlatzhalter(
vorlage: string,
werte: Record<string, string>,
): string {
return Object.entries(werte).reduce(
(url, [name, wert]) =>
url.split(`{${name}}`).join(encodeURIComponent(wert)),
vorlage,
);
}
/**
* Verschiebt eine Datei von einer fremd beschreibbaren Freigabe (Scanner),
* aber nur, wenn sie eine reguläre Datei in einem echten Verzeichnis ist.
* Symlinks würden sonst beliebige Dateien des Servers in den Posteingang
* holen – beim Kopieren über Mount-Grenzen folgt `copyFile` ihnen.
*/
export async function uebernehmeRegulaereDatei(
quelle: string,
ziel: string,
optionen: {
rename?: (von: string, nach: string) => Promise<void>;
} = {},
): Promise<void> {
const verzeichnis = await fs.lstat(path.dirname(quelle));
if (!verzeichnis.isDirectory()) {
throw new Error(`${path.dirname(quelle)} ist kein echtes Verzeichnis`);
}
if (!(await fs.lstat(quelle)).isFile()) {
throw new Error(`${quelle} ist keine reguläre Datei`);
}
const rename = optionen.rename ?? fs.rename;
try {
await rename(quelle, ziel);
// Zwischen Prüfung und rename könnte die Datei ersetzt worden sein.
if (!(await fs.lstat(ziel)).isFile()) {
await fs.unlink(ziel);
throw new Error(`${quelle} ist keine reguläre Datei`);
}
return;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err;
}
// Über Mount-Grenzen: ohne Symlinks zu folgen öffnen, dann kopieren.
const lesen = await fs.open(
quelle,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
if (!(await lesen.stat()).isFile()) {
throw new Error(`${quelle} ist keine reguläre Datei`);
}
const schreiben = await fs.open(ziel, 'wx');
try {
await pipeline(lesen.createReadStream(), schreiben.createWriteStream());
} catch (err) {
await fs.unlink(ziel).catch(() => undefined);
throw err;
} finally {
await schreiben.close().catch(() => undefined);
}
} finally {
await lesen.close().catch(() => undefined);
}
// Wenn das Löschen scheitert, Kopie zurückrollen, damit ein kaputter Mount
// nicht bei jedem Neustart Duplikate produziert.
try {
await fs.unlink(quelle);
} catch (err) {
await fs.unlink(ziel).catch(() => undefined);
throw err;
}
}