Selaa lähdekoodia

Validazione upload dell svg

main
Lorenzo Pollutri 2 viikkoa sitten
vanhempi
commit
0b3648c3e1
5 muutettua tiedostoa jossa 146 lisäystä ja 3 poistoa
  1. +22
    -1
      README.md
  2. +4
    -2
      app/admin/page.tsx
  3. +8
    -0
      app/api/files/route.ts
  4. +40
    -0
      app/api/upload/route.ts
  5. +72
    -0
      lib/svg-sanitize.ts

+ 22
- 1
README.md Näytä tiedosto

@@ -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


+ 4
- 2
app/admin/page.tsx Näytä tiedosto

@@ -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>}


+ 8
- 0
app/api/files/route.ts Näytä tiedosto

@@ -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,
},
});
}

+ 40
- 0
app/api/upload/route.ts Näytä tiedosto

@@ -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(


+ 72
- 0
lib/svg-sanitize.ts Näytä tiedosto

@@ -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 };
}

Ladataan…
Peruuta
Tallenna