La codifica URL spiegata: percent-encoding e dove morde
Pubblicato il 07/07/2026 · 16 min di lettura · Strumenti per sviluppatori
Daniel Okonkwo — Sviluppatore front-end e redattore Tech presso OneKitly
Performance web · Formati di file
Verificato su 6 fonti
Il percent-encoding sostituisce un byte con un segno di percentuale e due cifre esadecimali. Quali byte vadano sostituiti dipende dalla parte di URL in cui ti trovi, ed è per questo che l'argomento sembra incoerente. La RFC 3986 definisce caratteri non riservati che non necessitano mai di codifica — A-Z, a-z, 0-9, trattino, punto, trattino basso e tilde — e caratteri riservati con significato strutturale: i gen-delims : / ? # [ ] @ e i sub-delims ! $ & ' ( ) * + , ; = . Un carattere riservato va codificato quando compare come dato e non come struttura. Così / è del tutto lecito dentro un percorso e deve diventare %2F dentro un valore di query, perché lì verrebbe altrimenti letto come parte del percorso. JavaScript ti dà tre funzioni che su questo non concordano. Eseguile su a b/c?d=café+e&f#g~h*i(j): encodeURI restituisce a%20b/c?d=caf%C3%A9+e&f#g~h*i(j), encodeURIComponent restituisce a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j), e la deprecata escape restituisce a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29. Usa encodeURIComponent per ogni singolo valore, encodeURI solo per un'URL intera di cui ti fidi già, ed escape mai — emette Latin-1, quindi é diventa %E9 invece del corretto UTF-8 %C3%A9.
Il percent-encoding si decide componente per componente, ed è da lì che nasce tutta la confusione. Una barra è lecita in un percorso e va escapata in un valore di query; uno spazio è %20 in un percorso e può essere + in un corpo di form. Ecco gli insiemi esatti della RFC 3986, le tre funzioni JavaScript che non concordano e le trappole.
La regola è per componente, non per URL
Un'URL non è una sola stringa, è una sequenza di parti etichettate: schema, host, percorso, query, frammento. Ogni parte ha una propria idea di che cosa sia struttura e che cosa sia dato, e il percent-encoding esiste per distinguerle. Una barra dentro un percorso è struttura — separa i segmenti — quindi resta com'è. La stessa barra dentro un valore di query è dato, e va scritta %2F, altrimenti un parser che legge la query non ha modo di sapere che intendevi un carattere letterale e non un pezzo di percorso finito nel posto sbagliato.
La conseguenza si vede appena costruisci un'URL per concatenazione. Supponi che un nome di file sia 2026/08 report.pdf e debba stare in un segmento di percorso. Codifica il valore e ottieni /files/2026%2F08%20report.pdf, un solo segmento come volevi. Salta la codifica e ottieni /files/2026/08 report.pdf, tre segmenti e uno spazio, che puntano a qualcosa che non esiste. La stessa asimmetria colpisce i valori di query: ?note=rock&roll si analizza come due parametri, note con valore rock e un roll vuoto, mentre ?note=rock%26roll si analizza come l'unico valore che intendevi.
Riservati e non riservati, esattamente come li definisce la RFC 3986
L'insieme non riservato è piccolo e vale la pena impararlo: le lettere dalla A alla Z in entrambe le cassette, le cifre da 0 a 9 ed esattamente quattro segni di punteggiatura — trattino, punto, trattino basso e tilde. Questi non richiedono mai codifica in nessun punto di un'URL, e codificarli comunque è lecito ma inutile, poiché %41 e A denotano lo stesso carattere e un parser conforme li tratta in modo identico.
L'insieme riservato si divide in due. I gen-delims sono i caratteri che separano i componenti principali: due punti, barra, punto interrogativo, cancelletto, parentesi quadre di apertura e chiusura, e chiocciola. I sub-delims strutturano l'interno di un componente: punto esclamativo, dollaro, e commerciale, apostrofo, parentesi tonde di apertura e chiusura, asterisco, più, virgola, punto e virgola e uguale. Tutto ciò che non è né non riservato né riservato — caratteri di controllo, spazio, virgolette dritte, parentesi angolari, barra rovesciata, accento circonflesso, accento grave, parentesi graffe, barra verticale e ogni byte oltre 127 — va sempre percent-encoded.
Un dettaglio frega molti. encodeURIComponent lascia intatti punto esclamativo, apostrofo, parentesi tonde e asterisco, e tutti e quattro sono sub-delims secondo la RFC 3986. Quei caratteri sono leciti dove la funzione viene normalmente usata, quindi nei casi ordinari è innocuo; ma se stai producendo un valore per un sistema che segue rigorosamente la RFC 3986 — alcuni schemi di firma e canonicalizzazioni in stile OAuth lo fanno — devi escaparli tu dopo. Nota inoltre che le parentesi quadre sono sempre escapate da entrambi i codificatori JavaScript, dando %5B e %5D, perché sono state aggiunte all'insieme riservato per i letterali IPv6 dopo la specifica di quelle funzioni.
Tre funzioni JavaScript su una sola stringa, e l'eccezione del segno più
Prendi a b/c?d=café+e&f#g~h*i(j) ed esegui tutte e tre. encodeURI produce a%20b/c?d=caf%C3%A9+e&f#g~h*i(j): lo spazio e la lettera accentata vengono codificati, tutto ciò che è strutturale resta intatto. encodeURIComponent produce a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j): barra, punto interrogativo, uguale, più, e commerciale e cancelletto sono tutti escapati, perché nel mondo di questa funzione l'intera stringa è un solo valore. escape produce a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29, diverso da entrambi.
Enumera l'intervallo ASCII e la differenza diventa precisa. Le due funzioni moderne divergono su esattamente undici caratteri: # $ & + , / : ; = ? @ vengono lasciati stare da encodeURI ed escapati da encodeURIComponent. Quella lista è l'insieme riservato, e ti dice a che cosa servono le due funzioni. encodeURI presume che la stringa sia già un'URL completa i cui delimitatori devono sopravvivere; encodeURIComponent presume che la stringa sia un solo valore a cui non si deve permettere di introdurre alcun delimitatore.
escape è un altro animale e non andrebbe mai usata. Precede le specifiche moderne e codifica in Latin-1 anziché UTF-8: é diventa %E9 invece del corretto %C3%A9, e tutto ciò che supera U+00FF diventa una sequenza non standard %uXXXX, così il simbolo dell'euro esce come %u20AC. Lascia inoltre non escapati il più, la chiocciola e la barra, tutti pericolosi dentro un valore di query, mentre escapa inutilmente la tilde e le parentesi tonde. Sopravvive nel linguaggio solo per retrocompatibilità, nell'allegato riservato alle funzionalità che esistono ma su cui non ci si deve appoggiare.
Il percent-encoding come definito dalla RFC 3986 ha esattamente una rappresentazione per lo spazio: %20. Funziona ovunque — percorso, query, frammento. Il segno più come spazio appartiene a un meccanismo diverso e più vecchio: la serializzazione application/x-www-form-urlencoded usata dai form HTML, in cui gli spazi diventano segni più e un più letterale deve diventare %2B. I browser usano quella forma per la query string di un invio di form in GET, ed è per questo che in giro vedi entrambe le convenzioni nelle query string.
La conseguenza è un difetto di decodifica facile da scrivere e difficile da vedere. decodeURIComponent("a+b") restituisce a+b, con il più intatto, perché decodeURIComponent implementa la RFC 3986 e non sa nulla della codifica di form. Dai la stessa stringa a un parser consapevole dei form e ottieni a b. Quindi il decodificatore corretto dipende da come la stringa è stata prodotta. In pratica la mossa affidabile è smettere di improvvisare da entrambi i lati: costruisci le query string con URLSearchParams, che serializza uno spazio come + ed escapa un più letterale come %2B, e rileggile con URLSearchParams, che inverte esattamente le stesse regole.
Il non ASCII passa prima per UTF-8
Il percent-encoding opera sui byte, non sui caratteri, quindi un carattere non ASCII va trasformato in byte prima di poter essere escapato. La regola moderna è UTF-8, poi un escape per byte. é è un singolo carattere codificato come i due byte c3 a9, quindi diventa %C3%A9. Il simbolo dell'euro è di tre byte, e2 82 ac, quindi diventa %E2%82%AC — nove caratteri per un simbolo. Un'emoji come U+1F600 è di quattro byte e diventa %F0%9F%98%80, dodici caratteri.
È qui che escape ti tradisce, poiché mappa la stessa é sul singolo byte %E9, il suo punto di codice Latin-1. Un server che decodifica come UTF-8 vede una sequenza di byte non valida e o solleva un errore o produce un carattere sostitutivo, e il guasto si manifesta solo sulle righe accentate dei tuoi dati. La RFC 3986 non impone di per sé una codifica dei caratteri — precede l'adozione universale di UTF-8 e si limita a raccomandarlo per gli schemi nuovi — ma lo standard URL del WHATWG, che è ciò che i browser implementano davvero, specifica UTF-8 dappertutto. Tratta UTF-8 come l'unica risposta corretta.
La doppia codifica, e come nasce %2520
Il segno di percentuale è esso stesso un carattere riservato, quindi codificare una stringa già codificata escapa gli escape. Parti da a b. Codifica una volta: a%20b. Codifica quello: a%2520b, perché la percentuale è diventata %25. Codifica ancora: a%252520b. Ogni giro aggiunge tre caratteri e un ulteriore passaggio di decodifica obbligatorio, e la stringa cresce senza mai sollevare un errore.
Nei sistemi reali questo accade quando un valore attraversa più livelli, ciascuno dei quali codifica premurosamente ciò che gli è stato consegnato: un client codifica, un gateway ricodifica, un framework codifica all'ingresso di un template. Il sintomo è un %20 letterale che compare in una pagina o in un nome di file dove dovrebbe esserci uno spazio, o un 404 su un percorso che sembra corretto. La cura è una disciplina, non un trucco: decidi esattamente un punto della pipeline che possiede la codifica, codifica lì e passa valori grezzi ovunque altrove. Se devi decodificare in modo difensivo, decodifica una volta e verifica se il risultato contiene ancora un segno di percentuale seguito da due cifre esadecimali prima di decidere di decodificare di nuovo — e sappi che decodificare ripetutamente alla cieca è di per sé un problema di sicurezza, perché può trasformare %252e%252e in .. e riaprire un attraversamento di percorso che una singola decodifica aveva chiuso.
L'host è un meccanismo diverso: IDN e punycode
Il percent-encoding non si applica al nome di dominio. Le etichette DNS sono limitate a lettere, cifre e trattini, quindi i domini internazionalizzati usano una trasformazione del tutto separata: il Punycode, definito nella RFC 3492 e avvolto dalle specifiche IDNA. münchen.de diventa xn--mnchen-3ya.de, bücher.example diventa xn--bcher-kva.example, e un dominio giapponese come i due caratteri che indicano il Giappone seguiti da .jp diventa xn--wgv71a.jp. Il prefisso xn-- segnala l'etichetta come codificata; seguono i caratteri ASCII in ordine, poi un separatore, poi le istruzioni per reinserire quelli non ASCII.
Puoi vedere i due meccanismi lavorare fianco a fianco. Analizza https://münchen.de/straße?q=über alles con un parser URL standard e il risultato è https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles: l'host è passato per il Punycode, percorso e query sono passati per il percent-encoding UTF-8, e nessuno dei due ha invaso il territorio dell'altro. Questa separazione non è cosmetica. È il motivo per cui una sequenza percentuale in un nome host non viene decodificata come ci si potrebbe aspettare, e per cui gli attacchi omografici — registrare un dominio i cui caratteri Unicode somigliano a quelli di un altro — sono un problema del livello Punycode, che i browser affrontano con regole di visualizzazione e non con la codifica.
Quattro regole che evitano la maggior parte dei guai
Primo, non assemblare mai un'URL per concatenazione di stringhe quando è disponibile un costruttore di URL. new URL() e URLSearchParams sanno in quale componente si trovano e codificano di conseguenza, che è esattamente la conoscenza che manca a un template literal. Secondo, codifica i valori, non le URL: applica encodeURIComponent a ogni singolo segmento di percorso e a ogni singolo valore di query, e riserva encodeURI a un'URL finita che hai costruito tu. Terzo, codifica una volta sola, in un unico livello proprietario, e passa valori grezzi ovunque altrove — già solo questo elimina l'intera classe dei difetti da %2520. Quarto, cancella escape dalla tua base di codice; codifica in Latin-1 e lascia passare caratteri pericolosi, e non esiste situazione in cui sia la risposta giusta.
| Codificatore | Uscita su a b/c?d=café+e&f#g~h*i(j) | Lascia intatti (oltre lettere e cifre) | Non ASCII | Usalo per |
|---|---|---|---|---|
| encodeURIComponent | a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j) | - . _ ~ ! ' ( ) * | UTF-8 poi percent-encoded | Ogni singolo valore: un segmento di percorso, un valore di query, un frammento |
| encodeURI | a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) | Tutto ciò che il codificatore di componente mantiene, più # $ & + , / : ; = ? @ | UTF-8 poi percent-encoded | Un'URL intera che hai assemblato tu e di cui ti fidi già |
| escape (deprecata) | a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29 | * + - . / @ _ — ed esce ~ ( ) che gli altri mantengono | Latin-1 fino a U+00FF, poi il non standard %uXXXX | Niente. È nello standard solo per retrocompatibilità |
| URLSearchParams (codifica di form) | q=a+b per il valore a b; a/b+c diventa a%2Fb%2Bc | Stesso insieme non riservato, ma uno spazio diventa + e non %20 | UTF-8 poi percent-encoded | Costruire una query string o un corpo x-www-form-urlencoded |
Domande frequenti
- Qual è la differenza fra encodeURI ed encodeURIComponent?
- Esattamente undici caratteri. encodeURI lascia stare # $ & + , / : ; = ? @; encodeURIComponent li escapa tutti. Quella lista è l'insieme riservato della RFC 3986, e ti dice che cosa presume ciascuna funzione. encodeURI crede che tu gli abbia consegnato un'URL completa i cui delimitatori devono continuare a funzionare, quindi escapa solo ciò che non potrebbe mai essere struttura — spazi, non ASCII, caratteri di controllo. encodeURIComponent crede che tu gli abbia consegnato un singolo valore che non deve poter introdurre alcun delimitatore, quindi escapa tutto il riservato. Sulla stringa a b/c?d=café+e&f#g~h*i(j) la prima restituisce a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) e la seconda restituisce a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j). Usa encodeURIComponent per ogni segmento di percorso e ogni valore di query, che è quasi sempre ciò che vuoi. Usa encodeURI solo su un'URL intera che hai assemblato tu e di cui ti fidi già — applicarlo a input utente non rende sicuro quell'input, perché preserva deliberatamente i caratteri di cui un valore iniettato avrebbe bisogno.
- Uno spazio deve essere %20 o un segno più?
- %20 è sempre corretto; il segno più lo è solo in un contesto specifico. Il percent-encoding della RFC 3986 ha una sola rappresentazione dello spazio, %20, valida allo stesso modo nel percorso, nella query e nel frammento. Il più come spazio viene da application/x-www-form-urlencoded, la serializzazione più vecchia usata dai form HTML, dove gli spazi diventano più e un più letterale va scritto %2B. I browser lo applicano quando inviano un form in GET, ed è per questo che le query string reali contengono entrambe le convenzioni. Il pericolo pratico è sul lato decodifica: decodeURIComponent("a+b") restituisce a+b con il più intatto, perché implementa la RFC 3986 e non sa nulla dei form, mentre un parser consapevole dei form restituisce a b. Non decidere caso per caso. Costruisci le query string con URLSearchParams e rileggile con URLSearchParams, così le stesse regole valgono in entrambe le direzioni. In un segmento di percorso usa sempre %20 — lì un più è un carattere più letterale e nient'altro.
- Perché vedo %2520 nelle mie URL?
- Perché qualcosa ha codificato una stringa già codificata. Il segno di percentuale è esso stesso riservato, quindi diventa %25 quando viene escapato. Prendi a b, codificalo in a%20b, poi codifica quel risultato: la percentuale diventa %25 e ottieni a%2520b. Fallo ancora e ottieni a%252520b. Nulla va in errore, la stringa cresce solo di tre caratteri per giro e richiede un passaggio di decodifica in più. Nei sistemi reali questo accade quando più livelli codificano educatamente ciascuno ciò che ha ricevuto — un client, poi un gateway, poi un framework che renderizza in un template. Il sintomo è un %20 letterale dove dovrebbe esserci uno spazio, o un 404 su un percorso che sembra giusto. La correzione è architetturale: nomina esattamente un livello proprietario della codifica, codifica lì e fai circolare valori grezzi ovunque altrove. Evita di decodificare in ciclo finché non restano percentuali, perché è una vulnerabilità a sé — la decodifica ripetuta può trasformare %252e%252e in .. e riaprire un attraversamento di percorso che una singola decodifica aveva contenuto.
- Devo codificare i caratteri non inglesi in un'URL?
- Sì, e la codifica passa prima per UTF-8. Il percent-encoding lavora sui byte, quindi un carattere deve diventare byte prima di poter essere escapato, e la regola moderna è codificare come UTF-8 e poi scrivere un escape per byte. é sono due byte, c3 a9, e diventa %C3%A9. Il simbolo dell'euro è di tre byte e diventa %E2%82%AC, nove caratteri per un simbolo. Un'emoji a U+1F600 è di quattro byte e diventa %F0%9F%98%80. Questo conta per le dimensioni di archiviazione e per qualsiasi limite di lunghezza che imponi, dato che un carattere può costarne dodici. Spiega anche perché la deprecata funzione escape corrompe i dati: emette Latin-1, trasformando é nel singolo byte %E9, che un decodificatore UTF-8 rifiuta come non valido — e il guasto compare solo sui tuoi record accentati. L'host è l'eccezione: i nomi di dominio non sono percent-encoded affatto ma convertiti con il Punycode, quindi münchen.de diventa xn--mnchen-3ya.de mentre il percorso e la query accanto usano il normale percent-encoding UTF-8.
- Il percent-encoding basta a rendere sicuro l'input dell'utente?
- No, perché la codifica è contestuale e un'URL è solo uno dei contesti che un valore attraversa. encodeURIComponent impedisce a un valore di uscire dal suo componente di URL: una barra diventa %2F e non può aprire un nuovo segmento di percorso, una e commerciale diventa %26 e non può aprire un nuovo parametro. Questo è reale e importante. Non fa nulla per il passo successivo. Lo stesso valore messo in HTML richiede escape HTML, messo in un'istruzione SQL richiede una query parametrizzata, messo in un comando di shell richiede quoting a livello di argomento, e messo in un letterale di stringa JavaScript richiede il proprio escape. Codificare per il contesto sbagliato non è protezione parziale: è nessuna protezione. Due ulteriori avvertenze: encodeURI non è un sanificatore di input, poiché preserva deliberatamente i caratteri riservati che un attaccante userebbe, quindi non applicarlo mai a dati non fidati come misura di sicurezza; e decodificare ripetutamente finché non resta alcuna percentuale può ricostruire sequenze che una singola decodifica aveva neutralizzato, in particolare riportando %252e%252e a un attraversamento di percorso.
- Come si inseriscono qui i nomi di dominio internazionalizzati?
- Non usano affatto il percent-encoding. Le etichette DNS sono limitate a lettere, cifre e trattini, quindi un dominio che contenga altro viene convertito con il Punycode, definito nella RFC 3492 e governato dalle specifiche IDNA. münchen.de diventa xn--mnchen-3ya.de, bücher.example diventa xn--bcher-kva.example, e un dominio giapponese come i due caratteri che indicano il Giappone seguiti da .jp diventa xn--wgv71a.jp. Il prefisso xn-- segnala l'etichetta come codificata, e il resto contiene i caratteri ASCII seguiti dalle istruzioni per rimettere gli altri. Puoi vedere entrambi i sistemi insieme analizzando un'URL come https://münchen.de/straße?q=über alles: il risultato è https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles, con Punycode sull'host e percent-encoding UTF-8 in tutto ciò che segue. In pratica, non fare mai percent-encoding di un nome host e confronta i nomi host nella loro forma Punycode. È anche il motivo per cui gli attacchi omografici sono gestiti dalla politica di visualizzazione del browser e non dalla codifica — i due nomi sono davvero etichette distinte che si somigliano soltanto.
Articoli che potrebbero interessarti
Tutte le guide →Strumenti correlati
Fonti
- IETF — RFC 3986, Uniform Resource Identifier (URI): Generic Syntax
- WHATWG — URL Standard
- WHATWG — HTML Standard, URL-encoded form data
- IETF — RFC 3492, Punycode: A Bootstring encoding of Unicode for IDNA
- IETF — RFC 5890, Internationalized Domain Names for Applications (IDNA): Definitions and Document Framework
- MDN Web Docs — encodeURIComponent()
Hai notato un errore in questo articolo?