Сначала вывод, который можно проверить в одной вкладке. Откройте DevTools → Network и сравните всю адресную строку со строкой запроса документа. В адресе ключ после # может быть. В строке запроса той же строки быть не должно. Если она там есть, это уже не поведение браузера по умолчанию: страница записала fragment в query либо скрипт сам прочитал его и дописал в запрос.
Вопросительный знак и решётка — разные слои защиты
Одноразовые ссылки с шифротекстом часто описывают одной фразой: «ключ лежит в URL». Фраза слишком грубая. Она склеивает два разных факта. В одном адресе могут быть и параметры, и фрагмент: ? открывает query, # открывает fragment. Оба видны в адресной строке, и человек обычно копирует строку целиком. Для HTTP это не одно и то же.
Параметры становятся частью цели запроса. Откройте s.html?id=abc123 — сервер, обратный прокси, CDN и журнал доступа по задумке видят id=abc123. Фрагмент в этот запрос документа не входит. Откройте s.html?id=abc123#ключ — браузер просит у сервера страницу с этим id. Всё после решётки остаётся во вкладке, чтобы скрипт страницы прочитал его через location.hash.
Поэтому «ключ в URL» отвечает только на вопрос, видит ли человек всю строку. На вопрос, видит ли ключ сервер, оно не отвечает. Для ключа подходит именно часть после решётки — если реализация правда оставляет её во fragment, а не запихивает в query, потому что так проще.
Что пишут спецификации: fragment остаётся клиенту
Это не частное соглашение одного продукта. RFC 3986, раздел 3.5 определяет fragment как идентификатор «вторичного ресурса»: появляется решётка, затем до конца URI. Как толковать фрагмент, зависит от типа полученного документа. Обрабатывает его клиент. Схема URI его не переписывает.
RFC 9110, раздел 7.1 переносит то же правило на HTTP: целевой URI, который выводит браузер, не содержит fragment, потому что идентификатор фрагмента предназначен клиенту. Когда браузер запрашивает документ, в строке запроса не должно быть решётки и того, что за ней.
Заголовок Referer режется по той же линии. RFC 9110 говорит: пользовательский агент, который формирует Referer, не должен включать fragment и userinfo. Страница MDN про Referer совпадает с этим: в заголовке могут быть источник, путь и query, но не фрагмент после #. W3C Referrer Policy при превращении URL в referrer сначала обнуляет fragment и только потом решает, оставлять ли путь и параметры.
Три документа вместе доказывают одну вещь: браузер, который следует спецификации, не запишет ключ после решётки в строку HTTP-запроса и не положит его в Referer. Они не доказывают, что скрипты страницы, расширения или текст, который вы куда-то вставите, тоже сами вырежут этот кусок.
Если ключ стоит после «?», в логах он уже открытый
Записать ключ как ?id=abc123&key=... проще в разработке. Сервер из одного query забирает шифротекст и тут же расшифровывает. На страницах «зашифровать онлайн» так делают часто: один запрос закрывает всё. Для обещания «хостинг не должен видеть ключ» это прямо противоположный выбор.
Параметры попадают в строку запроса. Они же оказываются в большинстве журналов доступа, логах обратного прокси и части отчётов CDN. Как только ключ сел в query, фраза «мы храним только шифротекст» уже ложна на уровне логов: достаточно открыть access log за этот день — и вторая половина для расшифровки на столе.
Иногда предлагают унести ключ в тело POST. Из URL-логов он тогда пропадает. Но ключ всё равно покинул браузер и доехал до машины, которую назвали «нулевым знанием». Ценность fragment как раз в обратном: ключу вообще не нужно уходить как HTTP-поле. Страница читает location.hash в этой вкладке.
| Куда положили | Видит ли человек | Видит ли этот HTTP-запрос |
|---|---|---|
Query после ? |
Да: в адресной строке и в скопированном тексте | Да. Строка запроса, прокси и журналы это пишут |
Фрагмент после # |
Да: в адресной строке и в скопированном тексте | По умолчанию нет. Строка запроса и Referer по спецификации этот кусок не берут |
| Поле POST | В адресной строке нет | Да. Тело запроса доходит до сервера |
| Только в памяти, в URL не пишем | Собеседник не увидит, пока не откроете другой канал | Нет. Но одноразовую ссылку «открыл и прочитал» так не передать |
Рабочее разделение: локатор после «?», ключ после «#»
Одноразовая ссылка с шифротекстом должна сделать две разные вещи сразу: сказать серверу, какую запись забрать, и сказать браузеру получателя, каким ключом расшифровать её на месте. Эти две задачи не должны делить одно HTTP-поле.
Проверяемое разделение такое: s.html?id={id}#{key}. После вопросительного знака только локатор id. После решётки только ключ. При создании браузер считает AES-256-GCM через Web Crypto и наружу может отправить только шифротекст. При чтении скрипт берёт location.hash, запрашивает шифротекст у сервера и расшифровывает в этой вкладке. Сервер по задумке коротко хранит шифротекст и уничтожает его после прочтения.
Одноразовая ссылка MakePwd держит эту границу. Страница создания и страница чтения открываются без аккаунта. Страница чтения открыта получателю и не просит войти. Это всё равно выбор реализации, а не тезис «решётка сама по себе — протокол шифрования». Фрагмент не делает секрет конфиденциальным. Он только не даёт ключу уйти из вкладки как HTTP-полю.
Не ставьте эксперимент на настоящем пароле, номере документа или таблице без обезличивания. Возьмите одно предложение, которое можно выбросить, и одноразовую ссылку, созданную именно для этой проверки. Вы сверяете строку запроса с адресной строкой, а не распространяете секрет ещё раз.
Сверьте Network с адресной строкой на месте
В предыдущей заметке было, как канарейкой проверить, не ушёл ли исходный текст как рабочие данные. Здесь вопрос уже: попал ли ключ в HTTP. Шаги можно закончить отдельно. Сначала читать ту статью не обязательно.
Создайте безобидный тестовый текст и скопируйте полную ссылку. Найдите решётку: впереди s.html?id=..., ключ — после. Откройте чистую вкладку, включите DevTools, поставьте Preserve log и вставьте ссылку.
- Сверьте адресную строку со строкой запроса документа. В адресе должны остаться
#и то, что за ней. В URL документа — только путь и?id=, без ключа после решётки. - Затем откройте последующие XHR / Fetch. Интерфейс шифротекста по задумке может нести
id. Тот же ключ не должен снова появиться в теле или в query. Тело запроса создания — шифротекст, а не только что введённое предложение. - Отдельно откройте query и тела аналитики. Путь страницы может быть. Если скрипт пишет полный
location.href, ключ перестаёт быть «скрыт поведением HTTP» и становится «страница сама его отправила». Это ошибка реализации, а не поломка спецификации.
Три чистых прохода поддерживают только узкий вывод: в этом браузере, в этом открытии ключ не покинул вкладку как наблюдаемое HTTP-поле. Сменили браузер, версию или скрипт страницы — проверку нужно повторить.
Когда этот слой защиты ломается
Фрагмент закрывает только тезис «этот HTTP-запрос отдал ключ хостингу». В случаях ниже ключ и не рассчитывал на поведение HTTP по умолчанию. Спецификация тут не помощник.
Первое: полную ссылку вставили в чат, письмо или заявку. Собеседник видит всю строку глазами, а часть после решётки оседает в истории той системы. Некоторые клиенты отбрасывают hash и показывают превью только того, что стоит до него, — тогда получатель открывает страницу без ключа, и страница чтения должна сказать об этом, а не просить у сервера второй ключ. В любом варианте вы уже отдали секрет по факту владения другой системе.
Второе: скрипты страницы и расширения читают location.hash. Именно поэтому страница чтения работает. Именно поэтому XSS или враждебное расширение могут забрать ключ. RFC 9110, раздел 17.11, уже предупреждает: фрагмент не входит в запрос, но остаётся видимым пользовательскому агенту, расширениям и скриптам, которые пришли с ответом. Если редирект наследует fragment исходного URL, фрагмент этого сайта может уехать на другой origin.
Третье: история браузера, демонстрация экрана и буфер обмена. Полная строка из адресной строки попадает в локальную историю. Выведите вкладку на стену в переговорке — часть после решётки окажется на экране. Через ваш сервер это не проходит, и фраза «фрагмент не уходит в HTTP» это не опровергает.
Четвёртое: промежуточные страницы и короткие ссылки, которые переписывают URL. Если середина пересылает только путь и query, к моменту открытия у получателя hash уже нет. Если она сначала читает полный href в JavaScript и потом прыгает, ключ уже побывал во фронтенде посредника. Короткие ссылки особенно стоит смотреть вживую: та строка, которую вы отдаёте, всё ещё оригинал с #?
Три утверждения, которые переворачивают смысл
«После решётки безопаснее, значит всю строку можно свободно пересылать.» Нет. Безопаснее относительно сервера — не значит безопаснее относительно лога группового чата. Полный URL — это секрет. Кто его получил, откроет страницу чтения и расшифрует текст.
«Referer может слить ключ на внешнюю ссылку.» По действующим спецификациям браузер, который формирует Referer, обязан срезать fragment. Смотреть нужно другое: не пишет ли страница location.href в аналитику, логи или чужой скрипт. Проверка по-прежнему в содержимом Network, а не в допущении «любая внешняя ссылка сливает ключ».
«Страницу чтения надо закрыть входом, иначе откроет кто угодно.» Здесь смешаны «кто держит полную ссылку» и «у кого есть аккаунт». Контроль доступа у одноразовой ссылки с шифротекстом — сама строка. Вход для получателя не прячет ключ от сервера: ключ на сервер и не должен уходить. Страница чтения MakePwd открыта получателю. Создать и прочитать можно без аккаунта.
Частые вопросы
Уходит ли на сервер то, что стоит после решётки?
По умолчанию нет. RFC 9110 говорит: целевой URI не содержит fragment. В Network в строке запроса документа не должно быть # и ключа после неё. Сервер по задумке видит только путь и ?id=.
Почему ключ не кладут после вопросительного знака?
Параметры после «?» попадают в строку HTTP-запроса. Их видят хостинг и журналы доступа. Как только ключ сел в query, «только шифротекст» уже ложно на уровне логов. Локатор id может стоять после ?. Ключ должен стоять после #.
Безопасно ли вставлять полную ссылку в чат?
Для сервера ключ по-прежнему не виден. Для истории чата, заявок и журнала браузера — уже нет. Полный URL работает как секрет по факту владения. Если передавать всё же нужно, убедитесь, что собеседник получил исходную строку с решёткой, и помните: она осядет в его истории.
Нужно ли входить, чтобы открыть страницу чтения?
Нет. Страница чтения открыта получателю: id после «?» забирает шифротекст, ключ после решётки расшифровывает его в этой вкладке. В MakePwd нет аккаунтов и нет хранилища паролей. Создать и прочитать можно без регистрации.
Три вещи, которые стоит запомнить перед следующей одноразовой ссылкой
Первое: смотрите символ, а не слоган «ключ лежит в URL». Вопросительный знак уходит в HTTP, решётка по умолчанию нет. Второе: при создании убедитесь, что наружу уходит шифротекст; при чтении — что в строке запроса нет куска после #. Третье: прежде чем отдать полную ссылку, спросите себя, попадёт ли она в историю чата, заявку или короткую ссылку, которая отбрасывает hash. Последние два сбоя не зависят от того, нулевое ли знание у сервера.
Если ещё нужно проверить, не ушёл ли исходный текст как рабочие данные, это уже предыдущая заметка: ищите канарейку в телах Network и в аналитике. Здесь только вопрос «почему ключ можно класть после решётки» свёрнут в суждение, которое можно сверить со спецификациями и со строкой запроса.