URL-кодирование и декодирование: руководство по percent-encoding
Вы смотрите серверный лог и замечаете в query-строке вот такое: %E4%BD%A0%E5%A5%BD. Это битые данные? Баг? Ни то, ни другое — это китайские иероглифы 你好, переведённые в три байта UTF-8 каждый и затем закодированные в URL-безопасный формат через percent-encoding. Каждый веб-разработчик рано или поздно упирается в эту стену: что-то выглядит сломанным, а URL работает ровно так, как и задумано.
URL-кодирование — формально называемое percent-encoding — это механизм, который делает специальные символы безопасными для URL. Это руководство объясняет, как оно устроено на уровне байтов, когда выбирать encodeURI, а когда encodeURIComponent, как корректно кодировать в четырёх языках и какие баги ловят даже опытных разработчиков.
Вставьте любой URL в наш URL-декодер/кодировщик и наблюдайте за кодированием и декодированием в реальном времени, читая статью.
Что такое URL-кодирование (percent-encoding)?
URL может содержать только небольшое подмножество ASCII-символов. Буквы, цифры и горстка символов проходят через интернет без проблем. Всё остальное — пробелы, амперсанды, китайский текст, эмодзи — нужно перевести в формат, который URL способен переносить.
Percent-encoding заменяет каждый небезопасный байт знаком %, за которым следуют две шестнадцатеричные цифры. Пробел становится %20. Амперсанд — %26. Название схемы происходит как раз от префикса %.
Правила описаны в RFC 3986, опубликованном в 2005 году и до сих пор являющемся действующим стандартом. Он заменил RFC 2396 и уточнил, какие символы безопасны, какие зарезервированы и как обрабатывать не-ASCII текст.
Быстрые примеры:
| Вход | Закодировано | Почему |
|---|---|---|
hello world | hello%20world | Пробел в URL не разрешён |
price=10&tax=2 | price%3D10%26tax%3D2 | = и & имеют структурное значение |
中 | %E4%B8%AD | Не-ASCII → байты UTF-8 → percent-encoding |
🚀 | %F0%9F%9A%80 | Эмодзи → 4 байта UTF-8 → percent-encoding |
Какие символы нужно кодировать?
RFC 3986 делит символы на три группы.
Незарезервированные символы (никогда не кодируются)
Эти 66 символов проходят как есть в любой части URL:
A-Z a-z 0-9 - . _ ~
Буквы, цифры, дефис, точка, подчёркивание, тильда. Это весь список.
Зарезервированные символы (зависят от контекста)
Эти символы выступают структурными разделителями в URL:
| Символ | Роль в структуре URL |
|---|---|
: | Отделяет схему от authority (https:) |
/ | Разделяет сегменты пути |
? | Начинает query string |
# | Начинает fragment |
& | Разделяет параметры query |
= | Разделяет ключ и значение параметра |
@ | Отделяет userinfo от хоста |
+ ! $ ' ( ) * , ; [ ] | Различные зарезервированные роли |
Правило: когда зарезервированный символ выполняет свою структурную функцию, оставляйте его как есть. Когда он появляется как данные (например, внутри значения параметра) — кодируйте.
Всё остальное (всегда кодируется)
Пробелы, угловые скобки, фигурные скобки, вертикальные черты, обратные слэши и не-ASCII-символы (китайские, арабские, эмодзи) обязательно кодируются через percent-encoding.
Один нюанс: RFC 3986 кодирует пробел как %20, но HTML-формы отправляют его как +. Подробнее об этом конфликте — ниже.
Как реально работает URL-кодирование: конвейер UTF-8
Для ASCII-символов кодирование тривиально: смотрим значение байта в hex, ставим перед ним %. Пробел (значение байта 32, hex 20) становится %20.
Для не-ASCII текста кодирование идёт в три шага:
Шаг 1 — Символ в Unicode code point.
Символ é соответствует code point U+00E9. Эмодзи 🚀 — U+1F680.
Шаг 2 — Code point в байты UTF-8.
UTF-8 использует от 1 до 4 байтов в зависимости от диапазона code point. é (U+00E9) превращается в два байта: 0xC3 0xA9. Эмодзи ракета (U+1F680) — в четыре: 0xF0 0x9F 0x9A 0x80.
Шаг 3 — Каждый байт в %XX.
Каждый байт из шага 2 получает свою percent-encoded тройку.
Вот полный конвейер для нескольких типов символов:
| Символ | Code Point | Байты UTF-8 | Закодировано | Множитель размера |
|---|---|---|---|---|
A | U+0041 | 41 | A (не кодируется) | 1× |
| пробел | U+0020 | 20 | %20 | 3× |
é | U+00E9 | C3 A9 | %C3%A9 | 6× |
中 | U+4E2D | E4 B8 AD | %E4%B8%AD | 9× |
🚀 | U+1F680 | F0 9F 9A 80 | %F0%9F%9A%80 | 12× |
Это можно проверить самостоятельно в JavaScript:
const char = '中';
const encoded = encodeURIComponent(char);
console.log(encoded); // '%E4%B8%AD'
// Распечатать байты
const bytes = new TextEncoder().encode(char);
console.log([...bytes].map(b => '%' + b.toString(16).toUpperCase()).join(''));
// '%E4%B8%AD' — совпадает
Это расширение важно для ограничений на длину URL. URL с 20 китайскими иероглифами добавляет 180 символов percent-encoded текста.
encodeURI против encodeURIComponent — выбор правильной функции
Эти две функции JavaScript постоянно путают. Они выглядят похоже, но кодируют совершенно разные множества символов.
encodeURI() | encodeURIComponent() | |
|---|---|---|
| Назначение | Кодировать полный URL | Кодировать один компонент (ключ или значение параметра) |
| Сохраняет | : / ? # & = @ + $ , | Ничего из этого |
| Кодирует | Пробелы, не-ASCII, часть пунктуации | Всё, кроме A-Z a-z 0-9 - _ . ~ ! ' ( ) * |
| Применять, когда | Есть полный URL с пробелами или Unicode в пути | Собираете query-параметры из ввода пользователя |
Регулярно встречающийся в продакшне баг:
// ❌ БАГ: encodeURI НЕ кодирует &
const search = 'Tom & Jerry';
const bad = `https://api.example.com/search?q=${encodeURI(search)}`;
// Результат: https://api.example.com/search?q=Tom%20&%20Jerry
// Символ & разбивает query — сервер видит q=Tom%20 и отдельный параметр %20Jerry
// ✅ ИСПРАВЛЕНИЕ: encodeURIComponent кодирует & как %26
const good = `https://api.example.com/search?q=${encodeURIComponent(search)}`;
// Результат: https://api.example.com/search?q=Tom%20%26%20Jerry
В сомнительных случаях выбирайте encodeURIComponent(). Для 95% реальных задач сборки URL это правильный выбор.
Попробуйте оба режима бок о бок в нашем URL Encoder →
URL-кодирование на каждом языке
JavaScript (браузер и Node.js)
// Кодируем значение параметра
const value = encodeURIComponent('price >= 100 & currency = €');
// 'price%20%3E%3D%20100%20%26%20currency%20%3D%20%E2%82%AC'
// Декодируем
const original = decodeURIComponent(value);
// 'price >= 100 & currency = €'
// Современный подход: URLSearchParams делает кодирование автоматически
const params = new URLSearchParams({ q: 'hello world', lang: '中文' });
console.log(params.toString());
// 'q=hello+world&lang=%E4%B8%AD%E6%96%87'
// Учтите: URLSearchParams кодирует пробел как + (form encoding)
Python
from urllib.parse import quote, unquote, urlencode
# Кодируем сегмент пути
quote('hello world/file name.txt', safe='/')
# 'hello%20world/file%20name.txt'
# Кодируем query-параметры
urlencode({'q': '你好', 'page': '1'})
# 'q=%E4%BD%A0%E5%A5%BD&page=1'
# quote_plus кодирует пробел как + (form encoding)
from urllib.parse import quote_plus
quote_plus('hello world') # 'hello+world'
quote('hello world') # 'hello%20world'
Go
import "net/url"
// Кодируем значение query (пробел как +)
url.QueryEscape("hello world & more")
// "hello+world+%26+more"
// Кодируем сегмент пути (пробел как %20)
url.PathEscape("hello world & more")
// "hello%20world%20&%20more"
// Безопасная сборка URL через url.Values
params := url.Values{}
params.Set("q", "你好世界")
params.Set("page", "1")
fmt.Println(params.Encode())
// "page=1&q=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"
Java
import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
// Кодируем (пробел как + — Java идёт по form encoding)
String encoded = URLEncoder.encode("hello world & more", StandardCharsets.UTF_8);
// "hello+world+%26+more"
// Для соответствия RFC 3986 заменяем + на %20
String rfc3986 = encoded.replace("+", "%20");
// "hello%20world%20%26%20more"
// Декодируем
String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
// "hello world & more"
Go и Java по умолчанию используют form encoding (пробел как +). Для вывода по RFC 3986 пост-обработайте результат, заменив + на %20.
Пять багов URL-кодирования, которые ломают продакшн
1. Двойное кодирование (%2520 вместо %20)
Вы кодируете строку. Фреймворк кодирует её ещё раз. Знак % в %20 становится %25, и сервер видит буквальный текст %20 вместо пробела.
Симптом: в URL появляются %2520, %253D или другие паттерны %25xx.
Диагностика: %25 в URL означает, что был закодирован символ %, а это обычно указывает на двойное кодирование.
Исправление: сначала декодируйте, потом кодируйте один раз. Проверяйте, не закодирован ли уже вход, перед тем как кодировать.
// Обнаружение двойного кодирования
function isDoubleEncoded(str) {
return /%25[0-9A-Fa-f]{2}/.test(str);
}
// Безопасное кодирование: сначала декодируем, потом кодируем
function safeEncode(str) {
try { str = decodeURIComponent(str); } catch (e) { /* не закодировано — это нормально */ }
return encodeURIComponent(str);
}
2. + в сегментах пути
Разработчик URL-кодирует имя файла библиотекой, которая выдаёт + для пробелов. Файл my report.pdf становится my+report.pdf. Сервер интерпретирует + как буквальный плюс и возвращает 404.
Правило: + означает «пробел» только в query-строках (после ?). В сегментах пути + — это просто +. Для пробелов в путях всегда применяйте %20.
3. Сломанные redirect URI в OAuth
URL авторизации выглядит так:
https://auth.provider.com/authorize?redirect_uri=https://myapp.com/callback?code=abc&state=xyz
OAuth-сервер читает redirect_uri=https://myapp.com/callback?code=abc и считает state=xyz отдельным параметром верхнего уровня. Аутентификация проваливается.
Исправление: закодируйте всё значение redirect URI:
const redirectUri = 'https://myapp.com/callback?code=abc&state=xyz';
const authUrl = `https://auth.provider.com/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`;
// redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback%3Fcode%3Dabc%26state%3Dxyz
4. Искажённый не-ASCII текст в логах
Серверные логи показывают %E4%BD%A0%E5%A5%BD вместо читаемых китайских иероглифов. URL закодирован верно; просто ваш просмотрщик логов не декодирует percent-encoding.
Исправление: прогоняйте логи через декодер или вставьте URL в наш URL-декодер, чтобы прочитать исходный текст.
5. Сбои подписи API
OAuth 1.0 и AWS Signature V4 требуют строгого RFC 3986. JavaScript-функция encodeURIComponent() не кодирует !, ', (, ), *. Если эти символы попадают во вход подписи, подпись не совпадёт.
Исправление: обработайте вывод дополнительно:
function rfc3986Encode(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, c =>
'%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}
%20 против + — дилемма кодирования пробела
Два стандарта расходятся в том, как кодировать один символ.
| Стандарт | Пробел становится | Где применяется |
|---|---|---|
| RFC 3986 (синтаксис URI) | %20 | Везде в URL |
application/x-www-form-urlencoded | + | Query-строки от HTML-форм |
Соглашение про + пришло из ранних веб-браузеров. Когда <form> отправляется методом GET, браузер кодирует пробелы в query как +. HTML-спецификация это поведение закрепляет.
Проблема: + означает «пробел» только в query. В сегментах пути + — это буквальный плюс. Именно поэтому https://example.com/my+file.pdf отдаёт файл с именем my+file.pdf, а не my file.pdf.
Практические рекомендации:
- Используйте
%20при ручной сборке URL и кодировании сегментов пути. Работает везде. - Принимайте
+при разборе query от form-submission — ваш фреймворк, скорее всего, уже это делает. - Не смешивайте. Выберите одно соглашение для компонента и держитесь его.
URL-кодирование и безопасность
URL-кодирование НЕ шифрование
Percent-encoding — это полностью обратимое детерминированное преобразование без каких-либо криптографических свойств. Любой может декодировать %48%65%6C%6C%6F обратно в Hello за миллисекунды.
Не применяйте URL-кодирование, чтобы скрыть чувствительные данные. Используйте HTTPS для шифрования всего запроса. URL появляются в логах серверов, истории браузера и заголовках Referer, поэтому чувствительная информация должна быть в теле запроса, а не в URL.
Атаки open redirect
Атакующие используют закодированные URL, чтобы обойти наивную валидацию. Параметр redirect, содержащий %2F%2Fevil.com, декодируется в //evil.com, и браузеры интерпретируют это как protocol-relative URL, указывающий на домен злоумышленника.
Защита: проверяйте декодированный URL, а не закодированную форму. Применяйте allowlist для доменов redirect.
Эксплойты двойного кодирования
WAF проверяет входящие URL на наличие тегов <script>. Атакующий шлёт %253Cscript%253E. WAF видит percent-encoded текст и пропускает его. Приложение декодирует один раз, получает %3Cscript%3E, потом ещё одно декодирование даёт <script>, обходя фильтр.
Защита: нормализуйте весь ввод (декодируйте полностью) перед применением security-проверок. Не полагайтесь на единичный проход декодирования.
Подробное руководство по веб-безопасности — в нашем материале «основы веб-безопасности».
Ограничения длины URL и когда кодирование становится дорогим
В HTTP-спецификации не задан максимум длины URL, но каждый слой стека вводит практический предел.
| Слой | Предел |
|---|---|
| Общая рекомендация | 2000 символов |
| Chrome, Firefox | ~2 МБ (но серверы отвергают намного раньше) |
| Apache (по умолчанию) | 8190 байт |
| Nginx (по умолчанию) | 8192 байт |
| IIS | 16 384 байт (query string) |
| CDN, прокси | По-разному — часто 4096-8192 байт |
Percent-encoding делает URL длиннее. Один китайский иероглиф растёт с 1 символа до 9 (%E4%B8%AD). Эмодзи расширяется до 12. Двести китайских иероглифов в одной только query-строке дают 1800 символов percent-encoded текста.
Когда упираетесь в лимит: перенесите данные из query в тело POST-запроса. Для поисковых интерфейсов хорошо подходит POST-эндпоинт, принимающий JSON.
FAQ
Что такое URL-кодирование и зачем оно разработчикам?
URL-кодирование (percent-encoding) переводит символы, не разрешённые в URL, в hex-последовательности %XX. URL поддерживают только 66 незарезервированных ASCII-символов. Пробелы, амперсанды, Unicode-текст и большая часть пунктуации должны кодироваться, иначе они сломают структуру URL.
В чём разница между encodeURI и encodeURIComponent?
encodeURI() кодирует полный URL, сохраняя структурные символы вроде ://, /, ?, &. encodeURIComponent() кодирует всё, кроме A-Z a-z 0-9 - _ . ~ ! ' ( ) *. Используйте encodeURIComponent() для значений query-параметров. encodeURI() применяйте только тогда, когда есть полный URL и нужно починить пробелы или не-ASCII, не сломав его структуру.
Почему %20 иногда выглядит как + в URL?
Оба представляют пробел, но идут из разных стандартов. %20 соответствует RFC 3986 и работает везде в URL. + соответствует HTML form encoding и работает только в query. В сегментах пути + — это буквальный плюс. В сомнительных случаях используйте %20.
Как URL-кодировать текст в Python, JavaScript, Go и Java?
JavaScript: encodeURIComponent('hello world') → hello%20world. Python: urllib.parse.quote('hello world') → hello%20world. Go: url.QueryEscape("hello world") → hello+world. Java: URLEncoder.encode("hello world", UTF_8) → hello+world. Go и Java по умолчанию применяют form encoding (пробел как +) — замените + на %20 для вывода по RFC 3986.
Можно ли использовать URL-кодирование для безопасности или шифрования?
Нет. URL-кодирование полностью обратимо без ключа. Никакой конфиденциальности оно не даёт. Защищайте чувствительные данные через HTTPS, а не через percent-encoding. URL попадают в серверные логи, историю браузера и заголовки Referer, поэтому чувствительные данные должны быть в теле запроса.
Что такое двойное кодирование и как его исправить?
Двойное кодирование возникает, когда уже закодированная строка кодируется повторно. Знак % в %20 кодируется как %25, давая %2520. Серверы видят буквальный текст %20 вместо пробела. Исправляется так: сначала декодируйте вход, потом кодируйте один раз. Паттерн %25 плюс две hex-цифры — характерный признак.
Какова максимальная длина URL?
Официального максимума в HTTP-спецификации нет. 2000 символов — безопасный лимит для широкой совместимости. Apache по умолчанию даёт 8190 байт, Nginx — 8192 байт. Не-ASCII-символы растут в 3-12 раз при percent-encoding, так что интернационализированные URL упираются в лимиты быстрее. Для крупных payload переходите на POST.