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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user