fix(security): Befunde aus dem Security-Audit beheben #7

Merged
bjoernpoettker merged 10 commits from fix/security-audit into main 2026-11-04 10:03:36 +00:00
4 changed files with 80 additions and 7 deletions
Showing only changes of commit b11294ec10 - Show all commits

No files matched your search

@@ -1,10 +1,11 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import sharp from 'sharp'; import sharp from 'sharp';
const THUMBNAIL_WIDTH = 180; 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() @Injectable()
export class PageCacheService { 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 { documentDir(documentId: string): string {
if (!UUID.test(documentId)) {
throw new BadRequestException('Ungültige Dokument-ID');
}
return path.join(this.inboxRoot, documentId); return path.join(this.inboxRoot, documentId);
} }
@@ -10,6 +10,7 @@ import {
HttpStatus, HttpStatus,
Logger, Logger,
Delete, Delete,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { EmailImportService } from './email-import.service'; import { EmailImportService } from './email-import.service';
@@ -201,8 +202,8 @@ export class EmailImportController {
@Get('attachments/:attachmentId/pages/:page/thumb') @Get('attachments/:attachmentId/pages/:page/thumb')
@RequirePermissions(Permission.VIEW_MAIL) @RequirePermissions(Permission.VIEW_MAIL)
async getPageThumbnail( async getPageThumbnail(
@Param('attachmentId') attachmentId: number, @Param('attachmentId', ParseIntPipe) attachmentId: number,
@Param('page') page: number, @Param('page', ParseIntPipe) page: number,
@Res() res: Response, @Res() res: Response,
) { ) {
const hasPreview = await this.pageCache.hasPreview(attachmentId, page); const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
@@ -217,8 +218,8 @@ export class EmailImportController {
@Get('attachments/:attachmentId/pages/:page/preview') @Get('attachments/:attachmentId/pages/:page/preview')
@RequirePermissions(Permission.VIEW_MAIL) @RequirePermissions(Permission.VIEW_MAIL)
async getPagePreview( async getPagePreview(
@Param('attachmentId') attachmentId: number, @Param('attachmentId', ParseIntPipe) attachmentId: number,
@Param('page') page: number, @Param('page', ParseIntPipe) page: number,
@Res() res: Response, @Res() res: Response,
) { ) {
const hasPreview = await this.pageCache.hasPreview(attachmentId, page); const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
@@ -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);
},
);
});
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
@@ -6,6 +6,12 @@ import sharp from 'sharp';
const THUMBNAIL_WIDTH = 180; const THUMBNAIL_WIDTH = 180;
function pruefeSeite(page: number): void {
if (!Number.isSafeInteger(page) || page < 1) {
throw new BadRequestException('Ungültige Seitenzahl');
}
}
@Injectable() @Injectable()
export class EmailPageCacheService { export class EmailPageCacheService {
private readonly logger = new Logger(EmailPageCacheService.name); 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<uuid>` aus dem Mail-Cache in den Scanner-Cache.
*/
attachmentDir(attachmentId: number | string): string { 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 { previewPath(attachmentId: number | string, page: number): string {
pruefeSeite(page);
return path.join( return path.join(
this.attachmentDir(attachmentId), this.attachmentDir(attachmentId),
`page-${page}.preview.png`, `page-${page}.preview.png`,
@@ -30,6 +45,7 @@ export class EmailPageCacheService {
} }
thumbnailPath(attachmentId: number | string, page: number): string { thumbnailPath(attachmentId: number | string, page: number): string {
pruefeSeite(page);
return path.join( return path.join(
this.attachmentDir(attachmentId), this.attachmentDir(attachmentId),
`page-${page}.thumb.png`, `page-${page}.thumb.png`,