| @@ -169,7 +169,9 @@ Gli upload passano per tre controlli in cascata. Se uno fallisce, **nessun file | |||||
| | Video | `mp4` `m4v` `webm` `mov` `ogv` `ogg` | 1 GB | | | Video | `mp4` `m4v` `webm` `mov` `ogv` `ogg` | 1 GB | | ||||
| | Documenti | `pdf` | 20 MB | | | Documenti | `pdf` | 20 MB | | ||||
| Tutto il resto (es. `svg`, `heic`, `bmp`, `avi`, `exe`) viene rifiutato. **SVG è escluso di proposito** (può contenere `<script>`). | |||||
| Tutto il resto (es. `heic`, `bmp`, `avi`, `exe`) viene rifiutato. | |||||
| > **Eccezione SVG**: l'estensione `.svg` è ammessa **solo** per l'upload del **logo del portale** (Settings → Logo Image). Tutti gli altri upload (hero, cover card, gallery, ecc.) rifiutano l'SVG. Vedi [SVG: regole speciali per il logo](#svg-regole-speciali-per-il-logo) sotto. | |||||
| ### 2. Controllo "magic-bytes" | ### 2. Controllo "magic-bytes" | ||||
| Il contenuto reale del file viene confrontato con l'estensione dichiarata. Esempi rifiutati: | Il contenuto reale del file viene confrontato con l'estensione dichiarata. Esempi rifiutati: | ||||
| @@ -188,6 +190,25 @@ Solo i video possono essere ricodificati. Alla ricezione il server sonda i codec | |||||
| La transcodifica richiede `ffmpeg`/`ffprobe` sul server — vedi [Prerequisiti](#prerequisiti-di-sistema). Se mancano, gli upload di video che richiedono ricodifica rispondono `503`. | La transcodifica richiede `ffmpeg`/`ffprobe` sul server — vedi [Prerequisiti](#prerequisiti-di-sistema). Se mancano, gli upload di video che richiedono ricodifica rispondono `503`. | ||||
| ### SVG: regole speciali per il logo | |||||
| L'SVG è un formato vettoriale comodo per i loghi ma può contenere `<script>` JavaScript, event handler (`onclick`, `onload`, …) e riferimenti esterni che si attivano quando il browser renderizza l'immagine — è un classico vettore XSS. Per questo motivo: | |||||
| - L'SVG **non è ammesso** negli upload generici di card, gallery o hero. | |||||
| - È ammesso **solo per il logo** del portale (Settings → Logo Image), perché serve un caso d'uso ricorrente (loghi forniti come vettoriale) e l'admin è considerato fidato. | |||||
| Quando viene caricato un SVG via `POST /api/upload?context=logo` il server applica: | |||||
| | Difesa | Cosa fa | | |||||
| |---|---| | |||||
| | **Whitelist contestuale** | l'estensione `.svg` è ammessa solo se la query string contiene `context=logo`. Senza, viene rifiutata come tutte le altre estensioni fuori dalla whitelist generale. | | |||||
| | **Validazione struttura** | il contenuto deve iniziare con `<svg…>` (eventualmente preceduto da XML declaration e commenti). File che non sembrano SVG vengono rifiutati. | | |||||
| | **Sanitizzazione** ([lib/svg-sanitize.ts](lib/svg-sanitize.ts)) | rimozione di tag pericolosi (`script`, `foreignObject`, `iframe`, `embed`, `object`, `style`, `link`, `meta`, `set`, `animate*`), di tutti gli event handler `on*=…`, di `href`/`xlink:href` con schemi non sicuri, e delle stringhe `javascript:` / `data:text/html` inline. Il risultato sanificato è quello che viene salvato a disco. | | |||||
| | **CSP defensivo** sul `GET /api/files` | quando il file servito è SVG, la risposta include `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:` — anche se un payload sfuggisse al sanitizer, lo script non potrebbe eseguire né caricare risorse esterne. | | |||||
| | **Limite dimensione** | si applica il limite della famiglia immagini (25 MB), ma in pratica un logo SVG è qualche KB. | | |||||
| Il sanitizer è **regex-based** (deliberatamente leggero, niente nuove dipendenze). È adeguato per il modello di minaccia "admin trusted carica il proprio logo" ma non è bulletproof contro payload SVG sofisticatissimi. Per scenari più esposti (upload pubblico), il sostituto consigliato è DOMPurify + jsdom, che sostituisce solo l'implementazione del sanitizer senza toccare il resto della pipeline. | |||||
| --- | --- | ||||
| ## Limiti di testo | ## Limiti di testo | ||||
| @@ -394,7 +394,9 @@ export default function AdminDashboard() { | |||||
| const formData = new FormData(); | const formData = new FormData(); | ||||
| formData.append('file', e.target.files[0]); | formData.append('file', e.target.files[0]); | ||||
| const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData }); | |||||
| // Il logo è l'unico upload che ammette SVG (sanitizzato lato server). | |||||
| const endpoint = field === 'logoUrl' ? '/api/upload?context=logo' : '/api/upload'; | |||||
| const res = await fetch(withBasePath(endpoint), { method: 'POST', body: formData }); | |||||
| const data = await res.json(); | const data = await res.json(); | ||||
| if (data.url) { | if (data.url) { | ||||
| @@ -907,7 +909,7 @@ export default function AdminDashboard() { | |||||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label> | <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label> | ||||
| <div className="flex items-center gap-3 flex-wrap"> | <div className="flex items-center gap-3 flex-wrap"> | ||||
| <label className="cursor-pointer bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold text-sm px-4 py-2 rounded-full transition-colors"> | <label className="cursor-pointer bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold text-sm px-4 py-2 rounded-full transition-colors"> | ||||
| <input type="file" accept="image/*" onChange={e => handleUpload(e, 'logoUrl', true)} hidden /> | |||||
| <input type="file" accept="image/*,.svg,image/svg+xml" onChange={e => handleUpload(e, 'logoUrl', true)} hidden /> | |||||
| Choose image… | Choose image… | ||||
| </label> | </label> | ||||
| {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>} | {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>} | ||||
| @@ -10,6 +10,7 @@ const MIME: Record<string, string> = { | |||||
| '.jpeg': 'image/jpeg', | '.jpeg': 'image/jpeg', | ||||
| '.gif': 'image/gif', | '.gif': 'image/gif', | ||||
| '.webp': 'image/webp', | '.webp': 'image/webp', | ||||
| '.svg': 'image/svg+xml', | |||||
| '.mp4': 'video/mp4', | '.mp4': 'video/mp4', | ||||
| '.webm': 'video/webm', | '.webm': 'video/webm', | ||||
| '.mov': 'video/quicktime', | '.mov': 'video/quicktime', | ||||
| @@ -40,6 +41,12 @@ export async function GET(request: Request) { | |||||
| const disposition = `inline; filename="${safeName.replace(/"/g, '')}"`; | const disposition = `inline; filename="${safeName.replace(/"/g, '')}"`; | ||||
| const fileSize = stat.size; | const fileSize = stat.size; | ||||
| // Defense-in-depth per SVG: blocca qualunque script eventualmente sfuggito al | |||||
| // sanitizer al momento dell'upload, e impedisce caricamenti di risorse esterne. | |||||
| const extraHeaders: Record<string, string> = ext === '.svg' | |||||
| ? { 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:" } | |||||
| : {}; | |||||
| // Handle Range requests (essential for video seeking) | // Handle Range requests (essential for video seeking) | ||||
| const range = request.headers.get('range'); | const range = request.headers.get('range'); | ||||
| if (range) { | if (range) { | ||||
| @@ -84,6 +91,7 @@ export async function GET(request: Request) { | |||||
| 'Content-Length': fileSize.toString(), | 'Content-Length': fileSize.toString(), | ||||
| 'Accept-Ranges': 'bytes', | 'Accept-Ranges': 'bytes', | ||||
| 'Cache-Control': 'public, max-age=86400', | 'Cache-Control': 'public, max-age=86400', | ||||
| ...extraHeaders, | |||||
| }, | }, | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -4,6 +4,7 @@ import path from 'path'; | |||||
| import { fromBuffer as fileTypeFromBuffer } from 'file-type'; | import { fromBuffer as fileTypeFromBuffer } from 'file-type'; | ||||
| import { enqueueTranscode, needsTranscode, probeCodecs, isFfmpegAvailable } from '@/lib/transcode'; | import { enqueueTranscode, needsTranscode, probeCodecs, isFfmpegAvailable } from '@/lib/transcode'; | ||||
| import { UPLOAD_LIMITS } from '@/lib/config'; | import { UPLOAD_LIMITS } from '@/lib/config'; | ||||
| import { sanitizeSvg } from '@/lib/svg-sanitize'; | |||||
| export const dynamic = 'force-dynamic'; | export const dynamic = 'force-dynamic'; | ||||
| export const maxDuration = 300; | export const maxDuration = 300; | ||||
| @@ -74,6 +75,45 @@ export async function POST(request: Request) { | |||||
| } | } | ||||
| const ext = extractExt(file.name); | const ext = extractExt(file.name); | ||||
| // SVG: ammesso SOLO per upload del logo (`?context=logo`). | |||||
| // file-type non rileva l'SVG affidabilmente perché è XML/testo, quindi qui | |||||
| // saltiamo il magic-bytes check e affidiamo la verifica al sanitizer | |||||
| // (richiede che il file inizi con `<svg>` o `<?xml ... <svg>`). | |||||
| const { searchParams } = new URL(request.url); | |||||
| const isLogoContext = searchParams.get('context') === 'logo'; | |||||
| if (ext === 'svg') { | |||||
| if (!isLogoContext) { | |||||
| return NextResponse.json( | |||||
| { error: 'SVG accettato solo per upload del logo.' }, | |||||
| { status: 400 } | |||||
| ); | |||||
| } | |||||
| if (file.size > MAX_BYTES.image) { | |||||
| const mb = (MAX_BYTES.image / (1024 * 1024)).toFixed(0); | |||||
| return NextResponse.json( | |||||
| { error: `File troppo grande (max ${mb} MB per image).` }, | |||||
| { status: 413 } | |||||
| ); | |||||
| } | |||||
| const text = Buffer.from(await file.arrayBuffer()).toString('utf-8'); | |||||
| const result = sanitizeSvg(text); | |||||
| if (!result.ok) { | |||||
| return NextResponse.json({ error: result.error }, { status: 400 }); | |||||
| } | |||||
| const storedName = normalizeFilename(file.name); | |||||
| await mkdir(TMP_DIR, { recursive: true }); | |||||
| tmpPath = path.join(TMP_DIR, storedName); | |||||
| await writeFile(tmpPath, Buffer.from(result.sanitized, 'utf-8')); | |||||
| const finalPath = path.join(UPLOAD_DIR, storedName); | |||||
| await rename(tmpPath, finalPath); | |||||
| tmpPath = null; | |||||
| return NextResponse.json( | |||||
| { url: `/api/files?name=${encodeURIComponent(storedName)}` }, | |||||
| { status: 201 } | |||||
| ); | |||||
| } | |||||
| const family = EXT_FAMILY[ext]; | const family = EXT_FAMILY[ext]; | ||||
| if (!family) { | if (!family) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| @@ -0,0 +1,72 @@ | |||||
| // Sanitizer SVG focalizzato sul caso "logo del portale caricato dall'admin". | |||||
| // Approccio: rimozione regex-based dei costrutti pericolosi (deny-list aggressiva + | |||||
| // validazione che il contenuto sia davvero SVG). Non è una difesa esaustiva da | |||||
| // payload SVG sofisticati: per quello servirebbe DOMPurify+jsdom o xmldom. | |||||
| // Per il nostro modello di minaccia (admin trusted carica il proprio logo) basta. | |||||
| // Defense-in-depth: il file servito da /api/files include CSP `script-src 'none'` | |||||
| // quando il MIME è image/svg+xml. | |||||
| // Tag che vanno completamente rimossi (anche il contenuto): | |||||
| // - script/foreignObject/iframe/embed/object: codice eseguibile | |||||
| // - style/link/meta: possono caricare risorse esterne o iniettare CSS dannoso | |||||
| // - set/animate*: animation handlers possono triggerare eventi | |||||
| const FORBIDDEN_TAGS = [ | |||||
| 'script', | |||||
| 'foreignObject', | |||||
| 'iframe', | |||||
| 'embed', | |||||
| 'object', | |||||
| 'link', | |||||
| 'meta', | |||||
| 'style', | |||||
| 'set', | |||||
| 'animate', | |||||
| 'animateMotion', | |||||
| 'animateTransform', | |||||
| ] as const; | |||||
| const EVENT_ATTR_RE = /\s+on\w+\s*=\s*("[^"]*"|'[^']*'|\S+)/gi; | |||||
| // href/xlink:href con schema non consentito. | |||||
| // Sono ammessi: #fragment, http(s)://, mailto:, tel:. Tutto il resto via. | |||||
| const UNSAFE_HREF_RE = /\s+(xlink:)?href\s*=\s*("|')\s*(?!#|https?:|mailto:|tel:|\2)[^"']*\2/gi; | |||||
| // Rimuove strighe `javascript:` o `data:text/html` anche dentro attributi che non | |||||
| // passano dal pattern href (es. attributeName, values, ecc.) — best effort. | |||||
| const SCHEME_INLINE_RE = /(javascript:|data:text\/html)/gi; | |||||
| export type SvgSanitizeResult = | |||||
| | { ok: true; sanitized: string } | |||||
| | { ok: false; error: string }; | |||||
| export function sanitizeSvg(input: string): SvgSanitizeResult { | |||||
| if (typeof input !== 'string' || input.length === 0) { | |||||
| return { ok: false, error: 'Empty SVG content.' }; | |||||
| } | |||||
| // Deve essere un documento SVG: opzionalmente un XML declaration, opzionalmente | |||||
| // commenti, e poi un tag <svg>. | |||||
| if (!/^\s*(<\?xml[^?]*\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i.test(input)) { | |||||
| return { ok: false, error: 'Not a valid SVG document.' }; | |||||
| } | |||||
| let out = input; | |||||
| // 1) Rimuove i tag vietati (sia open+close che self-closing) | |||||
| for (const tag of FORBIDDEN_TAGS) { | |||||
| // <tag ...>...</tag> | |||||
| out = out.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?</${tag}>`, 'gi'), ''); | |||||
| // <tag ... /> oppure <tag ...> | |||||
| out = out.replace(new RegExp(`<${tag}\\b[^>]*/?>`, 'gi'), ''); | |||||
| } | |||||
| // 2) Rimuove event handler (onload, onclick, onerror, ecc.) | |||||
| out = out.replace(EVENT_ATTR_RE, ''); | |||||
| // 3) Strippa href/xlink:href con schema non ammesso | |||||
| out = out.replace(UNSAFE_HREF_RE, ''); | |||||
| // 4) Rimozione difensiva di `javascript:` / `data:text/html` ovunque appaiano | |||||
| out = out.replace(SCHEME_INLINE_RE, ''); | |||||
| return { ok: true, sanitized: out }; | |||||
| } | |||||