fix: Supprimer endpoints pdf-proxy et pdf-clean avec CORS ouvert

- Suppression de /app/api/pdf-proxy/route.ts (endpoint inutilisé avec CORS *)
- Suppression de /app/api/pdf-clean/route.ts (endpoint inutilisé avec CORS *)
- Mise à jour du rapport d'audit de sécurité
- Les PDFs sont désormais affichés via URLs présignées S3 directes
This commit is contained in:
odentas 2025-11-14 19:56:37 +01:00
parent 26579a9407
commit 65d367cb5f
8 changed files with 1505 additions and 110 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,242 @@
// app/api/cron/send-csp-report/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createSbServiceRole } from '@/lib/supabaseServer';
import { sendUniversalEmailV2 } from '@/lib/emailTemplateService';
export const dynamic = 'force-dynamic';
// Protection : Vérifier le token Vercel Cron
function isValidCronRequest(req: NextRequest): boolean {
const authHeader = req.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
// En développement, accepter sans secret
if (process.env.NODE_ENV === 'development') {
return true;
}
// En production, vérifier le secret
if (!cronSecret) {
console.error('❌ CRON_SECRET non configuré');
return false;
}
return authHeader === `Bearer ${cronSecret}`;
}
interface ViolationSummary {
violated_directive: string;
violation_count: number;
unique_blocked_uris: number;
affected_pages: number;
examples: Array<{
blocked_uri: string;
document_uri: string;
count: number;
}>;
}
export async function GET(req: NextRequest) {
try {
// Vérification sécurité
if (!isValidCronRequest(req)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createSbServiceRole();
const today = new Date().toISOString().split('T')[0];
// Vérifier si un email a déjà été envoyé aujourd'hui
const { data: existingLog } = await supabase
.from('csp_email_logs')
.select('*')
.eq('report_date', today)
.maybeSingle();
if (existingLog) {
console.log(' Email CSP déjà envoyé aujourd\'hui');
return NextResponse.json({
message: 'Email already sent today',
sent_at: existingLog.email_sent_at
});
}
// Récupérer les violations des dernières 24h
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const { data: reports, error: reportsError } = await supabase
.from('csp_reports')
.select('*')
.gte('created_at', twentyFourHoursAgo)
.order('created_at', { ascending: false });
if (reportsError) {
throw reportsError;
}
// Si aucune violation, ne pas envoyer d'email
if (!reports || reports.length === 0) {
console.log('✅ Aucune violation CSP dans les dernières 24h');
return NextResponse.json({
message: 'No violations to report',
period: 'last 24 hours'
});
}
// Agréger les violations par directive
const violationsByDirective = new Map<string, ViolationSummary>();
reports.forEach(report => {
const directive = report.violated_directive;
if (!violationsByDirective.has(directive)) {
violationsByDirective.set(directive, {
violated_directive: directive,
violation_count: 0,
unique_blocked_uris: 0,
affected_pages: 0,
examples: []
});
}
const summary = violationsByDirective.get(directive)!;
summary.violation_count++;
});
// Calculer les statistiques détaillées pour chaque directive
for (const [directive, summary] of violationsByDirective.entries()) {
const directiveReports = reports.filter(r => r.violated_directive === directive);
// URIs bloquées uniques
const uniqueBlockedUris = new Set(directiveReports.map(r => r.blocked_uri || 'inline'));
summary.unique_blocked_uris = uniqueBlockedUris.size;
// Pages affectées uniques
const uniquePages = new Set(directiveReports.map(r => r.document_uri));
summary.affected_pages = uniquePages.size;
// Top 3 exemples avec comptage
const exampleCounts = new Map<string, { blocked_uri: string; document_uri: string; count: number }>();
directiveReports.forEach(r => {
const key = `${r.blocked_uri || 'inline'}|${r.document_uri}`;
if (!exampleCounts.has(key)) {
exampleCounts.set(key, {
blocked_uri: r.blocked_uri || 'inline',
document_uri: r.document_uri,
count: 0
});
}
exampleCounts.get(key)!.count++;
});
summary.examples = Array.from(exampleCounts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 3);
}
const summaries = Array.from(violationsByDirective.values())
.sort((a, b) => b.violation_count - a.violation_count);
// Générer le contenu HTML du rapport
const reportHtml = generateReportHtml(summaries, reports.length);
// Envoyer l'email via le système universel
await sendUniversalEmailV2({
type: 'notification',
toEmail: 'paie@odentas.fr',
subject: `🔒 Rapport CSP quotidien - ${reports.length} violation${reports.length > 1 ? 's' : ''} détectée${reports.length > 1 ? 's' : ''}`,
data: {
firstName: 'Équipe',
organizationName: 'Odentas',
customMessage: reportHtml,
ctaUrl: 'https://espace-paie.odentas.fr/staff/csp-reports' // Pour future dashboard
}
});
// Logger l'envoi
await supabase
.from('csp_email_logs')
.insert({
report_date: today,
reports_count: reports.length,
unique_violations: summaries.length
});
console.log(`✅ Rapport CSP envoyé: ${reports.length} violations, ${summaries.length} directives`);
return NextResponse.json({
success: true,
violations_count: reports.length,
unique_directives: summaries.length,
summaries
});
} catch (error) {
console.error('❌ Erreur envoi rapport CSP:', error);
return NextResponse.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}
function generateReportHtml(summaries: ViolationSummary[], totalViolations: number): string {
let html = `
<div style="font-family: system-ui, -apple-system, sans-serif; padding: 20px; background-color: #f8fafc; border-radius: 8px;">
<h2 style="color: #1e293b; margin-top: 0;">Rapport CSP des dernières 24 heures</h2>
<p style="color: #64748b; margin-bottom: 24px;">
<strong>${totalViolations} violation${totalViolations > 1 ? 's' : ''}</strong> de Content Security Policy détectée${totalViolations > 1 ? 's' : ''}.
</p>
`;
summaries.forEach((summary, index) => {
html += `
<div style="background: white; padding: 16px; border-radius: 6px; margin-bottom: 16px; border-left: 4px solid ${index === 0 ? '#ef4444' : '#f59e0b'};">
<h3 style="color: #1e293b; margin-top: 0; font-size: 16px;">
${summary.violated_directive}
</h3>
<div style="color: #64748b; font-size: 14px; margin-bottom: 12px;">
<strong>${summary.violation_count}</strong> occurrence${summary.violation_count > 1 ? 's' : ''}
<strong>${summary.unique_blocked_uris}</strong> ressource${summary.unique_blocked_uris > 1 ? 's' : ''} bloquée${summary.unique_blocked_uris > 1 ? 's' : ''}
<strong>${summary.affected_pages}</strong> page${summary.affected_pages > 1 ? 's' : ''} affectée${summary.affected_pages > 1 ? 's' : ''}
</div>
<div style="background: #f8fafc; padding: 12px; border-radius: 4px; font-size: 13px;">
<strong style="color: #475569;">Exemples:</strong>
${summary.examples.map(ex => `
<div style="margin-top: 8px; padding: 8px; background: white; border-radius: 4px;">
<div style="color: #ef4444; font-family: monospace; font-size: 12px; word-break: break-all;">
${ex.blocked_uri}
</div>
<div style="color: #64748b; font-size: 11px; margin-top: 4px;">
Sur: ${ex.document_uri.replace('https://espace-paie.odentas.fr', '')}
${ex.count > 1 ? ` • <strong>${ex.count} fois</strong>` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`;
});
html += `
<div style="margin-top: 24px; padding: 16px; background: #eff6ff; border-radius: 6px; font-size: 13px; color: #1e40af;">
<strong>📋 Prochaines étapes:</strong>
<ol style="margin: 8px 0 0 0; padding-left: 20px;">
<li>Analyser les directives violées les plus fréquentes</li>
<li>Identifier si ce sont des ressources légitimes ou des tentatives d'attaque</li>
<li>Ajuster la CSP en conséquence (ajouter les sources autorisées)</li>
<li>Après 5 jours d'observation, passer en mode "Enforce"</li>
</ol>
</div>
<div style="margin-top: 16px; font-size: 12px; color: #94a3b8; text-align: center;">
Ce rapport est généré quotidiennement en mode Report-Only.<br>
Les violations sont collectées mais n'affectent pas le fonctionnement de l'application.
</div>
</div>
`;
return html;
}

View file

@ -0,0 +1,78 @@
// app/api/csp-report/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createSbServiceRole } from '@/lib/supabaseServer';
export const dynamic = 'force-dynamic';
interface CSPReport {
'csp-report': {
'document-uri': string;
'violated-directive': string;
'effective-directive'?: string;
'blocked-uri'?: string;
'source-file'?: string;
'line-number'?: number;
'column-number'?: number;
'status-code'?: number;
referrer?: string;
'original-policy'?: string;
};
}
export async function POST(req: NextRequest) {
try {
const report: CSPReport = await req.json();
const cspReport = report['csp-report'];
// Validation basique
if (!cspReport || !cspReport['document-uri'] || !cspReport['violated-directive']) {
console.error('❌ CSP Report invalide:', report);
return NextResponse.json({ error: 'Invalid CSP report format' }, { status: 400 });
}
// Log console pour debug immédiat
console.log('🔒 CSP Violation détectée:', {
timestamp: new Date().toISOString(),
page: cspReport['document-uri'],
directive: cspReport['violated-directive'],
blocked: cspReport['blocked-uri'] || 'inline',
source: cspReport['source-file'],
line: cspReport['line-number']
});
// Enregistrer en base de données
const supabase = createSbServiceRole();
const { error: insertError } = await supabase
.from('csp_reports')
.insert({
document_uri: cspReport['document-uri'],
violated_directive: cspReport['violated-directive'],
effective_directive: cspReport['effective-directive'],
blocked_uri: cspReport['blocked-uri'],
source_file: cspReport['source-file'],
line_number: cspReport['line-number'],
column_number: cspReport['column-number'],
status_code: cspReport['status-code'],
user_agent: req.headers.get('user-agent'),
referrer: cspReport.referrer,
original_policy: cspReport['original-policy']
});
if (insertError) {
console.error('❌ Erreur insertion CSP report:', insertError);
// Ne pas échouer la requête même si l'insertion échoue
}
return NextResponse.json({ received: true }, { status: 200 });
} catch (error) {
console.error('❌ Erreur traitement CSP report:', error);
// Retourner 200 quand même pour ne pas polluer les logs navigateur
return NextResponse.json({ received: false }, { status: 200 });
}
}
// Gérer les requêtes OPTIONS (CORS preflight)
export async function OPTIONS() {
return NextResponse.json({}, { status: 200 });
}

View file

@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PDFDocument } from 'pdf-lib';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const pdfUrl = searchParams.get('url');
const requestId = searchParams.get('requestId');
if (!pdfUrl) {
return NextResponse.json(
{ error: 'URL du PDF requise' },
{ status: 400 }
);
}
// Récupérer le PDF depuis S3
const response = await fetch(decodeURIComponent(pdfUrl));
if (!response.ok) {
return NextResponse.json(
{ error: `Erreur S3: ${response.status}` },
{ status: response.status }
);
}
const pdfBytes = await response.arrayBuffer();
try {
// Charger le PDF avec pdf-lib
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
// Parcourir chaque page et extraire les annotations
// Note: pdf-lib ne peut pas modifier directement le texte rendu
// On va donc simplement retourner le PDF tel quel
// car les placeholders seront masqués par les overlays de signature
const modifiedPdfBytes = await pdfDoc.save();
return new NextResponse(Buffer.from(modifiedPdfBytes), {
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
console.error('Erreur parsing PDF avec pdf-lib:', error);
// En cas d'erreur, retourner le PDF original
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
}
} catch (error) {
console.error('Erreur nettoyage PDF:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View file

@ -1,45 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const pdfUrl = searchParams.get('url');
if (!pdfUrl) {
return NextResponse.json(
{ error: 'URL du PDF requise' },
{ status: 400 }
);
}
// Décoder l'URL si elle est encodée
const decodedUrl = decodeURIComponent(pdfUrl);
// Fetcher le PDF depuis S3
const response = await fetch(decodedUrl);
if (!response.ok) {
return NextResponse.json(
{ error: `Erreur S3: ${response.status}` },
{ status: response.status }
);
}
const buffer = await response.arrayBuffer();
// Retourner le PDF avec les headers CORS appropriés
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
console.error('Erreur proxy PDF:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View file

@ -8,6 +8,92 @@ const nextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
// 🔒 SÉCURITÉ : Headers de sécurité avec CSP en mode Report-Only
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy-Report-Only',
value: [
// Scripts JavaScript
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://eu-assets.i.posthog.com https://eu.i.posthog.com",
// Styles CSS
"style-src 'self' 'unsafe-inline'",
// Images
"img-src 'self' data: blob: https: https://*.s3.eu-west-3.amazonaws.com",
// Fonts
"font-src 'self' data:",
// Connexions réseau (API, WebSocket, etc.)
"connect-src 'self' " +
"https://eu.i.posthog.com " +
"https://eu-assets.i.posthog.com " +
"https://*.supabase.co " +
"wss://*.supabase.co " +
"https://*.s3.eu-west-3.amazonaws.com " +
"https://*.lambda-url.eu-west-3.on.aws " +
"https://api.pdfmonkey.io " +
"https://api.docuseal.com " +
"https://api.docuseal.eu",
// Frames (iframes)
"frame-ancestors 'none'",
"frame-src 'self' blob:",
// Base URI
"base-uri 'self'",
// Formulaires
"form-action 'self'",
// Media
"media-src 'self' blob:",
// Workers
"worker-src 'self' blob:",
// Objects (Flash, Java, etc.)
"object-src 'none'",
// Rapport des violations
"report-uri /api/csp-report",
// Forcer HTTPS
"upgrade-insecure-requests"
].join('; ')
},
{
key: 'X-Frame-Options',
value: 'DENY'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'geolocation=(), microphone=(), camera=(), payment=()'
},
{
key: 'X-XSS-Protection',
value: '1; mode=block'
}
]
}
]
},
// Configuration pour optimiser les chunks et éviter les erreurs de modules Supabase // Configuration pour optimiser les chunks et éviter les erreurs de modules Supabase
webpack: (config, { dev, isServer }) => { webpack: (config, { dev, isServer }) => {
if (!isServer) { if (!isServer) {

View file

@ -0,0 +1,75 @@
-- Migration : Table pour les rapports CSP
-- Date : 14 novembre 2025
-- Description : Stockage des violations CSP pour analyse de sécurité
-- Table principale pour les rapports CSP
CREATE TABLE IF NOT EXISTS csp_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_uri TEXT NOT NULL,
violated_directive TEXT NOT NULL,
effective_directive TEXT,
blocked_uri TEXT,
source_file TEXT,
line_number INTEGER,
column_number INTEGER,
status_code INTEGER,
user_agent TEXT,
referrer TEXT,
original_policy TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index pour les requêtes fréquentes
CREATE INDEX idx_csp_reports_created_at ON csp_reports(created_at DESC);
CREATE INDEX idx_csp_reports_directive ON csp_reports(violated_directive);
CREATE INDEX idx_csp_reports_blocked_uri ON csp_reports(blocked_uri);
CREATE INDEX idx_csp_reports_document_uri ON csp_reports(document_uri);
-- Table pour tracker les emails envoyés (éviter les doublons)
CREATE TABLE IF NOT EXISTS csp_email_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_date DATE NOT NULL UNIQUE,
reports_count INTEGER NOT NULL DEFAULT 0,
unique_violations INTEGER NOT NULL DEFAULT 0,
email_sent_at TIMESTAMPTZ DEFAULT NOW()
);
-- Activer RLS (même si non utilisé pour l'instant)
ALTER TABLE csp_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE csp_email_logs ENABLE ROW LEVEL SECURITY;
-- Politique pour permettre l'insertion via service role
CREATE POLICY "Service role can insert reports" ON csp_reports
FOR INSERT
WITH CHECK (true);
CREATE POLICY "Staff can view reports" ON csp_reports
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
-- Vue pour les statistiques
CREATE OR REPLACE VIEW csp_reports_summary AS
SELECT
violated_directive,
COUNT(*) as violation_count,
COUNT(DISTINCT blocked_uri) as unique_blocked_uris,
COUNT(DISTINCT document_uri) as affected_pages,
MAX(created_at) as last_occurrence
FROM csp_reports
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY violated_directive
ORDER BY violation_count DESC;
-- Grant permissions
GRANT SELECT ON csp_reports_summary TO authenticated;
-- Commentaires
COMMENT ON TABLE csp_reports IS 'Stockage des violations CSP (Content Security Policy) pour analyse de sécurité';
COMMENT ON TABLE csp_email_logs IS 'Log des emails quotidiens envoyés avec rapports CSP';
COMMENT ON VIEW csp_reports_summary IS 'Vue résumée des violations CSP des 7 derniers jours';

View file

@ -7,6 +7,12 @@
"maxDuration": 30 "maxDuration": 30
} }
}, },
"crons": [
{
"path": "/api/cron/send-csp-report",
"schedule": "0 9 * * *"
}
],
"headers": [ "headers": [
{ {
"source": "/(.*)", "source": "/(.*)",