Compare commits

...

4 Commits

Author SHA1 Message Date
duffyduck 818f801939 Pentest R101.1: Inline-Preview-Pfad refaktoriert + Diagnose-Log
R101.1 INFO/funktional: Pentester sieht Content-Disposition:
attachment auch bei ?disposition=inline. Die Logik im Controller
ist korrekt und liefert beim Direkttest gegen echte PDFs
application/pdf, der Pfad lässt sich aber in Prod nicht
reproduzieren.

Refaktoriert:
- Magic-Byte-Check in detectSafeContentType() extrahiert
- File-Descriptor wird in finally garantiert geschlossen
- Short-Read-Fälle (bytesRead < n) explizit geguardet
- console.warn wenn inline angefragt aber Magic-Byte-Mismatch
  oder Read-Crash – damit der Fall in Prod-Logs sichtbar wird
  falls er wieder auftritt

Sicherheits-Verhalten unverändert: Mismatch → attachment
(Stored-XSS-Schutz aus R30.13).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 20:54:16 +02:00
duffyduck 386d206ff1 Vertragsdokumente-Modal: Vorschau-Link pro Dokument
Neben jeder Dokument-Zeile sitzt jetzt ein "Vorschau"-Link mit
ExternalLink-Icon, der die PDF in einem neuen Tab öffnet (via
viewUrl mit Token-Auth, inline-disposition). Klick darauf
schaltet bewusst NICHT die Checkbox um – die Auswahl bleibt,
nur das Dokument geht in einem zweiten Tab auf.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:56:16 +02:00
duffyduck 67d6fd4941 E-Mail-Liste: eigener Scrollbalken statt seitenweit wachsen
User-Bug: bei vielen E-Mails wuchs die Liste links unbegrenzt nach
unten, sodass die ganze Seite gescrollt werden musste.

- ContractEmailsSection: flex-Container von minHeight:400 auf
  feste 600px Höhe gestellt. Die linke Liste hatte schon
  overflow-y-auto – jetzt greift's auch.
- EmailClientTab: h-full auf calc(100vh - 240px) (mit
  minHeight:500) bounded. h-full hat im Tab-Container vorher
  nichts gebracht, weil der Parent selbst keine feste Höhe
  hatte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 07:51:16 +02:00
duffyduck 1680dcb0fe Pentest R97: Attachment-Validierung im Send-Handler
R97.1 LOW: malformed content (null, fehlend, true, "") landete
mit rohem Buffer.from()-Fehlertext in der Response; "" liess
sogar 0-Byte-Anhänge durch.
R97.2 INFO: keine App-Level-Caps für Größe/Anzahl – die im
Frontend dokumentierten 10/25 MB hingen am bodyParser.

Fix: validateAttachments() läuft VOR sendEmail() im Controller:
- max 25 Anhänge
- filename non-empty String, content non-empty Base64, optionaler
  contentType als String
- 10 MB pro Datei, 25 MB total (Größen-Schätzung über base64-Länge,
  kein Buffer.from während Validierung)

Harte 400 mit klarer Meldung. Sanity-Test 18/18 grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 00:45:59 +02:00
6 changed files with 237 additions and 59 deletions
@@ -392,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
return false;
}
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
const MAX_ATTACHMENT_COUNT = 25;
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
interface AttachmentValidationError {
status: 400;
error: string;
}
function validateAttachments(
attachments: unknown,
): { ok: true } | AttachmentValidationError {
if (attachments === undefined) return { ok: true };
if (!Array.isArray(attachments)) {
return { status: 400, error: 'attachments muss ein Array sein.' };
}
if (attachments.length > MAX_ATTACHMENT_COUNT) {
return {
status: 400,
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
};
}
let totalBytes = 0;
for (let i = 0; i < attachments.length; i++) {
const a = attachments[i];
const label = `Anhang ${i + 1}`;
if (!a || typeof a !== 'object') {
return { status: 400, error: `${label} hat das falsche Format.` };
}
const filename = (a as Record<string, unknown>).filename;
const content = (a as Record<string, unknown>).content;
const contentType = (a as Record<string, unknown>).contentType;
if (typeof filename !== 'string' || filename.trim() === '') {
return { status: 400, error: `${label} hat keinen Dateinamen.` };
}
if (typeof content !== 'string' || content.length === 0) {
return {
status: 400,
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
};
}
if (!BASE64_RE.test(content)) {
return {
status: 400,
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
};
}
if (contentType !== undefined && typeof contentType !== 'string') {
return {
status: 400,
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
};
}
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
const approxBytes = Math.ceil(content.length * 0.75);
if (approxBytes > MAX_PER_FILE_BYTES) {
return {
status: 400,
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
};
}
totalBytes += approxBytes;
if (totalBytes > MAX_TOTAL_BYTES) {
return {
status: 400,
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
};
}
}
return { ok: true };
}
// E-Mail senden
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -408,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
return;
}
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
// (Format, Größe, Anzahl) sonst leakte der rohe Node.js-Fehler
// in die Response und Limits waren nur Frontend-Doku.
const attachmentCheck = validateAttachments(attachments);
if (!('ok' in attachmentCheck)) {
res.status(attachmentCheck.status).json({
success: false,
error: attachmentCheck.error,
} as ApiResponse);
return;
}
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
// Stored-XSS gerendert.
//
// Default: Content-Disposition: attachment → Browser lädt nur runter.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
// Bei Mismatch fällt's auf attachment zurück Stored XSS bleibt
// weiterhin unmöglich.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
// attachment zurück Stored XSS bleibt weiterhin unmöglich.
//
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
const wantsInline = req.query.disposition === 'inline';
let useInline = false;
let inlineContentType: string | null = null;
if (wantsInline) {
try {
const fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
useInline = true;
inlineContentType = 'application/pdf';
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
useInline = true;
inlineContentType = 'image/png';
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
useInline = true;
inlineContentType = 'image/jpeg';
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
useInline = true;
inlineContentType = 'image/gif';
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
useInline = true;
inlineContentType = 'image/webp';
}
} catch { /* ignore fällt auf attachment zurück */ }
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
if (wantsInline && !safeContentType) {
console.warn(
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
);
}
res.setHeader('X-Content-Type-Options', 'nosniff');
if (useInline && inlineContentType) {
res.setHeader('Content-Type', inlineContentType);
if (safeContentType) {
res.setHeader('Content-Type', safeContentType);
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
} else {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
}
res.sendFile(absolute);
}
/**
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
* entspricht. Sonst `null` dann wird die Datei als attachment serviert.
*
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
*/
function detectSafeContentType(absolute: string): string | null {
let fd: number | null = null;
try {
fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
if (bytesRead >= 8
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) return 'image/png';
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
if (bytesRead >= 6
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
) return 'image/gif';
if (bytesRead >= 12
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
) return 'image/webp';
return null;
} catch (err) {
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
return null;
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
}
+37
View File
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔧 Pentest R101.1 Inline-Preview-Pfad refaktoriert + Diagnose-Log**
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
`fileDownload.controller` ist eigentlich korrekt sauberer Magic-
Byte-Check für PDF/PNG/JPEG/GIF/WebP und liefert beim Direkttest
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
aber nicht reproduzieren.
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
extrahiert, finally-Block schließt File-Descriptor garantiert,
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
R30.13).
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
der Fall im Prod-Log auf, falls er nochmal auftritt bisher
war's silent.
- [x] **🔒 Pentest R97 Attachment-Validierung im Send-Handler**
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
durchgehen.
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) die im
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
läuft **vor** dem `sendEmail`-Aufruf:
- `attachments` muss Array oder undefined sein
- max 25 Anhänge
- jeder: `filename` non-empty String, `content` non-empty Base64-
String (Regex), optional `contentType` String
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
kein Buffer.from-Aufruf während der Validierung)
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
grün inkl. aller R97.1-Pentest-Payloads.
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
"Datei anhängen":
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { FileText, Loader2 } from 'lucide-react';
import { FileText, Loader2, ExternalLink } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, EmailAttachment } from '../../services/api';
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
import { viewUrl } from '../../utils/fileUrl';
interface Props {
isOpen: boolean;
@@ -127,10 +128,11 @@ export default function AttachContractDocumentsModal({
</div>
<div className="space-y-1">
{docs.map((doc) => (
<label
<div
key={doc.id}
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer"
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
>
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
<input
type="checkbox"
checked={selected.has(doc.id)}
@@ -150,6 +152,18 @@ export default function AttachContractDocumentsModal({
)}
</div>
</label>
<a
href={viewUrl(doc.documentPath)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
title="Dokument in neuem Tab öffnen"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Vorschau</span>
</a>
</div>
))}
</div>
</div>
@@ -489,9 +489,13 @@ export default function ContractEmailsSection({
)}
</div>
) : (
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
{/* Email List */}
<div className="w-1/3 border-r border-gray-200 overflow-auto">
<div
className="flex -mx-6 -mb-6"
style={{ height: '600px' }}
>
{/* Email List scrollt intern, damit die Vertrags-Seite nicht
elendig lang wird. */}
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
{selectedFolder === 'TRASH' ? (
<TrashEmailList
emails={trashEmails}
@@ -295,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
};
return (
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
// Bounded auf Viewport-Höhe sonst ignoriert h-full ohnehin den
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
// ganze Seite scrollt statt nur die E-Mail-Liste.
<div
className="flex flex-col"
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
>
{/* Header */}
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
{/* Account Selector */}