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
9 changed files with 138 additions and 53 deletions
Showing only changes of commit e0a6598b35 - Show all commits

No files matched your search

@@ -2,6 +2,7 @@ import { Permission } from '../auth/permissions.enum';
import { import {
POSTEINGANG_TAG_ID, POSTEINGANG_TAG_ID,
darfDokumentBearbeiten, darfDokumentBearbeiten,
dokumentKurzinfo,
} from './dokument-zugriff.util'; } from './dokument-zugriff.util';
const MANUELL = 6; const MANUELL = 6;
@@ -43,3 +44,24 @@ describe('darfDokumentBearbeiten', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('dokumentKurzinfo', () => {
it('liefert nur Kennung, Titel, ASN und Datum – keinen OCR-Volltext', () => {
const doc = {
id: 5,
title: 'Rechnung',
archive_serial_number: 42,
created: '2026-01-02',
content: 'IBAN DE00 …',
owner: 3,
custom_fields: [{ field: 15, value: 'x' }],
notes: [{ note: 'intern' }],
};
expect(dokumentKurzinfo(doc)).toEqual({
id: 5,
title: 'Rechnung',
archive_serial_number: 42,
created: '2026-01-02',
});
});
});
@@ -23,3 +23,29 @@ export function darfDokumentBearbeiten(
permissions.includes(Permission.PROCESS_MANUALLY)) permissions.includes(Permission.PROCESS_MANUALLY))
); );
} }
export interface DokumentKurzinfo {
id: number;
title: string;
archive_serial_number: number | null;
created: string;
}
/**
* Für Suche und Verknüpfungsanzeige genügen Kennung, Titel, ASN und Datum.
* Das vollständige Paperless-Dokument enthält u.a. den OCR-Volltext und
* Statusfelder – die sollen über diese Routen nicht abrufbar sein.
*/
export function dokumentKurzinfo(doc: {
id: number;
title: string;
archive_serial_number?: number | null;
created: string;
}): DokumentKurzinfo {
return {
id: doc.id,
title: doc.title,
archive_serial_number: doc.archive_serial_number ?? null,
created: doc.created,
};
}
@@ -18,12 +18,16 @@ import {
Query, Query,
Request, Request,
ForbiddenException, ForbiddenException,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
import { entferneGeschuetzteStatusfelder } from './status-felder.util'; import { entferneGeschuetzteStatusfelder } from './status-felder.util';
import { darfDokumentBearbeiten } from './dokument-zugriff.util'; import {
darfDokumentBearbeiten,
dokumentKurzinfo,
} from './dokument-zugriff.util';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -42,6 +46,15 @@ import { ErechnungService } from '../erechnung/erechnung.service';
/** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */ /** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */
export const STEUERTAG_SETTING_KEY = 'steuertag_ids'; export const STEUERTAG_SETTING_KEY = 'steuertag_ids';
/** Von JwtStrategy gesetzt; die globalen Guards lassen nur Angemeldete durch. */
interface BenutzerRequest {
user: {
userId: string;
permissions?: Permission[];
preferredUsername?: string;
};
}
@Controller('api/paperless') @Controller('api/paperless')
export class PaperlessController { export class PaperlessController {
private readonly logger = new Logger(PaperlessController.name); private readonly logger = new Logger(PaperlessController.name);
@@ -94,8 +107,9 @@ export class PaperlessController {
return this.paperlessService.pruefeBelegnummer(belegnummer ?? ''); return this.paperlessService.pruefeBelegnummer(belegnummer ?? '');
} }
/** Dokumentsuche für Verknüpfungsfelder im Bearbeiten-Dialog. */
@Get('documents') @Get('documents')
@RequirePermissions(Permission.MANAGE_SETTINGS) @RequirePermissions(Permission.VIEW_INBOX)
async getDocuments( async getDocuments(
@Query('search') search?: string, @Query('search') search?: string,
@Query('page') page?: string, @Query('page') page?: string,
@@ -108,13 +122,18 @@ export class PaperlessController {
if (search) { if (search) {
params.query = search; // Global search in Paperless params.query = search; // Global search in Paperless
} }
return this.paperlessService.getDocuments(params); const ergebnis = await this.paperlessService.getDocuments(params);
return {
count: ergebnis.count,
results: (ergebnis.results ?? []).map(dokumentKurzinfo),
};
} }
/** Titel verknüpfter Dokumente – bewusst ohne Prüfung pro Dokument. */
@Get('documents/:id') @Get('documents/:id')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) @RequirePermissions(Permission.VIEW_INBOX)
async getDocument(@Param('id') id: string) { async getDocument(@Param('id', ParseIntPipe) id: number) {
return this.paperlessService.getDocument(parseInt(id, 10)); return dokumentKurzinfo(await this.paperlessService.getDocument(id));
} }
@Get('tags') @Get('tags')
@@ -226,9 +245,13 @@ export class PaperlessController {
})); }));
} }
/**
* Vorschaubild – auch für Suchergebnisse außerhalb des Posteingangs,
* deshalb ohne Prüfung pro Dokument.
*/
@Get('documents/:id/preview')
@RequirePermissions(Permission.VIEW_INBOX) @RequirePermissions(Permission.VIEW_INBOX)
@Get('inbox/preview/:id') async getDocumentPreview(@Param('id') id: string, @Res() res: Response) {
async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
try { try {
const stream = await this.paperlessService.getDocumentPreviewStream( const stream = await this.paperlessService.getDocumentPreviewStream(
parseInt(id, 10), parseInt(id, 10),
@@ -252,9 +275,14 @@ export class PaperlessController {
} }
} }
@RequirePermissions(Permission.VIEW_INBOX) /**
@Get('inbox/pdf/:id') * Original-PDF – für den Bearbeiten-Dialog und den Duplikatvergleich im
async getInboxPdf(@Param('id') id: string, @Res() res: Response) { * Scanner (dort ein beliebiges vorhandenes Dokument), deshalb ohne Prüfung
* pro Dokument.
*/
@Get('documents/:id/pdf')
@RequirePermissions(Permission.VIEW_INBOX, Permission.VIEW_SCANNER)
async getDocumentPdf(@Param('id') id: string, @Res() res: Response) {
try { try {
const stream = await this.paperlessService.getDocumentPdfStream( const stream = await this.paperlessService.getDocumentPdfStream(
parseInt(id, 10), parseInt(id, 10),
@@ -362,22 +390,22 @@ export class PaperlessController {
* Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument * Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument
* keine auswertbare E-Rechnung enthält. * keine auswertbare E-Rechnung enthält.
*/ */
@Get('inbox/:id/erechnung') @Get('documents/:id/erechnung')
@RequirePermissions(Permission.VIEW_INBOX) @RequirePermissions(Permission.VIEW_INBOX)
async getInboxErechnung(@Param('id') id: string, @Request() req: any) { async getDocumentErechnung(
return this.erechnungService.analysiereDokument( @Param('id', ParseIntPipe) id: number,
parseInt(id, 10), @Request() req: BenutzerRequest,
req.user?.userId, ) {
); await this.ladeBearbeitbaresDokument(id, req);
return this.erechnungService.analysiereDokument(id, req.user.userId);
} }
@Put('inbox/:id') @Put('documents/:id')
@RequirePermissions(Permission.VIEW_INBOX) @RequirePermissions(Permission.VIEW_INBOX)
async putInboxDocument( async putDocument(
@Param('id') id: string, @Param('id') id: string,
@Body() body: any, @Body() body: any,
@Request() @Request() req: BenutzerRequest,
req: { user?: { permissions?: Permission[]; preferredUsername?: string } },
) { ) {
const documentId = parseInt(id, 10); const documentId = parseInt(id, 10);
@@ -391,25 +419,7 @@ export class PaperlessController {
); );
} }
body.customFields = felder; body.customFields = felder;
// Fetch from paperless const oldDocument = await this.ladeBearbeitbaresDokument(documentId, req);
const oldDocument = await this.paperlessService.getDocument(documentId);
// Nur Dokumente aus dem eigenen Arbeitsbereich (Posteingang bzw. Manuell)
// – sonst ließen sich Mandant/Felder beliebiger Dokumente ändern.
if (
!darfDokumentBearbeiten(
oldDocument.tags as number[] | undefined,
req.user?.permissions ?? [],
this.paperlessService.getManuellTagId(),
)
) {
this.logger.warn(
`Dokument ${documentId}: Bearbeitung abgelehnt – nicht im Posteingang/Manuell-Bereich des Benutzers (${req.user?.preferredUsername ?? 'unbekannt'})`,
);
throw new ForbiddenException(
'Dieses Dokument liegt nicht in Ihrem Posteingang bzw. Ihrer Bearbeitungsliste',
);
}
oldDocument.document_type = body.documentType ?? oldDocument.document_type; oldDocument.document_type = body.documentType ?? oldDocument.document_type;
oldDocument.owner = body.mandant ?? oldDocument.owner; oldDocument.owner = body.mandant ?? oldDocument.owner;
@@ -574,6 +584,33 @@ export class PaperlessController {
return { success: true }; return { success: true };
} }
/**
* Lädt ein Dokument, das der Benutzer bearbeiten darf: Posteingang-Rolle nur
* mit Posteingang-Tag, Buchhaltung zusätzlich mit Manuell-Tag. Das Backend
* arbeitet mit dem Service-Token, Paperless prüft hier also nichts.
*/
private async ladeBearbeitbaresDokument(
documentId: number,
req: BenutzerRequest,
): Promise<any> {
const dokument = await this.paperlessService.getDocument(documentId);
if (
!darfDokumentBearbeiten(
dokument.tags as number[] | undefined,
req.user?.permissions ?? [],
this.paperlessService.getManuellTagId(),
)
) {
this.logger.warn(
`Dokument ${documentId}: Zugriff abgelehnt – nicht im Posteingang/Manuell-Bereich des Benutzers (${req.user?.preferredUsername ?? 'unbekannt'})`,
);
throw new ForbiddenException(
'Dieses Dokument liegt nicht in Ihrem Posteingang bzw. Ihrer Bearbeitungsliste',
);
}
return dokument;
}
@Get('tasks') @Get('tasks')
@RequirePermissions(Permission.MANAGE_SETTINGS) @RequirePermissions(Permission.MANAGE_SETTINGS)
async getAllTasks() { async getAllTasks() {
+1 -1
View File
@@ -81,7 +81,7 @@ export const paperlessApi = {
checksumExists: (checksum: string) => checksumExists: (checksum: string) =>
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists), api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
getDocumentPdfBlob: (id: number) => getDocumentPdfBlob: (id: number) =>
api.get<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data), api.get<Blob>(`/api/paperless/documents/${id}/pdf`, { responseType: 'blob' }).then(r => r.data),
pruefeBelegnummer: (belegnummer: string) => pruefeBelegnummer: (belegnummer: string) =>
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data), api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
}; };
+2 -2
View File
@@ -64,7 +64,7 @@ export const posteingangApi = {
.then((r) => r.data), .then((r) => r.data),
updateDocument: (id: number, data: any) => updateDocument: (id: number, data: any) =>
api.put(`/api/paperless/inbox/${id}`, data).then((r) => r.data), api.put(`/api/paperless/documents/${id}`, data).then((r) => r.data),
getKontonummern: (correspondentId: number) => getKontonummern: (correspondentId: number) =>
api.get<Kontonummer[]>(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data), api.get<Kontonummer[]>(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data),
@@ -73,5 +73,5 @@ export const posteingangApi = {
api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data), api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data),
getErechnung: (id: number) => getErechnung: (id: number) =>
api.get<ERechnungVorschlag>(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data), api.get<ERechnungVorschlag>(`/api/paperless/documents/${id}/erechnung`).then(r => r.data),
}; };
@@ -634,7 +634,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`); openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/documents/${currentId}/pdf`);
}} }}
/> />
)} )}
@@ -679,13 +679,13 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
icon={<ExportOutlined />} icon={<ExportOutlined />}
block block
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}`)} onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/documents/${documentId}/pdf`)}
> >
PDF in neuem Tab öffnen PDF in neuem Tab öffnen
</Button> </Button>
)} )}
<AuthIframe <AuthIframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${documentId}/pdf#toolbar=0`}
style={{ width: '100%', height: isMobile ? '50vh' : '100%' }} style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
title="PDF Preview" title="PDF Preview"
/> />
@@ -90,7 +90,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
<List.Item.Meta <List.Item.Meta
avatar={ avatar={
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${doc.id}/preview`}
width={80} width={80}
height={110} height={110}
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }} style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
@@ -75,7 +75,7 @@ export default function ManuellBearbeitenPage() {
const popoverContent = (id: number) => ( const popoverContent = (id: number) => (
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
width={400} width={400}
/> />
); );
@@ -108,7 +108,7 @@ export default function ManuellBearbeitenPage() {
render: (_: any, record: PosteingangDocument) => ( render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover"> <Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={100} width={100}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }} style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/> />
@@ -172,7 +172,7 @@ export default function ManuellBearbeitenPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={72} width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }} style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/> />
@@ -62,7 +62,7 @@ export default function PosteingangPage() {
const popoverContent = (id: number) => ( const popoverContent = (id: number) => (
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
width={400} width={400}
/> />
); );
@@ -80,7 +80,7 @@ export default function PosteingangPage() {
render: (_: any, record: PosteingangDocument) => ( render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover"> <Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={100} width={100}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }} style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/> />
@@ -136,7 +136,7 @@ export default function PosteingangPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<AuthImage <AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`} src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={72} width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }} style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/> />