diff --git a/paperless-backend/src/common/pfad-sicherheit.util.spec.ts b/paperless-backend/src/common/pfad-sicherheit.util.spec.ts new file mode 100644 index 0000000..6b9b8f5 --- /dev/null +++ b/paperless-backend/src/common/pfad-sicherheit.util.spec.ts @@ -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'); + }); +}); diff --git a/paperless-backend/src/common/pfad-sicherheit.util.ts b/paperless-backend/src/common/pfad-sicherheit.util.ts new file mode 100644 index 0000000..bfa6fd5 --- /dev/null +++ b/paperless-backend/src/common/pfad-sicherheit.util.ts @@ -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 { + 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; + } = {}, +): Promise { + 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; + } +} diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 013b56e..03ecbbd 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -92,8 +92,13 @@ export class EmailImportController { @Post('belegnummer/release') @RequirePermissions(Permission.VIEW_MAIL) - async releaseBelegnummer(@Body() body: { date: string; number: string }) { - if (!body.date || !body.number) + async releaseBelegnummer(@Body() body: { date: unknown; number: unknown }) { + if ( + typeof body?.date !== 'string' || + typeof body.number !== 'string' || + !body.date || + !body.number + ) throw new HttpException( 'Date and number required', HttpStatus.BAD_REQUEST, diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index c0fd1b2..412b25e 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -28,6 +28,7 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs/promises'; import * as crypto from 'crypto'; +import { ersetzeUrlPlatzhalter } from '../common/pfad-sicherheit.util'; function sanitizeFilename(name: string): string { return name @@ -152,7 +153,18 @@ export class EmailImportService { const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj) .getFullYear() .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 { @@ -207,8 +219,7 @@ export class EmailImportService { } const cleanNumber = number.replace(/^0+/, '') || '0'; - let url = this.buildUrl(urlTemplate, emailDate); - url = url.replace('{Nummer}', cleanNumber); + const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber); try { this.logger.log( @@ -231,8 +242,7 @@ export class EmailImportService { ); const cleanNumber = number.replace(/^0+/, '') || '0'; - let url = this.buildUrl(urlTemplate, emailDate); - url = url.replace('{Nummer}', cleanNumber); + const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber); try { this.logger.log( diff --git a/paperless-backend/src/postprocessing/export.service.ts b/paperless-backend/src/postprocessing/export.service.ts index 8ffdd8d..0ccb813 100644 --- a/paperless-backend/src/postprocessing/export.service.ts +++ b/paperless-backend/src/postprocessing/export.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ExportTarget } from '../database/entities/export-target.entity'; import * as ftp from 'basic-ftp'; +import { sichererDateiname } from '../common/pfad-sicherheit.util'; @Injectable() export class ExportService { @@ -24,9 +25,17 @@ export class ExportService { 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) { case 'ftp': - await this.uploadFtp(target, filename, content); + await this.uploadFtp(target, dateiname, content); break; default: throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`); diff --git a/paperless-backend/src/scanner/scanner-watcher.service.ts b/paperless-backend/src/scanner/scanner-watcher.service.ts index fb59965..fa90da5 100644 --- a/paperless-backend/src/scanner/scanner-watcher.service.ts +++ b/paperless-backend/src/scanner/scanner-watcher.service.ts @@ -18,6 +18,7 @@ import { InboxDocument, type InboxSource, } from '../database/entities/inbox-document.entity'; +import { uebernehmeRegulaereDatei } from '../common/pfad-sicherheit.util'; const STABILITY_MS = 5000; @@ -71,6 +72,8 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { pollInterval: 500, }, depth: 1, + // Symlinks auf der Scan-Freigabe nicht verfolgen (siehe uebernehmeRegulaereDatei) + followSymlinks: false, }); this.watcher @@ -184,7 +187,11 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { if (this.processing.has(filePath)) return; 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) { this.logger.debug(`Überspringe leere Datei: ${filePath}`); return; @@ -204,7 +211,7 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { const targetPdf = this.pageCache.documentPdfPath(id); await fs.mkdir(targetDir, { recursive: true }); - await this.move(filePath, targetPdf); + await uebernehmeRegulaereDatei(filePath, targetPdf); const doc = this.documentRepo.create({ Id: id, @@ -262,22 +269,4 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { this.logger.log('Backfill: alle Dateien bereits gescannt'); } } - - private async move(src: string, dest: string): Promise { - 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; - } - } }