Compare commits
2 Commits
6a670df1c4
...
c93d4375ab
| Author | SHA1 | Date | |
|---|---|---|---|
| c93d4375ab | |||
| 0bd2f9be7e |
@@ -578,24 +578,44 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
}
|
||||
|
||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
||||
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
||||
const INLINE_SAFE_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
|
||||
'text/plain',
|
||||
]);
|
||||
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
|
||||
// SVG kann Skripte enthalten → niemals inline
|
||||
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
|
||||
// o.ä. sein. Für inline-Preview verlässt sich der Server nicht auf den
|
||||
// gemeldeten Type, sondern prüft die Magic-Bytes des Buffer-Inhalts.
|
||||
// Real-world-Problem (intern gemeldet 2026-05-30): manche Mail-Clients
|
||||
// setzen für PDF-Anhänge `application/octet-stream` → unser alter
|
||||
// Whitelist-Check fiel auf attachment zurück, der Browser öffnete
|
||||
// trotz target="_blank" keinen neuen Tab. Mit Magic-Byte-Detection
|
||||
// wird der echte Typ erkannt und inline-Preview klappt zuverlässig.
|
||||
const buf: Buffer = attachment.content;
|
||||
let detectedType: string | null = null;
|
||||
if (buf.length >= 5 && buf.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||||
detectedType = 'application/pdf';
|
||||
} else if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
detectedType = 'image/png';
|
||||
} else if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
detectedType = 'image/jpeg';
|
||||
} else if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
|
||||
detectedType = 'image/gif';
|
||||
} else if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
|
||||
detectedType = 'image/webp';
|
||||
} else if (buf.length >= 1 && (attachment.contentType || '').toLowerCase().startsWith('text/plain')) {
|
||||
// text/plain hat keine eindeutige Magic-Byte – akzeptieren wenn
|
||||
// der IMAP-Header das so meldet und Inhalt nur druckbare ASCII/UTF-8 ist.
|
||||
// Konservative Prüfung: keine HTML-Tag-Anfänge.
|
||||
const sample = buf.subarray(0, Math.min(buf.length, 256)).toString('utf8');
|
||||
if (!/<[a-z!\/?]/i.test(sample)) {
|
||||
detectedType = 'text/plain; charset=utf-8';
|
||||
}
|
||||
}
|
||||
const isSafeInline = detectedType !== null;
|
||||
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
||||
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
||||
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
|
||||
// Bei sicherem Inline-Typ: erkannten Type setzen (überschreibt
|
||||
// eventuell falsches application/octet-stream aus IMAP). Sonst
|
||||
// octet-stream erzwingen, damit der Browser nichts erraten kann.
|
||||
res.setHeader('Content-Type', isSafeInline ? detectedType! : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
||||
res.setHeader('Content-Length', attachment.size);
|
||||
res.send(attachment.content);
|
||||
|
||||
@@ -84,15 +84,51 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
||||
// durch und wurde mit Original-Extension auf Disk geschrieben.
|
||||
// Beim Download bestimmt res.sendFile() den Content-Type aus der
|
||||
// Extension – also `text/html` – und der Browser hätte das als
|
||||
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
|
||||
// nicht, wenn der Server selbst text/html liefert.
|
||||
// Stored-XSS gerendert.
|
||||
//
|
||||
// Fix: alle Files via Content-Disposition: attachment ausliefern.
|
||||
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
|
||||
// Für legitime PDF/Bild-Vorschau ist das vertretbar – Browser
|
||||
// öffnen den Download dann eben aus dem Datei-Manager.
|
||||
// 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.
|
||||
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 */ }
|
||||
}
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
if (useInline && inlineContentType) {
|
||||
res.setHeader('Content-Type', inlineContentType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.sendFile(absolute);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Select from '../ui/Select';
|
||||
import Badge from '../ui/Badge';
|
||||
import { invoiceApi } from '../../services/api';
|
||||
import type { Invoice, InvoiceType } from '../../types';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
import { fileUrl, viewUrl } from '../../utils/fileUrl';
|
||||
|
||||
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
||||
INTERIM: 'Zwischenrechnung',
|
||||
@@ -121,7 +121,7 @@ export default function InvoicesSection({
|
||||
{invoice.documentPath && (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={fileUrl(invoice.documentPath)}
|
||||
href={viewUrl(invoice.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||
|
||||
@@ -20,7 +20,7 @@ import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
import { fileUrl, viewUrl } from '../../utils/fileUrl';
|
||||
|
||||
const typeLabels: Record<ContractType, string> = {
|
||||
ELECTRICITY: 'Strom',
|
||||
@@ -2118,7 +2118,7 @@ export default function ContractDetail() {
|
||||
{c.cancellationLetterPath ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(c.cancellationLetterPath)}
|
||||
href={viewUrl(c.cancellationLetterPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2175,7 +2175,7 @@ export default function ContractDetail() {
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(c.cancellationConfirmationPath)}
|
||||
href={viewUrl(c.cancellationConfirmationPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2261,7 +2261,7 @@ export default function ContractDetail() {
|
||||
{c.cancellationLetterOptionsPath ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(c.cancellationLetterOptionsPath)}
|
||||
href={viewUrl(c.cancellationLetterOptionsPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -2318,7 +2318,7 @@ export default function ContractDetail() {
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(c.cancellationConfirmationOptionsPath)}
|
||||
href={viewUrl(c.cancellationConfirmationOptionsPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -3484,7 +3484,7 @@ function ContractDocumentsSection({
|
||||
{doc.documentType}
|
||||
</span>
|
||||
<a
|
||||
href={fileUrl(doc.documentPath)}
|
||||
href={viewUrl(doc.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
|
||||
@@ -21,7 +21,7 @@ import { formatDate } from '../../utils/dateFormat';
|
||||
import { getContractTypeInfo } from '../../utils/contractInfo';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
import { fileUrl, viewUrl } from '../../utils/fileUrl';
|
||||
|
||||
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
||||
const { id } = useParams();
|
||||
@@ -577,7 +577,7 @@ function BusinessDataCard({
|
||||
{customer.businessRegistrationPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(customer.businessRegistrationPath)}
|
||||
href={viewUrl(customer.businessRegistrationPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -628,7 +628,7 @@ function BusinessDataCard({
|
||||
{customer.commercialRegisterPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(customer.commercialRegisterPath)}
|
||||
href={viewUrl(customer.commercialRegisterPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -948,7 +948,7 @@ function BankCardsTab({
|
||||
{card.documentPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(card.documentPath)}
|
||||
href={viewUrl(card.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -1184,7 +1184,7 @@ function DocumentsTab({
|
||||
{doc.documentPath ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(doc.documentPath)}
|
||||
href={viewUrl(doc.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -4123,7 +4123,7 @@ function ConsentTab({
|
||||
{customer.privacyPolicyPath ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<a
|
||||
href={fileUrl(customer.privacyPolicyPath)}
|
||||
href={viewUrl(customer.privacyPolicyPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||
@@ -4441,7 +4441,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
{auth.documentPath ? (
|
||||
<>
|
||||
<a
|
||||
href={fileUrl(auth.documentPath)}
|
||||
href={viewUrl(auth.documentPath)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs flex items-center gap-1"
|
||||
|
||||
@@ -7,7 +7,7 @@ import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Select from '../../components/ui/Select';
|
||||
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
import { viewUrl } from '../../utils/fileUrl';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
@@ -364,7 +364,7 @@ export default function GDPRDashboard() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')}
|
||||
onClick={() => window.open(viewUrl(`/uploads/${request.proofDocument}`), '_blank')}
|
||||
title="Löschnachweis anzeigen"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-blue-500" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import Input from '../../components/ui/Input';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
|
||||
import { fileUrl } from '../../utils/fileUrl';
|
||||
import { viewUrl } from '../../utils/fileUrl';
|
||||
|
||||
export default function PdfTemplates() {
|
||||
const navigate = useNavigate();
|
||||
@@ -96,7 +96,7 @@ export default function PdfTemplates() {
|
||||
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
|
||||
<Play className="w-4 h-4 text-green-500" />
|
||||
</Button>
|
||||
<a href={fileUrl(t.templatePath)} target="_blank" rel="noopener noreferrer">
|
||||
<a href={viewUrl(t.templatePath)} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -15,11 +15,25 @@
|
||||
*/
|
||||
import { getAccessToken } from '../services/api';
|
||||
|
||||
export function fileUrl(path: string | null | undefined): string {
|
||||
/**
|
||||
* Kurzform für Inline-Vorschau: identisch zu `fileUrl(path, { inline: true })`.
|
||||
* Verwenden für „Anzeigen"-Links / target="_blank"-Vorschauen. Default-
|
||||
* `fileUrl(path)` bleibt für Downloads (Content-Disposition: attachment).
|
||||
*/
|
||||
export function viewUrl(path: string | null | undefined): string {
|
||||
return fileUrl(path, { inline: true });
|
||||
}
|
||||
|
||||
export function fileUrl(path: string | null | undefined, opts?: { inline?: boolean }): string {
|
||||
if (!path) return '';
|
||||
const token = getAccessToken();
|
||||
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
||||
// `?disposition=inline` schaltet die Anzeige im Browser-Tab ein,
|
||||
// der Backend-Controller bleibt aber nur dann inline, wenn die
|
||||
// Datei tatsächlich ein safe Type (PDF/PNG/JPEG/GIF/WebP) ist –
|
||||
// sonst fällt's auf attachment zurück. Default attachment.
|
||||
const dispParam = opts?.inline ? '&disposition=inline' : '';
|
||||
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}${dispParam}`;
|
||||
if (!token) return base;
|
||||
return `${base}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user