From b11294ec108ba1087225f4970c8aeb34eb82ccc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Wed, 4 Nov 2026 10:19:30 +0100 Subject: [PATCH] fix(security): Path-Traversal in den Mail-Vorschau-Routen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachments/:attachmentId/pages/:page/{thumb,preview} übernahm die ID ungeprüft in den Dateipfad – mit `..%2Finbox%2F` ließen sich Scanner-Vorschauen anderer Benutzer aus /mnt/data/inbox lesen. - Routen wandeln attachmentId und page per ParseIntPipe in Zahlen um. - EmailPageCacheService nimmt nur positive ganze Zahlen als Anhang-ID und Seite an, PageCacheService (Scanner) nur UUIDs als Dokument-ID. Co-Authored-By: Claude Opus 5.5 --- .../src/barcode/page-cache.service.ts | 7 ++- .../src/email/email-import.controller.ts | 9 ++-- .../email/email-page-cache.service.spec.ts | 51 +++++++++++++++++++ .../src/email/email-page-cache.service.ts | 20 +++++++- 4 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 paperless-backend/src/email/email-page-cache.service.spec.ts diff --git a/paperless-backend/src/barcode/page-cache.service.ts b/paperless-backend/src/barcode/page-cache.service.ts index 32c5c9a..500e2c9 100644 --- a/paperless-backend/src/barcode/page-cache.service.ts +++ b/paperless-backend/src/barcode/page-cache.service.ts @@ -1,10 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import * as fs from 'fs/promises'; import sharp from 'sharp'; const THUMBNAIL_WIDTH = 180; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @Injectable() export class PageCacheService { @@ -18,7 +19,11 @@ export class PageCacheService { ); } + /** Scanner-Dokumente haben UUIDs – alles andere würde den Pfad verlassen. */ documentDir(documentId: string): string { + if (!UUID.test(documentId)) { + throw new BadRequestException('Ungültige Dokument-ID'); + } return path.join(this.inboxRoot, documentId); } diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 9802c63..013b56e 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -10,6 +10,7 @@ import { HttpStatus, Logger, Delete, + ParseIntPipe, } from '@nestjs/common'; import type { Response } from 'express'; import { EmailImportService } from './email-import.service'; @@ -201,8 +202,8 @@ export class EmailImportController { @Get('attachments/:attachmentId/pages/:page/thumb') @RequirePermissions(Permission.VIEW_MAIL) async getPageThumbnail( - @Param('attachmentId') attachmentId: number, - @Param('page') page: number, + @Param('attachmentId', ParseIntPipe) attachmentId: number, + @Param('page', ParseIntPipe) page: number, @Res() res: Response, ) { const hasPreview = await this.pageCache.hasPreview(attachmentId, page); @@ -217,8 +218,8 @@ export class EmailImportController { @Get('attachments/:attachmentId/pages/:page/preview') @RequirePermissions(Permission.VIEW_MAIL) async getPagePreview( - @Param('attachmentId') attachmentId: number, - @Param('page') page: number, + @Param('attachmentId', ParseIntPipe) attachmentId: number, + @Param('page', ParseIntPipe) page: number, @Res() res: Response, ) { const hasPreview = await this.pageCache.hasPreview(attachmentId, page); diff --git a/paperless-backend/src/email/email-page-cache.service.spec.ts b/paperless-backend/src/email/email-page-cache.service.spec.ts new file mode 100644 index 0000000..e82f718 --- /dev/null +++ b/paperless-backend/src/email/email-page-cache.service.spec.ts @@ -0,0 +1,51 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EmailPageCacheService } from './email-page-cache.service'; +import { PageCacheService } from '../barcode/page-cache.service'; + +const config = { + get: (key: string) => + key === 'MAILS_DATA_DIR' ? '/data/mails' : '/data/inbox', +} as unknown as ConfigService; + +describe('EmailPageCacheService – Pfade', () => { + const cache = new EmailPageCacheService(config); + + it('baut Pfade für numerische Anhang-IDs', () => { + expect(cache.previewPath(17, 2)).toBe('/data/mails/17/page-2.preview.png'); + expect(cache.thumbnailPath('17', 1)).toBe( + '/data/mails/17/page-1.thumb.png', + ); + }); + + it.each([ + '../inbox/0b6c2b6e-7c1e-4d8a-9a51-3f0c2d1e4b5a', + '..', + '17/../../etc', + '-1', + '1.5', + '', + ])('lehnt die Anhang-ID %p ab', (id) => { + expect(() => cache.previewPath(id, 1)).toThrow(BadRequestException); + }); + + it.each([0, -1, 1.5, NaN])('lehnt die Seite %p ab', (page) => { + expect(() => cache.thumbnailPath(17, page)).toThrow(BadRequestException); + }); +}); + +describe('PageCacheService (Scanner) – Pfade', () => { + const cache = new PageCacheService(config); + const uuid = '0b6c2b6e-7c1e-4d8a-9a51-3f0c2d1e4b5a'; + + it('baut Pfade für UUIDs', () => { + expect(cache.documentDir(uuid)).toBe(`/data/inbox/${uuid}`); + }); + + it.each(['../mails/17', '..', `${uuid}/..`, 'abc'])( + 'lehnt die Dokument-ID %p ab', + (id) => { + expect(() => cache.documentDir(id)).toThrow(BadRequestException); + }, + ); +}); diff --git a/paperless-backend/src/email/email-page-cache.service.ts b/paperless-backend/src/email/email-page-cache.service.ts index 80f8ff0..8d43231 100644 --- a/paperless-backend/src/email/email-page-cache.service.ts +++ b/paperless-backend/src/email/email-page-cache.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import * as fs from 'fs/promises'; @@ -6,6 +6,12 @@ import sharp from 'sharp'; const THUMBNAIL_WIDTH = 180; +function pruefeSeite(page: number): void { + if (!Number.isSafeInteger(page) || page < 1) { + throw new BadRequestException('Ungültige Seitenzahl'); + } +} + @Injectable() export class EmailPageCacheService { private readonly logger = new Logger(EmailPageCacheService.name); @@ -18,11 +24,20 @@ export class EmailPageCacheService { ); } + /** + * Die ID landet im Dateipfad – nur positive ganze Zahlen zulassen, sonst + * führt z.B. `..%2Finbox%2F` aus dem Mail-Cache in den Scanner-Cache. + */ attachmentDir(attachmentId: number | string): string { - return path.join(this.mailsRoot, attachmentId.toString()); + const id = String(attachmentId); + if (!/^[1-9]\d*$/.test(id)) { + throw new BadRequestException('Ungültige Anhang-ID'); + } + return path.join(this.mailsRoot, id); } previewPath(attachmentId: number | string, page: number): string { + pruefeSeite(page); return path.join( this.attachmentDir(attachmentId), `page-${page}.preview.png`, @@ -30,6 +45,7 @@ export class EmailPageCacheService { } thumbnailPath(attachmentId: number | string, page: number): string { + pruefeSeite(page); return path.join( this.attachmentDir(attachmentId), `page-${page}.thumb.png`,