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,121 @@
import {
mkdtemp,
mkdir,
writeFile,
symlink,
readFile,
lstat,
} from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import {
ersetzeUrlPlatzhalter,
sichererDateiname,
uebernehmeRegulaereDatei,
} from './pfad-sicherheit.util';
describe('sichererDateiname', () => {
it('lässt normale Namen unverändert', () => {
expect(sichererDateiname('2026-0042_Rechnung Müller.pdf')).toBe(
'2026-0042_Rechnung Müller.pdf',
);
});
it.each([
['../../etc/cron.d/x.pdf', '.._.._etc_cron.d_x.pdf'],
['a/b\\c.pdf', 'a_b_c.pdf'],
['..', '_'],
['.', '_'],
['', '_'],
['x\u0000y\r\n.pdf', 'x_y__.pdf'],
])('macht %p zu einem einzelnen Pfadsegment', (eingabe, erwartet) => {
expect(sichererDateiname(eingabe)).toBe(erwartet);
});
});
describe('ersetzeUrlPlatzhalter', () => {
it('setzt Werte URL-kodiert ein', () => {
expect(
ersetzeUrlPlatzhalter('https://beleg.example/{Jahr}/set/{Nummer}', {
Jahr: '2026',
Nummer: '42',
}),
).toBe('https://beleg.example/2026/set/42');
});
it('verhindert Pfad- und Query-Manipulation', () => {
expect(
ersetzeUrlPlatzhalter('https://beleg.example/set/{Nummer}?k=1', {
Nummer: '../../admin/delete?all=1#',
}),
).toBe(
'https://beleg.example/set/..%2F..%2Fadmin%2Fdelete%3Fall%3D1%23?k=1',
);
});
it('ersetzt jedes Vorkommen', () => {
expect(ersetzeUrlPlatzhalter('/{Nummer}/{Nummer}', { Nummer: 'a b' })).toBe(
'/a%20b/a%20b',
);
});
});
describe('uebernehmeRegulaereDatei', () => {
let wurzel: string;
let quelle: string;
let ziel: string;
beforeEach(async () => {
wurzel = await mkdtemp(join(tmpdir(), 'scan-'));
await mkdir(join(wurzel, 'scans', 'bob'), { recursive: true });
await mkdir(join(wurzel, 'data'));
await writeFile(join(wurzel, 'geheim.txt'), 'GEHEIM');
quelle = join(wurzel, 'scans', 'bob', 'scan.pdf');
ziel = join(wurzel, 'data', 'document.pdf');
});
// Wie in Produktion: /mnt/scans und /mnt/data sind verschiedene Mounts.
const crossDevice = () =>
Promise.reject(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
it.each([
['rename', undefined],
['copy (EXDEV)', crossDevice],
])('übernimmt reguläre Dateien per %s', async (_n, rename) => {
await writeFile(quelle, '%PDF-1.7');
await uebernehmeRegulaereDatei(quelle, ziel, { rename });
expect(await readFile(ziel, 'utf8')).toBe('%PDF-1.7');
await expect(lstat(quelle)).rejects.toThrow();
});
it.each([
['rename', undefined],
['copy (EXDEV)', crossDevice],
])('lehnt Symlinks ab (%s)', async (_n, rename) => {
await symlink(join(wurzel, 'geheim.txt'), quelle);
await expect(
uebernehmeRegulaereDatei(quelle, ziel, { rename }),
).rejects.toThrow(/keine reguläre Datei/);
await expect(lstat(ziel)).rejects.toThrow();
});
it('lehnt Dateien in symverlinkten Verzeichnissen ab', async () => {
await mkdir(join(wurzel, 'fremd'));
await writeFile(join(wurzel, 'fremd', 'x.pdf'), 'FREMD');
await symlink(join(wurzel, 'fremd'), join(wurzel, 'scans', 'eve'));
await expect(
uebernehmeRegulaereDatei(join(wurzel, 'scans', 'eve', 'x.pdf'), ziel, {
rename: crossDevice,
}),
).rejects.toThrow(/Verzeichnis/);
});
it('überschreibt beim Kopieren keine vorhandene Zieldatei', async () => {
await writeFile(quelle, 'NEU');
await writeFile(ziel, 'ALT');
await expect(
uebernehmeRegulaereDatei(quelle, ziel, { rename: crossDevice }),
).rejects.toThrow();
expect(await readFile(ziel, 'utf8')).toBe('ALT');
});
});
@@ -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;
}
}
@@ -92,8 +92,13 @@ export class EmailImportController {
@Post('belegnummer/release') @Post('belegnummer/release')
@RequirePermissions(Permission.VIEW_MAIL) @RequirePermissions(Permission.VIEW_MAIL)
async releaseBelegnummer(@Body() body: { date: string; number: string }) { async releaseBelegnummer(@Body() body: { date: unknown; number: unknown }) {
if (!body.date || !body.number) if (
typeof body?.date !== 'string' ||
typeof body.number !== 'string' ||
!body.date ||
!body.number
)
throw new HttpException( throw new HttpException(
'Date and number required', 'Date and number required',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
@@ -28,6 +28,7 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { ersetzeUrlPlatzhalter } from '../common/pfad-sicherheit.util';
function sanitizeFilename(name: string): string { function sanitizeFilename(name: string): string {
return name return name
@@ -152,7 +153,18 @@ export class EmailImportService {
const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj) const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj)
.getFullYear() .getFullYear()
.toString(); .toString();
return urlTemplate.replace('{Jahr}', year); return ersetzeUrlPlatzhalter(urlTemplate, { Jahr: year });
}
/** URL mit Jahr und Belegnummer – die Nummer kommt u.a. aus Barcodes. */
private buildNummerUrl(
urlTemplate: string,
dateStr: string,
nummer: string,
): string {
return ersetzeUrlPlatzhalter(this.buildUrl(urlTemplate, dateStr), {
Nummer: nummer,
});
} }
async getBelegnummer(emailDate: string): Promise<string> { async getBelegnummer(emailDate: string): Promise<string> {
@@ -207,8 +219,7 @@ export class EmailImportService {
} }
const cleanNumber = number.replace(/^0+/, '') || '0'; const cleanNumber = number.replace(/^0+/, '') || '0';
let url = this.buildUrl(urlTemplate, emailDate); const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber);
url = url.replace('{Nummer}', cleanNumber);
try { try {
this.logger.log( this.logger.log(
@@ -231,8 +242,7 @@ export class EmailImportService {
); );
const cleanNumber = number.replace(/^0+/, '') || '0'; const cleanNumber = number.replace(/^0+/, '') || '0';
let url = this.buildUrl(urlTemplate, emailDate); const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber);
url = url.replace('{Nummer}', cleanNumber);
try { try {
this.logger.log( this.logger.log(
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ExportTarget } from '../database/entities/export-target.entity'; import { ExportTarget } from '../database/entities/export-target.entity';
import * as ftp from 'basic-ftp'; import * as ftp from 'basic-ftp';
import { sichererDateiname } from '../common/pfad-sicherheit.util';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
@@ -24,9 +25,17 @@ export class ExportService {
throw new Error(`Export-Ziel "${target.Name}" ist deaktiviert.`); throw new Error(`Export-Ziel "${target.Name}" ist deaktiviert.`);
} }
// Der Name enthält u.a. Barcode-Werte – er darf den Zielordner nicht verlassen.
const dateiname = sichererDateiname(filename);
if (dateiname !== filename) {
this.logger.warn(
`Export-Dateiname bereinigt: "${filename}" → "${dateiname}"`,
);
}
switch (target.Protocol) { switch (target.Protocol) {
case 'ftp': case 'ftp':
await this.uploadFtp(target, filename, content); await this.uploadFtp(target, dateiname, content);
break; break;
default: default:
throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`); throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`);
@@ -18,6 +18,7 @@ import {
InboxDocument, InboxDocument,
type InboxSource, type InboxSource,
} from '../database/entities/inbox-document.entity'; } from '../database/entities/inbox-document.entity';
import { uebernehmeRegulaereDatei } from '../common/pfad-sicherheit.util';
const STABILITY_MS = 5000; const STABILITY_MS = 5000;
@@ -71,6 +72,8 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
pollInterval: 500, pollInterval: 500,
}, },
depth: 1, depth: 1,
// Symlinks auf der Scan-Freigabe nicht verfolgen (siehe uebernehmeRegulaereDatei)
followSymlinks: false,
}); });
this.watcher this.watcher
@@ -184,7 +187,11 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
if (this.processing.has(filePath)) return; if (this.processing.has(filePath)) return;
try { try {
const stat = await fs.stat(filePath); const stat = await fs.lstat(filePath);
if (!stat.isFile()) {
this.logger.warn(`Überspringe (keine reguläre Datei): ${filePath}`);
return;
}
if (stat.size === 0) { if (stat.size === 0) {
this.logger.debug(`Überspringe leere Datei: ${filePath}`); this.logger.debug(`Überspringe leere Datei: ${filePath}`);
return; return;
@@ -204,7 +211,7 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
const targetPdf = this.pageCache.documentPdfPath(id); const targetPdf = this.pageCache.documentPdfPath(id);
await fs.mkdir(targetDir, { recursive: true }); await fs.mkdir(targetDir, { recursive: true });
await this.move(filePath, targetPdf); await uebernehmeRegulaereDatei(filePath, targetPdf);
const doc = this.documentRepo.create({ const doc = this.documentRepo.create({
Id: id, Id: id,
@@ -262,22 +269,4 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
this.logger.log('Backfill: alle Dateien bereits gescannt'); this.logger.log('Backfill: alle Dateien bereits gescannt');
} }
} }
private async move(src: string, dest: string): Promise<void> {
try {
await fs.rename(src, dest);
return;
} catch (err: any) {
if (err.code !== 'EXDEV') throw err;
}
// Cross-device: copy + unlink. Wenn unlink scheitert, Kopie zurückrollen,
// damit ein kaputter Mount nicht bei jedem Neustart Duplikate produziert.
await fs.copyFile(src, dest);
try {
await fs.unlink(src);
} catch (err) {
await fs.unlink(dest).catch(() => undefined);
throw err;
}
}
} }