Пошук уроків, статей та іншого контенту
Розберете ризики витоку секретів, токенів і персональних даних через URL, логи, referrer та клієнтський код.
Браузер працює на боці користувача. Усе, що потрапило до:
JavaScript-коду;
HTML;
DevTools;
localStorage, sessionStorage або IndexedDB;
URL;
cookies, доступних JavaScript;
мережевих запитів;
кешу або service worker,
потенційно може бути прочитано користувачем або шкідливим кодом, який виконується в цьому origin.
Тому секретами не можна вважати дані, які вже віддані браузеру:
const apiKey = "production-secret-key";Після складання застосунку цей рядок може опинитися в JavaScript-бандлі, source map, кеші або логах помилок. Обфускація змінює вигляд коду, але не робить значення секретним.
Справжні секрети, наприклад приватні ключі або client secret OAuth, повинні зберігатися на сервері.
URL часто здається зручним способом передати параметри:
https://example.com/reset?token=eyJhbGciOi...Але URL може бути скопійований або збережений у багатьох місцях:
історії браузера;
синхронізації браузера;
закладках;
журналах вебсерверів;
reverse proxy та CDN;
системах аналітики;
логах браузера;
звітах про помилки;
заголовку Referer;
скриншотах і текстових повідомленнях;
access-логах стороннього сервісу.
Навіть якщо URL доступний лише кілька секунд, він уже міг потрапити до журналу або аналітичного скрипту.
Параметри після ? надсилаються серверу разом із HTTP-запитом:
https://example.com/invite?email=olena@example.com&token=temporary-tokenСервер, проксі або CDN можуть записати весь шлях і query string до access-логу:
GET /invite?email=olena@example.com&token=temporary-token HTTP/1.1Тому токени скидання пароля, access token, email, телефони та інші персональні дані не слід передавати через query-параметри.
Шлях також може витікати:
https://example.com/users/olena@example.com/invoicesЗаміна query-параметра на частину шляху не розв'язує проблему. Серверні логи зазвичай містять і шлях.
Частина після # не надсилається серверу в HTTP-запиті:
https://example.com/callback#access_token=temporary-tokenЦе краще за query string лише в одному вузькому сенсі: звичайний HTTP-сервер не отримує fragment.
Проте fragment все одно доступний:
JavaScript-коду сторінки через location.hash;
стороннім скриптам, завантаженим на цій сторінці;
користувачу через історію браузера;
розширенням браузера;
системам моніторингу, якщо застосунок сам передає їм URL;
XSS-коду.
Fragment не входить до стандартного значення Referer, але це не робить його безпечним сховищем.
RefererКоли браузер переходить зі сторінки на іншу сторінку або завантажує ресурс, він може надіслати заголовок:
Referer: https://example.com/account?email=olena@example.comТочне написання заголовка в HTTP — Referer, хоча слово історично написане з помилкою.
Так URL поточної сторінки може потрапити до:
сторонньої картинки;
рекламного скрипту;
системи аналітики;
iframe;
зовнішньої таблиці стилів;
іншого сайту, на який перейшов користувач.
Сучасні браузери зазвичай використовують політику strict-origin-when-cross-origin за замовчуванням. Вона, зокрема, обмежує передачу повного URL між різними origin. Але покладатися лише на налаштування браузера не варто: політику потрібно задавати явно, а секрети не повинні потрапляти в URL взагалі.
Найкраще встановлювати політику HTTP-заголовком:
Referrer-Policy: strict-origin-when-cross-originДля сторінок, де URL може містити чутливі дані, часто доречніше:
Referrer-Policy: no-referrerДля HTML-документа можна використати метатег:
<meta name="referrer" content="no-referrer">Заголовок відповіді є кращим варіантом, оскільки він діє раніше та охоплює більше сценаріїв.
Для окремого запиту політику можна вказати в fetch:
await fetch("/api/profile", {
method: "GET",
referrerPolicy: "no-referrer",
});Це контролює referrer для конкретного запиту, але не видаляє секрети з поточного URL і не очищає вже створені логи.
Небезпечно логувати URL цілком:
console.log("Current page:", location.href);Якщо URL містить токен, він потрапить до DevTools, системи збору логів або звітів про помилки.
Також небезпечні:
console.log("OAuth callback", window.location);
console.error("Request failed:", requestUrl);
analytics.track("page_view", { url: location.href });Навіть якщо команда не планувала збирати секрети, автоматичні інструменти можуть записувати:
location.href;
document.referrer;
URL мережевих запитів;
query-параметри;
stack trace з фрагментами URL;
значення полів форми;
заголовки запитів.
Логувати потрібно подію та технічний ідентифікатор, але не секрет:
console.info("OAuth callback received", {
hasCode: Boolean(new URLSearchParams(location.search).get("code")),
});Якщо потрібно зберегти URL для діагностики, спочатку видаліть чутливі параметри:
function sanitizeUrl(input) {
const url = new URL(input, window.location.origin);
for (const name of [
"token",
"access_token",
"refresh_token",
"code",
"state",
"email",
"phone",
]) {
url.searchParams.delete(name);
}
url.hash = "";
return url.toString();
}
console.info("Navigation", {
url: sanitizeUrl(location.href),
});Очищення для логування не означає, що вихідний URL уже безпечний. Якщо він раніше був записаний у серверний лог або історію, цей запис нікуди не зникає.
Код, який виконується в браузері, доступний користувачу:
const config = {
apiBaseUrl: "https://api.example.com",
privateKey: "-----BEGIN PRIVATE KEY-----",
};Навіть якщо значення надходить через змінну середовища на етапі збірки, після підстановки воно опиняється в клієнтському бандлі.
Не можна додавати до frontend-коду:
приватні ключі;
паролі;
client secret;
ключі доступу до внутрішніх API;
ключі підпису JWT;
облікові дані бази даних;
токени з надлишковими правами.
Публічний ключ або спеціально обмежений frontend API key може бути допустимим, але його потрібно обмежити за origin, дозволеними API та квотами. Це не заміна серверній авторизації.
Source map полегшує налагодження, але може розкрити:
початковий код;
коментарі;
назви внутрішніх модулів;
випадково додані конфігураційні значення.
У production source map не повинен містити секретів. Якщо source map публікується, потрібно ставитися до його вмісту як до публічного коду.
Скрипт, який виконується в контексті сторінки, може прочитати:
location.href;
location.hash;
document.cookie;
localStorage;
document.referrer;Якщо callback-сторінка OAuth завантажує аналітику до очищення URL, сторонній скрипт може прочитати authorization code або інші параметри.
На сторінках, що обробляють чутливі дані:
не завантажуйте непотрібні сторонні скрипти;
очищайте URL якомога раніше;
застосовуйте Content Security Policy;
не передавайте повний URL до аналітики;
обмежуйте джерела скриптів.
history.replaceState() дає змогу замінити поточний запис історії без перезавантаження сторінки:
history.replaceState({}, document.title, "/callback");Це корисно після обробки callback-параметрів, але має обмеження:
не видаляє URL з серверних логів;
не змінює журнали проксі та CDN;
не очищає копії в аналітичних системах;
не скасовує вже виконаний сторонній JavaScript;
не видаляє попередній URL з інших пристроїв або синхронізованої історії.
Приклад самодостатньої сторінки: вона визначає наявність чутливих параметрів, не виводить їх значення та замінює URL у поточній історії.
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="referrer" content="no-referrer">
<title>Безпечний callback</title>
</head>
<body>
<h1>Обробка callback</h1>
<p id="status">Перевірка параметрів...</p>
<script>
const sensitiveNames = new Set([
"token",
"access_token",
"refresh_token",
"code",
"state",
"email",
"phone",
]);
const currentUrl = new URL(window.location.href);
const foundNames = [];
for (const name of currentUrl.searchParams.keys()) {
if (sensitiveNames.has(name)) {
foundNames.push(name);
}
}
const hasFragment = currentUrl.hash.length > 0;
// Відразу прибираємо query-параметри та fragment з адресного рядка.
currentUrl.search = "";
currentUrl.hash = "";
const cleanUrl =
currentUrl.pathname +
(currentUrl.search ? currentUrl.search : "") +
(currentUrl.hash ? currentUrl.hash : "");
window.history.replaceState({}, document.title, cleanUrl);
const status = document.querySelector("#status");
if (foundNames.length === 0 && !hasFragment) {
status.textContent = "Чутливих параметрів не знайдено.";
} else {
status.textContent =
"URL очищено. Отримані чутливі значення не виводяться та не логуються.";
}
</script>
</body>
</html>У справжньому OAuth callback після перевірки state застосунок може передати короткоживучий authorization code на сервер. Сам code не слід виводити в лог або зберігати в localStorage.
Для сучасних застосунків рекомендований підхід — Authorization Code Flow з PKCE.
Типовий сценарій:
Клієнт генерує code_verifier і пов'язаний із ним code_challenge.
Клієнт зберігає verifier у пам'яті або в іншому захищеному від повторного використання місці.
Користувач проходить автентифікацію у провайдера.
Провайдер повертає короткоживучий authorization code.
Застосунок надсилає code та verifier на сервер.
Сервер обмінює їх на токени.
Сервер створює сесію, бажано через cookie з атрибутами HttpOnly, Secure і відповідним SameSite.
Не слід використовувати implicit flow для нових застосунків, де access token повертається безпосередньо в URL fragment.
state і nonceПараметр state допомагає захиститися від підміни OAuth-відповіді та CSRF-подібних атак. Його потрібно:
генерувати криптографічно випадково;
прив'язувати до початку конкретної авторизації;
перевіряти після повернення;
видаляти після одноразового використання;
не записувати в логи.
Для OpenID Connect також використовується nonce, який захищає від повторного використання або підміни ID token.
Сам факт наявності state не робить URL безпечним. Значення все одно може потрапити до історії та логів.
localStoragelocalStorage.setItem("accessToken", token);Перевага — простота. Недолік — будь-який JavaScript-код у цьому origin, включно з XSS, може прочитати токен:
const token = localStorage.getItem("accessToken");localStorage також зберігається між перезапусками браузера, тому наслідки крадіжки можуть бути довготривалими.
sessionStoragesessionStorage обмежений вкладкою та очищається після завершення сесії вкладки, але все одно доступний JavaScript. XSS-код може прочитати його так само, як localStorage.
Cookie з атрибутом HttpOnly не доступний через document.cookie:
Set-Cookie: session_id=random-value; Secure; HttpOnly; SameSite=LaxКорисні атрибути:
HttpOnly — забороняє доступ до cookie з JavaScript;
Secure — дозволяє надсилати cookie лише через HTTPS;
SameSite — обмежує міжсайтове надсилання cookie;
Path — обмежує шляхи, для яких cookie надсилається;
Max-Age або Expires — обмежує час життя.
HttpOnly не захищає від усіх атак. Якщо на сайті є XSS, шкідливий скрипт може виконувати запити від імені користувача, навіть не читаючи cookie. Для цього потрібні захист від XSS, CSP, CSRF-захист і коректна авторизація на сервері.
Не передавайте токени в URL:
// Небезпечно: токен потрапляє до URL і може бути записаний у логах.
fetch(`/api/report?token=${encodeURIComponent(token)}`);Краще використовувати cookie-сесію або заголовок авторизації:
const response = await fetch("/api/report", {
method: "GET",
credentials: "include",
referrerPolicy: "no-referrer",
});
if (!response.ok) {
throw new Error("Не вдалося завантажити звіт");
}
const report = await response.json();Якщо застосунок використовує bearer token у пам'яті:
const response = await fetch("/api/report", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
referrerPolicy: "no-referrer",
});Значення заголовка Authorization не є частиною URL, але його все одно можуть записати спеціально налаштовані проксі, middleware або системи діагностики. Логи запитів потрібно проєктувати з маскуванням секретних заголовків.
Навіть несекретні на перший погляд дані можуть бути персональними:
/search?name=Ірина&diagnosis=...
/documents?customerId=12345
/profile?email=olena@example.comРизики:
URL може бути доступний іншим користувачам на спільному пристрої;
URL може потрапити до історії або синхронізації;
його можуть побачити оператори інфраструктури;
він може опинитися в аналітиці;
його можна випадково передати під час копіювання посилання.
Для фільтрів сторінки query-параметри зазвичай прийнятні, якщо вони не містять чутливих даних. Для персональних або приватних значень краще використовувати короткий випадковий ідентифікатор із серверною перевіркою доступу або передавати дані в тілі POST-запиту.
POST не є автоматичним захистом: тіло запиту також можуть логувати, а HTTPS потрібен у будь-якому випадку. Перевага полягає в тому, що дані не потрапляють до звичайної історії URL, Referer і стандартного access-логу шляху.
Практичний набір заходів:
Не передавати секрети та персональні дані через URL.
Використовувати HTTPS для всього застосунку.
Додавати явну Referrer-Policy.
Не логувати location.href, location.search, location.hash і повні URL запитів.
Маскувати Authorization, cookies та інші чутливі заголовки в логах.
Очищати OAuth callback URL до завантаження сторонніх скриптів.
Не додавати секрети до frontend-бандла та source map.
Віддавати сесійні cookie з HttpOnly, Secure і коректним SameSite.
Використовувати короткий час життя токенів та можливість їх відкликання.
Обмежувати сторонні скрипти через Content Security Policy.
Не передавати повний URL до аналітики.
Перевіряти, чи не зберігаються чутливі значення в кеші, IndexedDB, service worker або crash reports.
Fragment не надсилається серверу, але його бачить JavaScript. XSS або сторонній скрипт може прочитати location.hash.
Такий код уже створює витік:
console.log(location.href);
history.replaceState({}, "", "/");Спочатку не логують секрет, а потім очищають URL. Потрібно взагалі не передавати секрет у лог.
Referer-Policy замість правильної архітектуриПолітика referrer зменшує кількість витоків, але не захищає від:
історії браузера;
XSS;
стороннього скрипту на поточній сторінці;
серверного access-логу;
копіювання URL користувачем.
Основне рішення — не розміщувати секрет у URL.
Якщо браузер може виконати запит із ключем, користувач може побачити цей ключ і повторити запит. Потрібно перенести операцію на сервер або видати обмежений публічний ключ із мінімальними правами.
CORS керує тим, чи може JavaScript прочитати відповідь іншого origin. Він не приховує frontend-код, URL або відкритий API key. Крім того, XSS на вашому origin може виконувати дозволені запити від імені користувача.
localStorageТакий токен легко прочитати через XSS і він переживає перезапуск браузера. Для сесій часто безпечніше використовувати короткоживучу серверну сесію в HttpOnly cookie.
POST не робить дані секретними. Якщо секрет потрапляє в тіло запиту, він все одно може опинитися в логах сервера, middleware або системи моніторингу. Логування потрібно налаштувати окремо.
Під час аудиту перевірте:
чи містять URL слова token, code, secret, email, phone;
чи виводяться URL у console.log;
чи надсилає аналітика location.href;
чи завантажуються сторонні скрипти на callback-сторінках;
чи видно секрети у зібраному JavaScript;
чи доступні токени через localStorage або document.cookie;
чи встановлені HttpOnly, Secure, SameSite;
чи є Referrer-Policy;
чи маскуються секрети в серверних і клієнтських логах;
чи видаляються одноразові OAuth-параметри після обробки;
чи мають токени короткий строк дії та обмежені права.
URL не є безпечним контейнером для секретів.
Query-параметри та шлях можуть потрапляти до історії, логів, аналітики й Referer.
Fragment не надсилається серверу, але доступний JavaScript і залишається в історії.
Повні URL та мережеві дані не слід бездумно записувати в логи.
Код, який виконується в браузері, не може містити справжніх секретів.
localStorage і sessionStorage доступні XSS-коду.
Для сесій часто використовують HttpOnly, Secure, SameSite cookies.
Для OAuth варто застосовувати Authorization Code Flow з PKCE, state і nonce.
Referrer-Policy, CSP та маскування логів зменшують ризик, але не замінюють правильну архітектуру.
Найнадійніший спосіб не допустити витоку секрету через браузер — не передавати його браузеру або URL взагалі.