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