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