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:
parent
26579a9407
commit
65d367cb5f
8 changed files with 1505 additions and 110 deletions
1018
SECURITY_AUDIT_REPORT_NOVEMBER_2025.md
Normal file
1018
SECURITY_AUDIT_REPORT_NOVEMBER_2025.md
Normal file
File diff suppressed because it is too large
Load diff
242
app/api/cron/send-csp-report/route.ts
Normal file
242
app/api/cron/send-csp-report/route.ts
Normal 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;
|
||||||
|
}
|
||||||
78
app/api/csp-report/route.ts
Normal file
78
app/api/csp-report/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
75
supabase/migrations/20251114_create_csp_reports.sql
Normal file
75
supabase/migrations/20251114_create_csp_reports.sql
Normal 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';
|
||||||
|
|
@ -7,6 +7,12 @@
|
||||||
"maxDuration": 30
|
"maxDuration": 30
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"crons": [
|
||||||
|
{
|
||||||
|
"path": "/api/cron/send-csp-report",
|
||||||
|
"schedule": "0 9 * * *"
|
||||||
|
}
|
||||||
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"source": "/(.*)",
|
"source": "/(.*)",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue