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>
This commit is contained in:
@@ -392,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
|||||||
return false;
|
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
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -408,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
|||||||
return;
|
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
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [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**
|
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
||||||
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
||||||
"Datei anhängen":
|
"Datei anhängen":
|
||||||
|
|||||||
Reference in New Issue
Block a user