Files
paperlessmanager/paperless-backend/src/common/pfad-sicherheit.util.ts
T
bjoernpoettkerandClaude Opus 5.5 fe338271bf 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>
2026-11-04 10:46:57 +01:00

99 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}