La e() che mancava
Lo stesso campo note, sfuggito all’escaping in un solo transformer: la fix del 2021 per CVE-2021-4018 non fu mai portata sui componenti, riaprendo uno stored XSS in Snipe-IT ≤ 8.4.0.
Il 5 maggio 2026 è stata pubblicata — come GitHub Security Advisory GHSA-r42m-953q-6vjx — una seconda vulnerabilità che ho riportato in Snipe-IT (Grokability, Laravel). Nelle versioni ≤ 8.4.0 un utente con il permesso components.checkout poteva iniettare HTML/JavaScript nel campo note di un checkout di componente. Il payload veniva memorizzato grezzo e restituito senza escaping, per poi eseguirsi nel browser di chiunque aprisse la pagina del componente. Il fix è nella versione 8.4.1.
Non è un bug nuovo, ma una regressione: lo stesso identico pattern — il campo note di un checkout non escapato in un transformer — era già stato corretto nel 2021 (CVE-2021-4018) sugli accessori. La correzione non fu mai portata sui componenti. Una singola e() mancante ha lasciato aperta la stessa porta per quattro anni.
AV:A/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N = 4.8 (Moderate), con Attack Vector Adjacent. Nei references è conservato il vettore che avevo assegnato in origine, AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N = 6.1 (Medium), con Attack Vector Network. La differenza è tutta lì: l’API di checkout è raggiungibile via rete, quindi ritengo Network più aderente; la severità qualitativa resta comunque “media”. Riporto il numero pubblicato (4.8) come autoritativo e questo per trasparenza.La sorgente: una nota salvata senza validazione
Quando un componente viene assegnato (checkout), il campo note della richiesta finisce grezzo nella pivot components_assets. Le regole di validazione (righe 299–302) coprono solo assigned_to e assigned_qty:
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'), // input utente grezzo, nessuna validazione
]);Il sink: un transformer che non fa escape (a differenza dei suoi fratelli)
Quando i componenti assegnati vengono letti dall’API, il ComponentsTransformer restituisce la nota senza e(). Il dettaglio rivelatore è che gli altri transformer, per lo stesso campo, la escapano correttamente:
// Vulnerabile — nessun e()
'note' => $asset->pivot->note,
// Confronto: gli altri transformer escapano lo stesso campo
// AccessoriesTransformer.php:103 (corretto dopo CVE-2021-4018)
'note' => $checkout->note ? e($checkout->note) : null,
// AssetsTransformer.php:329
'note' => $accessory_checkout->note ? e($accessory_checkout->note) : null,
// ComponentsAssetsTransformer.php:29
'note' => e($asset->note),Tre transformer su quattro fanno la cosa giusta. Il quarto no. È la firma classica di una fix di sicurezza applicata a un caso ma non replicata sui percorsi paralleli — un problema di patch parity.
Il render: un formatter che sostituisce i newline, non li escapa
Sul lato view, la colonna della nota passa per un formatter JavaScript di Bootstrap Table che si limita a trasformare gli a capo in <br>, senza alcun escape:
function notesFormatter(value) {
if (value) {
return value.replace(/(?:\r\n|\r|\n)/g, '<br />'); // sostituisce i newline, non fa escape
}
}Bootstrap Table imposta il valore restituito come innerHTML della cella: qualsiasi HTML nella nota viene interpretato ed eseguito. Snipe-IT, di default, non imposta alcuna Content Security Policy, quindi non c’è una seconda linea di difesa a fermare lo script.
La catena, dal payload alla vittima
- Un utente con
components.checkout(spesso delegato allo staff IT) fa checkout di un componente con JavaScript nella nota. - La nota viene salvata grezza in
components_assets. - Una vittima con
assets.viewapre/components/{id}; Bootstrap Table carica i dati, il transformer li restituisce non escapati, il formatter li passa, la cella li renderizza via innerHTML. - Lo script esegue nel contesto della vittima: furto del cookie di sessione, azioni per suo conto, esfiltrazione dalla sua vista. Se la vittima è admin, il potenziale è l’account takeover.
Riproduzione
# 1. Checkout di un componente con payload nella nota (serve components.checkout)
curl -s -X POST "http://TARGET/api/v1/components/1/checkout" \
-H "Authorization: Bearer ATTACKER_TOKEN" \
-H "Accept: application/json" \
--data-urlencode "assigned_to=1" \
--data-urlencode "assigned_qty=1" \
--data-urlencode "note=<img src=x onerror=alert(document.cookie)>"
# 2. La nota è stata salvata grezza
curl -s "http://TARGET/api/v1/components/1/assets" \
-H "Authorization: Bearer ADMIN_TOKEN" -H "Accept: application/json" \
| jq '.rows[].note'
# "<img src=x onerror=alert(document.cookie)>"
# 3. Qualsiasi utente con assets.view che apre /components/1
# esegue il JavaScript automaticamente al caricamento della tabella.Timeline della disclosure
| Data | Evento |
|---|---|
| 2026-03-09 | Scoperta e conferma su Snipe-IT v8.4.0 (immagine Docker ufficiale); verificata come non corretta su master |
| 2026-03-09 | Report inviato via email a security@snipeitapp.com, nello stesso thread della privilege escalation |
| 2026-04-07 | Fix incluso nel rilascio di v8.4.1 |
| 2026-05-05 | Pubblicazione GitHub Security Advisory GHSA-r42m-953q-6vjx (CVE-2026-44831) |
| 2026-05-06 | Crediti accettati; pubblicazione di questo writeup |
Mitigazione
Aggiornare a Snipe-IT 8.4.1 o successiva. La correzione è la stessa applicata nel 2021 agli accessori: escaping e() del campo al confine del transformer.
// Prima:
'note' => $asset->pivot->note,
// Dopo (coerente con il fix di CVE-2021-4018):
'note' => $asset->pivot->note ? e($asset->pivot->note) : null,Come difesa in profondità vale anche escapare nel formatter lato client (costruendo il testo con textContent prima di sostituire i newline) e configurare una Content Security Policy, assente di default. Ma la lezione operativa è un’altra: quando si corregge un XSS su un campo, va cercato lo stesso campo su tutti i percorsi paralleli — qui i quattro transformer gemelli — perché una fix applicata a metà è una fix che invecchia male.
Advisory GitHub e record CVE
GitHub Security Advisory pubblicata dal vendor (Grokability) con impatto, versioni affette, commit di fix e crediti. Record CVE ufficiale su cve.org.
Lorenzo Fradeani è un security researcher indipendente focalizzato su vulnerability research e tooling di sicurezza offensiva. Disponibile per collaborazioni AppSec e ingaggi di pentest da Massa-Carrara e completamente da remoto. Contattami.