| @@ -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 | | |||
| | 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" | |||
| 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`. | |||
| ### 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 | |||
| @@ -394,7 +394,9 @@ export default function AdminDashboard() { | |||
| const formData = new FormData(); | |||
| 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(); | |||
| 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> | |||
| <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"> | |||
| <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… | |||
| </label> | |||
| {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>} | |||
| @@ -10,6 +10,7 @@ const MIME: Record<string, string> = { | |||
| '.jpeg': 'image/jpeg', | |||
| '.gif': 'image/gif', | |||
| '.webp': 'image/webp', | |||
| '.svg': 'image/svg+xml', | |||
| '.mp4': 'video/mp4', | |||
| '.webm': 'video/webm', | |||
| '.mov': 'video/quicktime', | |||
| @@ -40,6 +41,12 @@ export async function GET(request: Request) { | |||
| const disposition = `inline; filename="${safeName.replace(/"/g, '')}"`; | |||
| 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) | |||
| const range = request.headers.get('range'); | |||
| if (range) { | |||
| @@ -84,6 +91,7 @@ export async function GET(request: Request) { | |||
| 'Content-Length': fileSize.toString(), | |||
| 'Accept-Ranges': 'bytes', | |||
| 'Cache-Control': 'public, max-age=86400', | |||
| ...extraHeaders, | |||
| }, | |||
| }); | |||
| } | |||
| @@ -4,6 +4,7 @@ import path from 'path'; | |||
| import { fromBuffer as fileTypeFromBuffer } from 'file-type'; | |||
| import { enqueueTranscode, needsTranscode, probeCodecs, isFfmpegAvailable } from '@/lib/transcode'; | |||
| import { UPLOAD_LIMITS } from '@/lib/config'; | |||
| import { sanitizeSvg } from '@/lib/svg-sanitize'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export const maxDuration = 300; | |||
| @@ -74,6 +75,45 @@ export async function POST(request: Request) { | |||
| } | |||
| 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]; | |||
| if (!family) { | |||
| 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 }; | |||
| } | |||