tutoriales·7 分で読める

URL Encoding: Por Qué %20 y + No Son lo Mismo (y Cuándo Importa)

El espacio se escribe %20 en una ruta pero + en un formulario, y confundirlos rompe búsquedas, firmas de APIs y enlaces de OAuth. Una guía práctica del percent-encoding para dejar de adivinar.

DevToolsHub Team
·
URL Encoding: Por Qué %20 y + No Son lo Mismo (y Cuándo Importa)

El bug que solo aparece con apellidos raros

El escenario es un clásico: todo funciona en tus tests hasta que un usuario real busca "rock & roll", se apellida "Núñez" o pone un "C++" en el formulario. De repente el backend recibe basura, la firma de la petición no valida o el redirect de OAuth devuelve un 400 críptico.

El culpable casi siempre es el mismo: alguien concatenó strings en una URL sin entender qué es el percent-encoding, cuándo el espacio es %20, cuándo es +, y por qué encodeURIComponent y encodeURI no son intercambiables. Vamos a arreglar eso de una vez.


Por qué existe esto: caracteres con doble vida

Una URL no es un saco de texto libre. El RFC 3986 divide los caracteres en dos grupos:

  • Unreserved: letras, dígitos y cuatro símbolos (-, _, ., ~). Van tal cual, siempre.
  • Reserved: los que tienen significado estructural — :, /, ?, #, &, =, +, y otros. Se usan para separar las partes de la URL.

El problema es que a veces quieres usar un carácter reservado como dato, no como estructura. En https://api.com/buscar?q=rock&roll, ese & no es parte del valor rock&roll: para el servidor son dos parámetros (q=rock y un misterioso roll vacío). Ahí entra el percent-encoding: cada byte "peligroso" se escribe como % seguido de su código hexadecimal. & es el byte 0x26, así que se escribe %26.

https://api.com/buscar?q=rock%26roll          → q = "rock&roll"
https://api.com/buscar?q=Jos%C3%A9+N%C3%BA%C3%B1ez  → q = "José Núñez"

Ojo al detalle importante: el percent-encoding opera sobre bytes, no sobre caracteres. La é en UTF-8 son dos bytes (0xC3 0xA9), de ahí el %C3%A9. Si alguna vez ves salidas tipo Nuñez, lo que pasó es que bytes UTF-8 se interpretaron como Latin-1 — el encoding se rompió en otro punto de la cadena.


%20 contra +: la guerra de los dos estándares

Aquí está la confusión número uno. Hay dos convenciones para el espacio, y conviven:

  1. RFC 3986 (la URL "pura"): el espacio se escribe %20. Punto.
  2. application/x-www-form-urlencoded (los formularios HTML): el espacio se escribe +, y el + literal se escribe %2B.

¿Y dónde se aplica cada una? Esta es la tabla mental que necesitas:

  • En el path (/docs/mi archivo.pdf): siempre %20. Un + en el path es literalmente el carácter más, no un espacio. /docs/mi+archivo.pdf busca un archivo llamado mi+archivo.pdf.
  • En la query string (?q=mi+búsqueda): depende de quién lea. La tradición form-urlencoded acepta + como espacio, y la mayoría de servidores lo decodifican así. Pero es convención, no ley universal — hay APIs y frameworks que tratan el + como literal incluso en la query.

La regla práctica que nunca falla: genera %20 para espacios siempre que puedas (es válido en ambos mundos), y al decodificar, acepta los dos. Lo que no puedes hacer es lo contrario: enviar + por un path y esperar que alguien lo lea como espacio.


encodeURIComponent vs encodeURI: el error del millón

JavaScript tiene dos funciones y la gente elige mal constantemente:

encodeURIComponent("rock & roll, 100%")
// "rock%20%26%20roll%2C%20100%25"

encodeURI("https://ej.com/ruta?x=rock & roll")
// "https://ej.com/ruta?x=rock%20&%20roll"
  • encodeURIComponent codifica todo lo reservado: /, ?, &, =, #, +, ,, etc. Es lo que quieres para un valor individual: un parámetro de query, un segmento de path, un token.
  • encodeURI deja intactos los caracteres estructurales (/ ? & = #), porque asume que le pasas una URL completa y solo quiere escapar espacios y no-ASCII.

El bug canónico: meter un valor con encodeURI pensando que ya está "escapado":

const ciudad = "Trinidad & Tobago";
fetch(`/api/clima?ciudad=${encodeURI(ciudad)}`);
// → /api/clima?ciudad=Trinidad & Tobago
//    el & sigue suelto: son DOS parámetros. Bug.

fetch(`/api/clima?ciudad=${encodeURIComponent(ciudad)}`);
// → /api/clima?ciudad=Trinidad%20%26%20Tobago  ✓

Regla nemotécnica: component para componentes (piezas), sin component para URLs enteras. Si lo que codificas es un dato, encodeURIComponent. Y olvida escape() — está deprecada desde ES3 y rompe Unicode.

URLSearchParams: el atajo con truco

const params = new URLSearchParams({ q: "rock & roll", pag: "2" });
params.toString();  // "q=rock+%26+roll&pag=2"

Es la forma correcta de construir queries hoy en día, con un matiz: serializa el espacio como + (estilo form-urlencoded). Válido en la mayoría de contextos, pero si firmas peticiones o comparas strings de URL exactas (OAuth, AWS Signature), recuerda que + ≠ %20 a nivel de bytes — dos representaciones del mismo espacio producen firmas distintas. Ahí suele vivir el "mi firma no valida y no sé por qué" de las 3 AM.


El doble encoding: %2520 y la sopa de porcentajes

Si %20 es un espacio, ¿qué es %2520? El percent-encoding de un percent-encoding: %25 es el % literal, así que %2520 decodifica una vez a %20 y dos veces a espacio.

Esto pasa más de lo que crees:

// El valor ya venía codificado de otra capa
const ya = "rock%20%26%20roll";
encodeURIComponent(ya);  // "rock%2520%2526%2520roll" — desastre

Síntomas en la vida real: logs con %2525 por todas partes, redirects de OAuth que fallan porque el redirect_uri se codificó dos veces, y nombres de archivo en S3 que literalmente se llaman foto%2520final.jpg. La cura es disciplina: decide en qué capa se codifica (normalmente la más externa, la que serializa la URL final) y pasa valores crudos hasta ahí. Codificar "por si acaso" en cada capa es como envolver un regalo en siete papeles: nadie sabe ya qué hay dentro.


El lado oscuro: decodificar antes de validar

Del otro lado del cable, el percent-encoding es también un vector de ataque viejo conocido:

GET /files/%2e%2e%2f%2e%2e%2fetc%2fpasswd

%2e es ., %2f es /. Si tu servidor decodifica el path y después comprueba que no contiene ../, todo bien. Si la comprobación de seguridad mira el string antes de decodificar, el atacante acaba de salir de tu directorio público. Variantes divertidas: %252e%252e%252f (doble encoding, sobrevive una decodificación), ..%c0%af (encoding inválido que algunos parsers antiguos aceptaban).

La lección para tu lado del servidor: decodifica una sola vez, valida después, y nunca construyas paths de fichero uniendo strings decodificados sin verificación de que el resultado queda dentro del directorio permitido. Los CVEs de path traversal de 2026 siguen usando este truco de 1999.


Checklist para no volver a depurar esto

  • Valor suelto → encodeURIComponent. URL completa → encodeURI. Nunca escape().
  • Queries: usa URLSearchParams en vez de concatenar strings — pero recuerda que emite + para espacios.
  • En el path, solo %20. Un + ahí no es un espacio, es el carácter más.
  • Codifica una vez, en la capa que serializa la URL. Pasa datos crudos hasta entonces.
  • Si firmas peticiones (OAuth, AWS, webhooks con HMAC): normaliza a %20 y mayúsculas en los hex (%2F, no %2f) — el estándar de firma es estricto con la forma exacta.
  • Al decodificar en servidor: una pasada, luego valida. Path traversal vive en el orden contrario.
  • No-ASCII siempre en UTF-8 antes de percent-encodear. La ñ son dos bytes, no uno.

Pruébalo sin montar un servidor

La forma más rápida de asimilar las diferencias es verlas: nuestro codificador URL te muestra exactamente cómo queda cualquier texto — prueba con rock & roll, con José Núñez, con una URL que ya venga codificada para ver el efecto del doble encoding. Y el decodificador URL hace el camino inverso: pégale ese q=rock%26roll sospechoso de tus logs o ese redirect_uri raro de OAuth y mira qué está recibiendo realmente el servidor.

El percent-encoding es de esos temas que parecen de apuntes de primero de carrera hasta que te rompe una integración un viernes. Media hora entendiéndolo hoy te ahorra esa tarde.

#url#encoding#http#apis#web

関連記事

chmod 755 vs rwxr-xr-x: Domina los Permisos Unix de una Vez
•
8 min

chmod 755 vs rwxr-xr-x: Domina los Permisos Unix de una Vez

Qué significa realmente rwxr-xr-x, cómo traducir entre octal y simbólico sin calculadora, los bits especiales que casi nadie entiende (setuid, setgid, sticky) y por qué chmod 777 "funciona" pero es una mala idea.

chmodlinux