HTTP Advanced — cookies, аутентифікація та HTTPS
HTTP Advanced — cookies, аутентифікація та HTTPS
Від теорії до практики: проблема stateless
У попередньому розділі ми з'ясували, що HTTP є stateless протоколом — сервер не зберігає жодної інформації між запитами. Кожен запит є ізольованим і повністю незалежним від попередніх.
Ця властивість забезпечує видатну масштабованість: будь-який сервер у кластері може обробити будь-який запит, не знаючи нічого про попередні взаємодії з клієнтом. Але вона ж породжує фундаментальну проблему реального вебу.
Уявіть сценарій: користувач вводить логін і пароль. Наступний запит — відкриває особистий кабінет. Ще один — оформлює замовлення. Як сервер знає, що всі три запити — від одного і того самого аутентифікованого користувача, якщо HTTP не зберігає стан?
Існує три принципово різних підходи до вирішення проблеми стану:
| Механізм | Де зберігається ідентифікатор | Хто надсилає | Захист |
|---|---|---|---|
| Session Cookie | Браузер (cookie jar) | Браузер автоматично | HttpOnly, Secure, SameSite |
| Bearer Token (JWT) | Застосунок (memory/storage) | Вручну в заголовку | HTTPS, short expiry |
| URL-параметр | URL-рядок | Вручну | ❌ Логується, кешується |
Cookies: механізм передачі стану
Що таке cookie
Cookie (HTTP Cookie, RFC 6265) — це невеликий фрагмент даних, який сервер надсилає браузеру у заголовку Set-Cookie, і браузер автоматично відправляє назад з кожним наступним запитом до того самого домену через заголовок Cookie.
Cookies — це не безпечне сховище і не база даних. Це лише механізм, що дозволяє серверу «наклеїти мітку» на браузер клієнта і потім розпізнавати його у наступних запитах.
Браузер зберігає cookies у cookie jar — приватній базі даних. Обмеження за специфікацією RFC 6265:
- Не більше 4096 байт на один cookie (ім'я + значення + атрибути)
- Не більше 50 cookies на домен
- Не більше 3000 cookies загалом у браузері
Анатомія Set-Cookie
Повний приклад заголовку Set-Cookie:
Set-Cookie: session=abc123xyz; Domain=.example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax
Domain=.example.com — для всіх піддоменів (api.example.com, shop.example.com). Якщо не вказано — лише для поточного хоста (без піддоменів).Path=/ — для всього сайту. Path=/admin — лише для запитів до /admin/*.Max-Age=3600 — 1 година. Max-Age=0 — негайне видалення. Має пріоритет над Expires. Якщо не вказано — session cookie: видаляється при закритті браузера.Max-Age: абсолютна дата закінчення. Expires=Thu, 01 Jan 2026 00:00:00 GMT. Застаріла альтернатива Max-Age, але широко підтримується.document.cookie). Захищає від атак XSS (Cross-Site Scripting): навіть якщо зловмисник впровадив скрипт на сторінку, він не зможе вкрасти session cookie.Strict— лише при навігації з того самого сайту (максимальний захист)Lax— дозволяє при top-level навігації (GET), блокує при cross-site POST (баланс)None; Secure— завжди (для сторонніх виджетів, потребуєSecure) ::
Cookie-префікси: примусова безпека
RFC 8941 визначає Cookie Prefixes — конвенцію іменування, що дозволяє браузеру примусово перевіряти атрибути безпеки при встановленні cookie. Якщо атрибути не відповідають вимогам префіксу, браузер відхиляє cookie.
| Префікс | Обов'язкові атрибути | Призначення |
|---|---|---|
__Secure- | Secure + доставка лише по HTTPS | Захист від downgrade атак |
__Host- | Secure + Path=/ + без Domain | Прив'язка до конкретного хоста |
Set-Cookie: __Secure-token=abc123; Secure; SameSite=Lax
Set-Cookie: __Host-session=xyz; Secure; Path=/; SameSite=Lax; HttpOnly
__Host- — найбезпечніший варіант для session cookies: неможливо передати на піддомен, неможливо встановити без HTTPS, завжди для кореневого шляху /.
__Host- префікс. Це запобігає атаці cookie tossing — коли зловмисник, що контролює піддомен evil.example.com, встановлює cookie для батьківського домену example.com.SameSite у деталях
Важливе розрізнення: site ≠ origin. SameSite перевіряє реєстрований домен (eTLD+1), а не повний origin.
https://app.example.com та https://api.example.com — це same-site (обидва example.com), але cross-origin (різні піддомени).
| Сценарій запиту | Strict | Lax | None |
|---|---|---|---|
| Top-level навігація GET (клік по посиланню) | ✅ | ✅ | ✅ |
| Top-level навігація POST (submit форми) | ❌ | ❌ | ✅ |
Cross-site <img src=...>, <script src=...> | ❌ | ❌ | ✅ |
Cross-site fetch() / XHR | ❌ | ❌ | ✅ |
<iframe> cross-site | ❌ | ❌ | ✅ |
| Same-site будь-який | ✅ | ✅ | ✅ |
SameSite=Noneвимагає атрибут Secure. Без нього браузери (Chrome 80+) відхиляють cookie. Також SameSite=None дозволяє cross-site запити — використовуйте лише для сторонніх виджетів (iframe, кросдоменні API).Атаки на cookies та механізми захисту
Cross-Site Scripting (XSS) — впровадження шкідливого JavaScript на сторінку жертви через вразливе поле (коментарі, профіль, параметр URL). Якщо cookie не захищений HttpOnly, зловмисник читає його через document.cookie.
Захист: HttpOnly атрибут повністю блокує доступ до cookie через document.cookie. Навіть при наявності XSS-вразливості зловмисник не зможе прочитати HttpOnly cookie.
Також: Content-Security-Policy (CSP) обмежує виконання inline-скриптів і завантаження ресурсів із сторонніх джерел.
Cross-Site Request Forgery (CSRF) — змушує браузер жертви надіслати автентифікований запит до цільового сайту. Браузер автоматично додає cookies до будь-якого запиту на відповідний домен, незалежно від ініціатора.
Захист:
SameSite=LaxабоStrict— найефективніший захист на рівні cookies- CSRF-токен — прихований токен у формі, що перевіряється сервером (для старих браузерів без SameSite)
Origin/Refererзаголовки перевірка — додатковий захист
Зловмисник встановлює жертві відомий йому session ID ще до аутентифікації. Після того, як жертва логіниться з цим ID, зловмисник використовує ту саму сесію.
1. Зловмисник: GET / → отримує session=known_id
2. Зловмисник надсилає жертві:
https://bank.com/login?session=known_id
3. Жертва переходить, логіниться
4. Сервер НЕ змінює session ID → сесія known_id тепер авторизована
5. Зловмисник: GET /account, Cookie: session=known_id → має повний доступ!
Захист: Завжди генерувати новий session ID після успішного логіну (RegenerateId()). Це єдине ефективне рішення — зробити ID, відомий зловмиснику, непридатним після аутентифікації.
Якщо зловмисник контролює будь-який піддомен (evil.example.com), він може встановити cookie для батьківського домену .example.com, перезаписавши легітимний session cookie.
Set-Cookie: session=malicious; Domain=.example.com; Path=/
Браузер перешле цей cookie на bank.example.com поруч із легітимним. Якщо сервер бере перший cookie без перевірки, він обробить шкідливий.
Захист: __Host- префікс: cookie прив'язується до конкретного хоста, і браузер відхиляє його встановлення з будь-якого піддомену.
При передачі cookie по незашифрованому HTTP зловмисник у тій самій мережі (публічний Wi-Fi) може перехопити session ID через packet sniffing.
Жертва: GET http://example.com/ Cookie: session=abc123
Зловмисник (Wi-Fi) перехоплює: session=abc123
Зловмисник: GET /account Cookie: session=abc123 → доступ!
Захист: Secure атрибут — браузер надсилає cookie виключно по HTTPS. HSTS — примусова HTTPS-навігація (навіть якщо користувач вводить http://).
::
Cookies у C#
Читання та встановлення cookies
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
// CookieContainer автоматично зберігає і надсилає cookies
var cookieContainer = new CookieContainer();
var handler = new HttpClientHandler
{
CookieContainer = cookieContainer,
UseCookies = true
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://httpbingo.org/")
};
// httpbingo.org/cookies/set — встановлює cookie через 302-редирект
// CookieContainer автоматично слідує за редиректом і зберігає Set-Cookie
// GET /cookies/set?session=abc123xyz → 302 Location:/cookies + Set-Cookie: session=abc123xyz; HttpOnly
HttpResponseMessage loginResponse = await client.GetAsync("cookies/set?session=abc123xyz");
loginResponse.EnsureSuccessStatusCode();
// Переглянемо отримані cookies
IEnumerable<Cookie> cookies = cookieContainer.GetCookies(
new Uri("https://httpbingo.org/")
);
foreach (Cookie cookie in cookies)
{
Console.WriteLine($"Cookie: {cookie.Name}={cookie.Value}");
Console.WriteLine($" HttpOnly: {cookie.HttpOnly}");
Console.WriteLine($" Secure: {cookie.Secure}");
Console.WriteLine($" Expires: {cookie.Expires}");
Console.WriteLine($" Domain: {cookie.Domain}");
}
// 2. Наступний запит — CookieContainer автоматично надішле Cookie header
// httpbingo.org/cookies повертає {"cookies":{"session":"abc123xyz"}}
HttpResponseMessage profileResponse = await client.GetAsync("cookies");
// Заголовок Cookie: session=abc123xyz буде додано автоматично
using System.Net;
using System.Net.Http;
// Вимикаємо автоматичну обробку cookies і редиректів, щоб читати Set-Cookie вручну
var handler = new HttpClientHandler { UseCookies = false, AllowAutoRedirect = false };
using var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://httpbingo.org/")
};
string? sessionToken = null;
// 1. /cookies/set повертає 302 з Set-Cookie — читаємо вручну
// GET /cookies/set?session=abc123 → 302 Found + Set-Cookie: session=abc123; HttpOnly
var request = new HttpRequestMessage(HttpMethod.Get, "cookies/set?session=abc123xyz");
HttpResponseMessage loginResp = await client.SendAsync(request);
// Статус: 302 Found, заголовок: Set-Cookie: session=abc123xyz; HttpOnly
if (loginResp.Headers.TryGetValues("Set-Cookie", out var setCookies))
{
foreach (string rawCookie in setCookies)
{
// Парсимо "session=abc123; HttpOnly; Secure; SameSite=Lax"
// Перша частина до ';' — це ім'я=значення
string nameValue = rawCookie.Split(';')[0].Trim();
if (nameValue.StartsWith("session=", StringComparison.OrdinalIgnoreCase))
{
sessionToken = nameValue["session=".Length..];
Console.WriteLine($"Session ID отримано: {sessionToken[..8]}...");
}
}
}
// 2. httpbingo.org/cookies показує всі cookies, отримані від клієнта
var profileRequest = new HttpRequestMessage(HttpMethod.Get, "cookies");
if (sessionToken is not null)
profileRequest.Headers.Add("Cookie", $"session={sessionToken}");
HttpResponseMessage profileResp = await client.SendAsync(profileRequest);
// Відповідь: {"cookies":{"session":"abc123xyz"}}
// Сервер видаляє cookie, надсилаючи Set-Cookie з Max-Age=0 або минулою датою
// Клієнт також може видалити cookie вручну через CookieContainer
var cookieContainer = new CookieContainer();
// ... після логіну cookie зберігається в container
// Видалення cookie у CookieContainer:
var uri = new Uri("https://httpbingo.org/");
Cookie? sessionCookie = cookieContainer
.GetCookies(uri)
.FirstOrDefault(c => c.Name == "session");
if (sessionCookie is not null)
{
sessionCookie.Expired = true; // видалити з jar
Console.WriteLine("Cookie видалено з CookieContainer");
}
Сесії: серверний стан через cookie
Cookie — це лише ключ. Реальний стан (хто такий користувач, що він може робити) зберігається на сервері. Класична схема — Session ID через Cookie.
Сесії vs Токени: порівняння архітектур
Два фундаментально різних підходи до збереження стану автентифікації:
| Критерій | Session Cookie | JWT / Bearer Token |
|---|---|---|
| Де стан | На сервері (DB/Redis) | У самому токені (claims) |
| Розмір | ~30 байт (ID) | ~300–600 байт (base64) |
| Відкликання | Миттєво (DEL session:id) | Складно (blacklist або short TTL) |
| Масштабування | Потребує centralized store | Без серверного стану (stateless) |
| Мікросервіси | Складніше — потрібен store | Простіше — JWT перевіряється локально |
| Безпека зберігання | HttpOnly cookie (XSS-стійкий) | Залежить від клієнта |
| CSRF | Вразливий (потребує SameSite/токен) | Не вразливий (header, не cookie) |
HTTP-аутентифікація
Аутентифікація — це підтвердження особи клієнта. HTTP пропонує кілька схем, кардинально різних за безпекою та складністю.
WWW-Authenticate: стандартний challenge flow
Перш ніж розглядати схеми, розберемо, як HTTP стандартизує запит і підтвердження автентифікації через заголовки WWW-Authenticate та Authorization.
Сервер може оголосити кілька схем у відповіді 401. Клієнт обирає схему, яку підтримує, і повторює запит з відповідним заголовком Authorization.
Basic Authentication
Найпростіша схема. Логін і пароль кодуються у Base64 і передаються в заголовку Authorization:
alice:s3cr3t_p@ssw0rd
→ Base64 →
YWxpY2U6czNjcjN0X3BAc3N3MHJk
Authorization: Basic YWxpY2U6czNjcjN0X3BAc3N3MHJk
echo "YWxpY2U6czNjcjN0X3BAc3N3MHJk" | base64 -d. Basic Auth допустимий виключно через HTTPS. Ніколи не використовуйте Basic Auth по незашифрованому HTTP.using System.Net.Http.Headers;
using System.Text;
// httpbingo.org/basic-auth/{user}/{password} — реальна перевірка Basic Auth
// Повертає 401 при невірних даних, 200 при правильних
using var client = new HttpClient
{
BaseAddress = new Uri("https://httpbingo.org/")
};
// Кодуємо "alice:secret" у Base64
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes("alice:secret")
);
// Варіант 1: на рівні клієнта (для всіх запитів)
// GET /basic-auth/alice/secret → перевіряє Base64("alice:secret") у заголовку
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
// Варіант 2: на рівні одного запиту
var request = new HttpRequestMessage(HttpMethod.Get, "basic-auth/alice/secret");
request.Headers.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
HttpResponseMessage response = await client.SendAsync(request);
// Варіант 3: через NetworkCredential
var handler = new HttpClientHandler
{
Credentials = new NetworkCredential("alice", "secret")
};
using var credClient = new HttpClient(handler);
Digest Authentication
Digest Auth (RFC 7616) — вдосконалення Basic, де пароль ніколи не передається відкрито. Замість цього клієнт відповідає на challenge сервера хешем.
1. Клієнт → GET /private
2. Сервер → 401 WWW-Authenticate: Digest realm="example.com",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
qop="auth"
3. Клієнт обчислює:
HA1 = MD5("alice:example.com:password")
HA2 = MD5("GET:/private")
response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
4. Клієнт → GET /private
Authorization: Digest username="alice",
realm="example.com", nonce="dcd98b...",
response="6629fae49393a05397450978507c4ef1"
nonce — одноразове значення від сервера, що запобігає replay attacks: зловмисник не може повторно використати перехоплену відповідь, бо nonce змінюється. Незважаючи на це, Digest Auth рідко використовується в сучасних API — Bearer Token забезпечує кращу гнучкість.
Bearer Token та JWT
Bearer Token (RFC 6750) — сучасний стандарт аутентифікації у REST API. Клієнт отримує токен після аутентифікації і передає його з кожним запитом:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbGljZSIsInJvbGVzIjpbInVzZXIiXSwiZXhwIjoxNzQ3NDgwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT: структура та розшифрування
JWT (JSON Web Token, RFC 7519) — найпопулярніший формат токену. Три Base64URL-закодовані частини, розділені крапкою: header.payload.signature.
JWT Claims поділяються на три категорії:
| Тип | Поля | Призначення |
|---|---|---|
| Registered | sub, iss, aud, exp, nbf, iat, jti | Стандартизовані RFC 7519 |
| Public | name, email, roles | Публічні, реєструються IANA |
| Private | department, tenantId | Власні claims застосунку |
sub — subject: ідентифікатор суб'єкта (userId)
iss — issuer: хто видав токен
aud — audience: для кого токен (перевіряється при валідації)
exp — expiration time: Unix timestamp закінчення
nbf — not before: токен недійсний до цього часу
iat — issued at: час видачі
jti — JWT ID: унікальний ідентифікатор (для blacklist)
JWT алгоритми підпису
| Алгоритм | Тип | Ключ | Перевага | Недолік |
|---|---|---|---|---|
| HS256 | Symmetric | Один секрет | Простий, швидкий | Всі сервіси мають один ключ |
| HS512 | Symmetric | Один секрет | Довший хеш | Більший розмір |
| RS256 | Asymmetric | Private/Public key | Публічна верифікація | Повільніший, більший токен |
| ES256 | Asymmetric (ECDSA) | Private/Public key | Менший за RS256 | Складніша реалізація |
HS256 — для монолітних застосунків (один сервер видає і перевіряє). RS256 — для мікросервісів: auth-сервер підписує приватним ключем, інші сервіси перевіряють публічним (без доступу до секрету).
JWT валідація: 7 кроків
1. Перевірити підпис — HMAC/RSA верифікація
2. Перевірити exp (expiration) — токен не протух
3. Перевірити nbf (not before) — токен вже активний
4. Перевірити iss (issuer) — від правильного видавця
5. Перевірити aud (audience) — для цього сервісу
6. Перевірити alg — алгоритм відповідає очікуваному (не "none"!)
7. Опційно: перевірити jti у blacklist (для відкликання)
"alg":"none" без підпису. Завжди явно вказуйте очікуваний алгоритм при валідації і відхиляйте токени з іншими алгоритмами.Безпечне зберігання токенів у клієнті
| Сховище | XSS | CSRF | Persistence | Рекомендація |
|---|---|---|---|---|
localStorage | ❌ Вразливий | ✅ Не вразливий | Permanent | ❌ Не рекомендується |
sessionStorage | ❌ Вразливий | ✅ Не вразливий | Tab-scoped | ⚠ Тільки не-критичні |
| HttpOnly Cookie | ✅ Захищений | ❌ Вразливий (CSRF) | Configurable | ✅ Рекомендується + SameSite |
| Memory (JS var) | ✅ Захищений | ✅ Не вразливий | None (tab close) | ✅ Для access tokens |
Рекомендована стратегія для SPA:
- Access token — у пам'яті JavaScript (втрачається при reload)
- Refresh token — у
HttpOnly; Secure; SameSite=Strictcookie - При reload сторінки — тихо обмінювати refresh token на новий access token
using System.Net.Http.Headers;
using System.Net.Http.Json;
// ── Крок 1: Отримати JWT-токен ───────────────────────────────────────────────
// dummyjson.com — реальний API з JWT аутентифікацією (справжній eyJ... токен)
using var client = new HttpClient { BaseAddress = new Uri("https://dummyjson.com/") };
// POST /auth/login → справжній JWT access + refresh token
var credentials = new { username = "emilys", password = "emilyspass", expiresInMins = 30 };
HttpResponseMessage authResponse = await client.PostAsJsonAsync("auth/login", credentials);
authResponse.EnsureSuccessStatusCode();
var tokenResult = await authResponse.Content.ReadFromJsonAsync<TokenResponse>();
Console.WriteLine($"Access token: {tokenResult?.AccessToken[..30]}...");
Console.WriteLine($"Token type: {tokenResult?.TokenType ?? "Bearer"}");
// ── Крок 2: Використовувати токен ────────────────────────────────────────────
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", tokenResult?.AccessToken);
// GET /auth/me — захищений endpoint, повертає поточного користувача
HttpResponseMessage profileResponse = await client.GetAsync("auth/me");
profileResponse.EnsureSuccessStatusCode();
var profile = await profileResponse.Content.ReadFromJsonAsync<UserProfile>();
Console.WriteLine($"Hello, {profile?.FirstName} {profile?.LastName}!");
Console.WriteLine($"Email: {profile?.Email}, Role: {profile?.Role}");
// ── Крок 3: Оновити токен через Refresh Token ─────────────────────────────────
if (tokenResult?.RefreshToken is not null)
{
var refreshBody = new { refreshToken = tokenResult.RefreshToken, expiresInMins = 30 };
var refreshResp = await client.PostAsJsonAsync("auth/refresh", refreshBody);
if (refreshResp.IsSuccessStatusCode)
{
var newTokens = await refreshResp.Content.ReadFromJsonAsync<TokenResponse>();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", newTokens?.AccessToken);
Console.WriteLine("Токен успішно оновлено.");
}
}
// ── Моделі ───────────────────────────────────────────────────────────────────
record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn = 1800, string? TokenType = null);
record UserProfile(int Id, string FirstName, string LastName, string Email, string? Role);
OAuth 2.0: делегована авторизація
OAuth 2.0 (RFC 6749) — це не схема аутентифікації, а фреймворк делегованої авторизації. Він відповідає на питання: «Як дозволити стороньому застосунку діяти від імені користувача, не передаючи йому пароль?»
OAuth 2.0 визначає чотири grant type (варіанти отримання токену), кожен для свого сценарію:
| Grant Type | Сценарій | Участь користувача |
|---|---|---|
| Authorization Code + PKCE | Вебзастосунки, SPA, мобільні | Так — логін на auth server |
| Client Credentials | Server-to-server (машина-машина) | Ні |
| Device Authorization | TV, CLI без браузера | Так — з іншого пристрою |
| Refresh Token | Оновлення access token | Ні |
Authorization Code Flow + PKCE
PKCE (Proof Key for Code Exchange, RFC 7636) — захист від перехоплення authorization code. Навіть якщо зловмисник отримає code (через redirect URI), він не зможе обміняти його на токен без code_verifier, який знає лише оригінальний застосунок.
Client Credentials Flow
// Server-to-server автентифікація без участі користувача.
// Замініть URL на ваш OAuth-сервер:
// Auth0: https://YOUR-DOMAIN.auth0.com/oauth/token
// Keycloak: https://YOUR-KEYCLOAK/realms/REALM/protocol/openid-connect/token
// Azure AD: https://login.microsoftonline.com/TENANT-ID/oauth2/v2.0/token
using var client = new HttpClient();
var tokenRequest = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", "my-service-id"),
new KeyValuePair<string, string>("client_secret", "my-service-secret"),
new KeyValuePair<string, string>("scope", "read:analytics write:reports"),
});
// httpbingo.org/post — відображає тіло запиту (демонстрація формату без реального OAuth-сервера)
// У реальному проєкті: замінити на URL вашого OAuth-сервера
HttpResponseMessage echoResponse = await client.PostAsync(
"https://httpbingo.org/post",
tokenRequest
);
echoResponse.EnsureSuccessStatusCode();
Console.WriteLine("Запит відправлено. Формат: application/x-www-form-urlencoded");
Console.WriteLine("Параметри: grant_type=client_credentials, scope=read:analytics");
// Реальний OAuth-сервер поверне:
// {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600,"scope":"read:analytics"}
// var token = await tokenResponse.Content.ReadFromJsonAsync<OAuthToken>();
// client.DefaultRequestHeaders.Authorization =
// new AuthenticationHeaderValue("Bearer", token?.AccessToken);
// var data = await client.GetFromJsonAsync<object>("https://your-api.com/analytics/summary");
record OAuthToken(
string AccessToken,
string TokenType,
int ExpiresIn,
string? Scope
);
Device Authorization Flow
Для пристроїв без браузера (Smart TV, CLI-інструменти, IoT):
Refresh Token Rotation
Стратегія безпеки: кожен refresh видає новий refresh token і інвалідує старий. При виявленні повторного використання старого refresh token — вся сесія блокується (ознака компрометації).
1. Client → POST /token (refresh_token=RT1)
2. Server → access_token=AT2, refresh_token=RT2 (RT1 інвалідовано)
Якщо зловмисник перехопив RT1:
3. Evil → POST /token (refresh_token=RT1)
4. Server: RT1 вже використано! → Блокувати всю сесію користувача
HTTPS та TLS: шифрування транспорту
Навіщо HTTPS
Без шифрування будь-який вузол між клієнтом і сервером (провайдер, публічний Wi-Fi, проксі) може читати, підмінювати та вставляти довільний контент у передані дані.
HTTPS = HTTP over TLS. TLS (Transport Layer Security) забезпечує три властивості:
| Властивість | Механізм | Що захищає |
|---|---|---|
| Конфіденційність | Симетричне шифрування (AES-256-GCM) | Ніхто не читає трафік |
| Цілісність | MAC (Message Authentication Code) | Ніхто не підміняє дані |
| Автентичність | X.509 сертифікати | Клієнт спілкується з правильним сервером |
X.509 Сертифікат: анатомія
Сертифікат — це підписаний цифровий документ, що прив'язує публічний ключ до ідентифікатора (домену).
Certificate:
Version: 3
Serial: 0A:B4:C2:...
Algorithm: SHA256withRSA
Issuer: C=US, O=Let's Encrypt, CN=R10
Validity:
Not Before: 2025-01-01 00:00:00
Not After: 2025-04-01 00:00:00 ← 90 днів для Let's Encrypt
Subject: CN=api.example.com
Subject Alt Names (SAN):
DNS: api.example.com
DNS: *.example.com
Public Key: RSA 2048 bits (або EC P-256)
Extensions:
Key Usage: Digital Signature, Key Encipherment
Extended Key Usage: TLS Web Server Authentication
OCSP URL: http://ocsp.pki.goog/
CRL URL: http://crl.pki.goog/
Signature: [підпис Issuer приватним ключем]
SAN (Subject Alternative Names) — саме цей розділ перевіряє браузер при порівнянні з доменом. CN (Common Name) — застаріла перевірка, SAN — сучасний стандарт.
Рівні довіри сертифікатів
| Тип | Перевірка CA | Видається за | Для чого |
|---|---|---|---|
| DV (Domain Validated) | Лише домен | ~хвилини (ACME) | Більшість сайтів |
| OV (Organization Validated) | Домен + організація | ~дні | Корпоративні сайти |
| EV (Extended Validation) | Детальна перевірка юр. особи | ~тижні | Банки, платіжні |
Ланцюжок довіри
Браузер не знає сертифікат кожного сайту. Замість цього він довіряє кільком Root Certificate Authorities (CA), чиї сертифікати вбудовані в ОС та браузер. Сертифікат сервера підписаний ланцюжком до одного з цих довірених Root CA.
Revocation (відкликання) — якщо приватний ключ скомпрометовано, сертифікат відкликається:
- CRL (Certificate Revocation List) — список відкликаних серійних номерів (великий файл, рідко оновлюється)
- OCSP (Online Certificate Status Protocol) — запит статусу конкретного сертифікату в реальному часі
- OCSP Stapling — сервер заздалегідь отримує OCSP відповідь і вкладає її у TLS Handshake (клієнту не потрібно робити окремий запит)
TLS 1.3 Handshake крок за кроком
TLS 1.3 vs TLS 1.2: ключові відмінності
| Аспект | TLS 1.2 | TLS 1.3 |
|---|---|---|
| RTT для handshake | 2 RTT | 1 RTT |
| 0-RTT відновлення | Немає | Є (ризик replay) |
| Cipher suites | Багато (включно із слабкими) | 5 сильних, тільки AEAD |
| Forward Secrecy | Опційна (DHE/ECDHE) | Обов'язкова (завжди ECDHE) |
| RSA key exchange | Підтримується | Видалено |
| Шифрування handshake | Certificate в відкритому вигляді | Certificate зашифровано |
HSTS: HTTP Strict Transport Security
HSTS (RFC 6797) — заголовок відповіді, що наказує браузеру завжди використовувати HTTPS для цього домену протягом зазначеного часу, навіть якщо користувач вводить http://.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| Директива | Значення |
|---|---|
max-age=N | Кешувати N секунд (рекомендовано ≥ 1 рік = 31536000) |
includeSubDomains | Застосовувати до всіх піддоменів |
preload | Дозволити внесення у HSTS Preload List |
HSTS Preload List — список доменів, вбудований у Chrome, Firefox, Safari. Браузер знає про HTTPS до першого з'єднання, унеможливлюючи SSLstrip-атаки. Для внесення — hstspreload.org.
Certificate Transparency
CT (RFC 6962) — публічний реєстр всіх виданих TLS-сертифікатів. Кожен CA зобов'язаний записувати видані сертифікати у публічні CT Logs (Merkle tree). Браузери перевіряють наявність SCT (Signed Certificate Timestamp) у сертифікаті.
Навіщо: якщо CA випустив підроблений сертифікат для google.com, це буде публічно видно у CT Logs протягом хвилин. Без CT — підроблений сертифікат міг би існувати роками без виявлення.
HTTPS у .NET: налаштування HttpClient
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
// ── Стандартне HTTPS — без додаткового налаштування ──────────────────────────
// HttpClient автоматично перевіряє сертифікат та використовує TLS 1.2/1.3
using var defaultClient = new HttpClient();
var response = await defaultClient.GetAsync("https://httpbingo.org/get");
// ── Явно вказати дозволені версії TLS ────────────────────────────────────────
var tlsHandler = new HttpClientHandler();
tlsHandler.SslProtocols = System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13;
using var tlsClient = new HttpClient(tlsHandler);
// ── Кастомна перевірка сертифікату ───────────────────────────────────────────
// ⚠ ТІЛЬКИ ДЛЯ РОЗРОБКИ — ніколи не відключайте перевірку у production!
var devHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
if (errors == SslPolicyErrors.None) return true;
// Приймаємо self-signed тільки для localhost
return message.RequestUri?.Host is "localhost" or "127.0.0.1";
}
};
using var devClient = new HttpClient(devHandler);
// ── Клієнтський сертифікат (mTLS — двостороння автентифікація) ────────────────
var mtlsHandler = new HttpClientHandler();
var clientCert = X509Certificate2.CreateFromPemFile("client.crt", "client.key");
mtlsHandler.ClientCertificates.Add(clientCert);
using var mtlsClient = new HttpClient(mtlsHandler);
// ── Отримати інформацію про сертифікат сервера ─────────────────────────────────
var infoHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
if (cert is not null)
{
Console.WriteLine($"Subject: {cert.Subject}");
Console.WriteLine($"Issuer: {cert.Issuer}");
Console.WriteLine($"Valid to: {cert.GetExpirationDateString()}");
Console.WriteLine($"Thumbprint: {cert.GetCertHashString()}");
}
return errors == SslPolicyErrors.None;
}
};
using var infoClient = new HttpClient(infoHandler);
await infoClient.GetAsync("https://httpbingo.org/");
HTTP Security Headers
Окрім TLS, сервери повинні надсилати набір security response headers, що захищають клієнтів від різних атак. HttpClient ці заголовки не додає — їх встановлює сервер. Але розробнику важливо розуміти, що вони означають.
Ключові заголовки безпеки
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Примушує браузер використовувати HTTPS протягом max-age секунд. Захищає від SSLstrip і downgrade атак.
Content-Security-Policy:
default-src 'self';
script-src 'self' cdn.example.com;
img-src 'self' data: images.example.com;
connect-src 'self' api.example.com;
frame-ancestors 'none';
upgrade-insecure-requests
Визначає, з яких джерел браузер може завантажувати ресурси. Найефективніший захист від XSS — навіть при наявності вразливості, injected script не виконається (порушить CSP). frame-ancestors 'none' замінює застарілий X-Frame-Options.
X-Frame-Options: DENY
# або
X-Frame-Options: SAMEORIGIN
Забороняє вставляти сторінку у <iframe>, захищаючи від Clickjacking — атаки, де зловмисник накладає прозорий iframe поверх свого контенту і змушує клік на «Отримати приз» насправді натиснути «Підтвердити транзакцію». Застаріло на користь Content-Security-Policy: frame-ancestors.
X-Content-Type-Options: nosniff
Забороняє браузеру MIME sniffing — автоматичне визначення типу контенту, ігноруючи Content-Type. Захист: якщо сервер надіслав файл як text/plain, браузер не виконає його як JavaScript навіть якщо вміст схожий на скрипт.
Referrer-Policy: strict-origin-when-cross-origin
Контролює, скільки інформації браузер надсилає у заголовку Referer при переходах між сторінками. strict-origin-when-cross-origin — надсилає лише origin (без шляху/параметрів) при cross-origin переходах.
Permissions-Policy: camera=(), microphone=(), geolocation=(self)
Обмежує доступ до браузерних API (камера, мікрофон, геолокація). () = заборонено повністю. Захищає від зловживань третіх сторін у embed-контенті.
Читання security headers у C#
using System.Net.Http;
using var client = new HttpClient();
// mozilla.org — сайт з повним набором security headers (HSTS, CSP, X-Frame-Options тощо)
HttpResponseMessage response = await client.GetAsync("https://www.mozilla.org/");
// Перевірити наявність security headers
var securityHeaders = new[]
{
"Strict-Transport-Security",
"Content-Security-Policy",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy"
};
Console.WriteLine("Security Headers Audit:");
foreach (string header in securityHeaders)
{
bool present = response.Headers.Contains(header)
|| response.Content.Headers.Contains(header);
string status = present ? "✅" : "❌";
string value = present
? response.Headers.TryGetValues(header, out var vals) ? string.Join(", ", vals) : "(in content headers)"
: "ВІДСУТНІЙ";
Console.WriteLine($"{status} {header}: {value}");
}
HTTP-кешування
Навіщо кешування
HTTP-кешування — один з найпотужніших механізмів оптимізації продуктивності. Правильно налаштоване кешування дозволяє зменшити навантаження на сервер у рази, прискорити відповідь для користувача та заощадити трафік.
Ієрархія кешів
Cache-Control: директиви відповіді та запиту
Cache-Control використовується як у відповідях (сервер → кеш → клієнт), так і у запитах (клієнт → кеш).
Директиви відповіді
| Директива | Значення |
|---|---|
public | Можна кешувати у будь-якому кеші (CDN, проксі, браузер) |
private | Тільки у приватному кеші (браузер конкретного користувача) |
no-cache | Перевіряти актуальність перед використанням (conditional GET) |
no-store | Взагалі не зберігати у кеші (паролі, банківські дані) |
max-age=N | Кешувати N секунд без перевірки |
s-maxage=N | Для CDN: кешувати N секунд (ігнорує max-age) |
must-revalidate | Після закінчення max-age — обов'язково перевірити на сервері |
immutable | Ресурс ніколи не зміниться (статичні файли з хешем у URL) |
stale-while-revalidate=N | Поки оновлює у фоні — видавати застарілий кеш ще N секунд |
stale-if-error=N | При помилці сервера — видавати застарілий кеш ще N секунд |
Директиви запиту (клієнт управляє кешем)
| Директива | Значення |
|---|---|
no-cache | Не використовувати кешовану відповідь без перевірки |
no-store | Не зберігати цей запит/відповідь |
max-age=0 | Отримати свіжу відповідь (аналог no-cache) |
max-stale=N | Прийняти застарілу відповідь, якщо вона не старша N секунд |
only-if-cached | Повернути тільки кешовану відповідь, не йти на сервер |
stale-while-revalidate: фоновий refresh
Cache-Control: max-age=60, stale-while-revalidate=30
- Перші 60 сек: свіжий кеш, відповідає миттєво
- 60–90 сек: кеш "протух", але повертається одразу + у фоні оновлюється
- Після 90 сек: чекати відповіді сервера
Ідеальний баланс між актуальністю та latency для API з не-критичними даними.
Cache Busting для статичних ресурсів
<!-- BAD: старий URL, може кешуватись нескінченно -->
<script src="/app.js"></script>
<!-- GOOD: хеш вмісту у URL → при зміні файлу URL змінюється -->
<script src="/app.a1b2c3d4.js"></script>
Cache-Control: public, max-age=31536000, immutable
Хеш у URL + immutable = агресивне кешування на рік без перевірок. CDN кешує максимально. При деплої новий хеш = новий URL = примусове завантаження.
Conditional GET: ETag та Last-Modified
Механізм, що дозволяє клієнту перевірити актуальність кешованого ресурсу без його повного завантаження.
ETag (Entity Tag)
ETag — довільний ідентифікатор версії ресурсу (хеш вмісту, версія, timestamp). Сервер генерує; клієнт надсилає назад через If-None-Match.
Last-Modified та If-Modified-Since
Альтернатива ETag на основі часу останньої модифікації:
GET /api/products HTTP/1.1
→
HTTP/1.1 200 OK
Last-Modified: Thu, 22 May 2025 10:00:00 GMT
Cache-Control: max-age=60
Content-Length: 51200
[50KB body]
GET /api/products HTTP/1.1
If-Modified-Since: Thu, 22 May 2025 10:00:00 GMT
→
HTTP/1.1 304 Not Modified
Last-Modified: Thu, 22 May 2025 10:00:00 GMT
(без тіла!)
ETag vs Last-Modified:
- ETag точніший (змінюється тільки при зміні вмісту, не лише часу)
- ETag підтримує паралельні версії (
W/"weak"для приблизного порівняння) - Last-Modified простіший, але секундна точність може не вистачати
- Рекомендація: надсилати обидва заголовки
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using var client = new HttpClient { BaseAddress = new Uri("https://httpbingo.org/") };
string? cachedETag = null;
DateTimeOffset? cachedLastModified = null;
string? cachedContent = null;
async Task<string?> GetProductsAsync()
{
// httpbingo.org/etag/{etag} — повертає ETag і відповідає 304 при If-None-Match
var request = new HttpRequestMessage(HttpMethod.Get, "etag/v5-abc123");
// Conditional GET: ETag
if (cachedETag is not null)
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue($"\"{cachedETag}\""));
// Conditional GET: Last-Modified
if (cachedLastModified.HasValue)
request.Headers.IfModifiedSince = cachedLastModified.Value;
HttpResponseMessage response = await client.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotModified)
{
Console.WriteLine("📦 304 Not Modified — 0 байт завантажено");
return cachedContent;
}
response.EnsureSuccessStatusCode();
// Зберігаємо валідатори для наступного запиту
if (response.Headers.ETag is not null)
cachedETag = response.Headers.ETag.Tag.Trim('"');
if (response.Content.Headers.LastModified.HasValue)
cachedLastModified = response.Content.Headers.LastModified;
cachedContent = await response.Content.ReadAsStringAsync();
Console.WriteLine($"🔄 200 OK — {cachedContent.Length} байт завантажено");
return cachedContent;
}
var first = await GetProductsAsync(); // 200 OK — 50000 байт
var second = await GetProductsAsync(); // 304 Not Modified — 0 байт
Content Negotiation та Compression
Content Negotiation
Механізм, що дозволяє клієнту та серверу погодитись про формат представлення ресурсу через заголовки сімейства Accept-*.
Q-values: вагові коефіцієнти
Кожна опція у Accept заголовку може мати q-value від 0 до 1.0 (за замовчуванням 1.0). Сервер обирає формат з найвищим q-value серед доступних.
Accept: application/json;q=1.0, application/xml;q=0.8, text/plain;q=0.5, */*;q=0.1
Розшифрування: «Найбільш бажаний — JSON (1.0), потім XML (0.8), потім text (0.5), будь-що інше (0.1)».
Повний набір Accept-* заголовків
| Заголовок | Що узгоджує | Приклад |
|---|---|---|
Accept | Формат тіла відповіді | application/json, application/xml |
Accept-Language | Мова відповіді | uk;q=1.0, en;q=0.8 |
Accept-Encoding | Алгоритм стиснення | br, gzip, deflate |
Accept-Charset | Кодування (застарілий) | utf-8, iso-8859-1 |
GET /api/users/42 HTTP/1.1
Accept: application/json;q=1.0, application/xml;q=0.8, text/plain;q=0.5
Accept-Language: uk;q=1.0, en;q=0.8
Accept-Encoding: br, gzip, deflate
Відповідь сервера:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: uk
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding
406 Not Acceptable
Якщо сервер не може задовольнити жоден з Accept форматів:
HTTP/1.1 406 Not Acceptable
Content-Type: application/problem+json
{
"title": "Not Acceptable",
"detail": "Сервер підтримує тільки application/json і application/xml"
}
Vary: Критично важливий для кешів. Він вказує, від яких заголовків запиту залежить представлення ресурсу. Vary: Accept означає, що кеш повинен зберігати окремі копії для JSON і XML клієнтів. Без Vary кеш може повернути XML клієнту, що просив JSON.using System.Net.Http;
using System.Net.Http.Headers;
using var client = new HttpClient { BaseAddress = new Uri("https://httpbingo.org/") };
// Явно вказати бажані формати з пріоритетами
// httpbingo.org/get — відображає заголовки запиту (для перевірки content negotiation)
var request = new HttpRequestMessage(HttpMethod.Get, "get");
request.Headers.Accept.Clear();
request.Headers.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json") { Quality = 1.0 }
);
request.Headers.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/xml") { Quality = 0.8 }
);
// Мова
request.Headers.AcceptLanguage.Add(
new StringWithQualityHeaderValue("uk") { Quality = 1.0 }
);
request.Headers.AcceptLanguage.Add(
new StringWithQualityHeaderValue("en") { Quality = 0.8 }
);
HttpResponseMessage response = await client.SendAsync(request);
Console.WriteLine($"Content-Type: {response.Content.Headers.ContentType}");
Console.WriteLine($"Content-Language: {response.Headers.GetValues("Content-Language").FirstOrDefault()}");
Console.WriteLine($"Content-Encoding: {response.Content.Headers.ContentEncoding.FirstOrDefault()}");
Стиснення відповіді
using System.IO.Compression;
using System.Net;
using System.Net.Http;
// HttpClientHandler підтримує автоматичну деcompression
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip
| DecompressionMethods.Deflate
| DecompressionMethods.Brotli
};
// Автоматично додає: Accept-Encoding: gzip, deflate, br
// Автоматично розпаковує відповідь — прозоро для коду
using var client = new HttpClient(handler);
// httpbingo.org/gzip — завжди повертає gzip-стиснений JSON
HttpResponseMessage response = await client.GetAsync("https://httpbingo.org/gzip");
Console.WriteLine($"Content-Length: {response.Content.Headers.ContentLength}"); // може бути null при chunked
string content = await response.Content.ReadAsStringAsync(); // вже розпаковано
Console.WriteLine($"Розпакований розмір: {content.Length} символів");
Порівняння алгоритмів стиснення:
| Алгоритм | Ступінь стиснення | Швидкість розпакування | Підтримка браузерами |
|---|---|---|---|
| Gzip | Середній | Висока | 100% |
| Deflate | Середній | Висока | 100% (але проблеми) |
| Brotli (br) | Найкращий (+15-25% vs gzip) | Висока | 95%+ |
CORS: Cross-Origin Resource Sharing
Проблема: Same-Origin Policy
Браузери реалізують Same-Origin Policy (SOP) — правило безпеки, що забороняє JavaScript одного джерела робити запити до іншого джерела без явного дозволу сервера.
Origin = схема + хост + порт. Усі три компоненти мають співпадати.
| URL запиту | Origin сторінки | Результат | Причина |
|---|---|---|---|
https://api.example.com/data | https://app.example.com | ❌ Cross-origin | Різні хости |
https://api.example.com/data | http://api.example.com | ❌ Cross-origin | Різна схема |
https://api.example.com:8080/data | https://api.example.com | ❌ Cross-origin | Різний порт |
https://api.example.com/users | https://api.example.com | ✅ Same-origin | Все співпадає |
Навіщо SOP? Без нього будь-який сайт міг би виконати fetch("https://bank.com/account") з cookies жертви і прочитати баланс. SOP не захищає від CSRF (браузер все одно надсилає запит), але захищає від читання відповіді.
Простий vs Preflight запит
Simple Request — не вимагає preflight, якщо:
- Метод:
GET,HEAD,POST Content-Type:text/plain,application/x-www-form-urlencoded,multipart/form-data- Немає кастомних заголовків
Preflighted Request — вимагає попереднього OPTIONS запиту, якщо:
- Метод:
PUT,DELETE,PATCH Content-Type:application/json- Будь-який кастомний заголовок (
Authorization,X-Request-Id)
Credentialed запити: cookies та CORS
За замовчуванням cross-origin запити не включають cookies. Для цього потрібен explicit opt-in з обох сторін:
Клієнт (JavaScript):
fetch('https://api.example.com/me', {
credentials: 'include', // відправити cookies та Authorization
})
Сервер (відповідь):
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Credentials: trueзаборонено використовувати Access-Control-Allow-Origin: *. Обов'язково вказувати конкретний origin. Інакше браузер заблокує відповідь навіть при отриманні *.CORS-заголовки відповіді
_— будь-який (несумісно з credentials). Для конкретних:https://app.example.com. Для кількох доменів — сервер динамічно перевіряє Origin запиту і відповідає конкретним.GET, POST, PUT, DELETE, PATCH, OPTIONS.Authorization, Content-Type, X-Request-Id.true — дозволити cookies та Authorization. При trueAllow-Originне може бути *.86400 = 24 год. Зменшує кількість OPTIONS запитів.X-Total-Count, X-Request-Id, Link.Типові помилки CORS та їх усунення
Проблема: Сервер надсилає Access-Control-Allow-Origin: *, але клієнт відправляє credentials (cookies/Authorization).
Симптом: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
Рішення: Сервер повинен читати Origin заголовок запиту і повертати конкретний origin + Access-Control-Allow-Credentials: true.
Проблема: Сервер не обробляє OPTIONS запити — повертає 404 або 405.
Симптом: Preflight fail — основний запит ніколи не надсилається.
Рішення: Додати обробку OPTIONS на всіх CORS-endpoints або через глобальний middleware.
Проблема: Після зміни CORS конфігурації на сервері браузер використовує старий кешований preflight.
Рішення: В DevTools Network → відключити кеш (Disable cache). Або дочекатися Access-Control-Max-Age секунд.
Проблема: CORS заголовки додані в застосунку, але CDN не передає Vary: Origin і повертає кешовану відповідь з неправильним Allow-Origin.
Рішення: CDN повинен кешувати окремі копії для кожного дозволеного origin. Додати Vary: Origin у відповідь сервера.
CORS у .NET HttpClient
HttpClient у .NET — не браузер і не дотримується CORS. Обмеження CORS діють тільки на JavaScript у браузері.using System.Net.Http;
// HttpClient не має ніяких CORS обмежень — це суто браузерна функція
using var client = new HttpClient();
// httpbingo.org підтримує CORS — повертає Access-Control-Allow-Origin
// Емуляція cross-origin запиту з Origin заголовком
var request = new HttpRequestMessage(HttpMethod.Get, "https://httpbingo.org/get");
request.Headers.Add("Origin", "https://app.other-domain.com");
HttpResponseMessage response = await client.SendAsync(request);
// Переглянути CORS заголовки відповіді
if (response.Headers.TryGetValues("Access-Control-Allow-Origin", out var origins))
Console.WriteLine($"Allowed Origin: {string.Join(", ", origins)}");
if (response.Headers.TryGetValues("Access-Control-Allow-Methods", out var methods))
Console.WriteLine($"Allowed Methods: {string.Join(", ", methods)}");
// Перевірка preflight вручну
var preflight = new HttpRequestMessage(HttpMethod.Options, "https://httpbingo.org/get");
preflight.Headers.Add("Origin", "https://app.other-domain.com");
preflight.Headers.Add("Access-Control-Request-Method", "PUT");
preflight.Headers.Add("Access-Control-Request-Headers", "Authorization, Content-Type");
HttpResponseMessage preflightResp = await client.SendAsync(preflight);
Console.WriteLine($"Preflight: {preflightResp.StatusCode}");
if (preflightResp.Headers.TryGetValues("Access-Control-Max-Age", out var maxAge))
Console.WriteLine($"Preflight кешується: {string.Join(", ", maxAge)} секунд");
Redirects: деталі та підводні камені
Типи редиректів та поведінка при POST
| Код | Постійний | Зберігає метод | Типове застосування |
|---|---|---|---|
| 301 | Так | Ні (POST→GET у старих) | Зміна домену, URL реструктуризація |
| 302 | Ні | Ні (POST→GET у старих) | Тимчасова заміна (не рекомендується) |
| 303 | Ні | Завжди GET | POST/Redirect/GET паттерн |
| 307 | Ні | Так | Тимчасовий редирект із збереженням методу |
| 308 | Так | Так | Постійний редирект із збереженням методу |
Post/Redirect/Get (PRG) pattern: після успішного POST відповідати 303 See Other + Location: /success. Браузер перейде GET на сторінку успіху. При натисканні «Назад/Оновити» браузер не повторить POST.
Open Redirect вразливість
https://bank.com/redirect?to=https://evil.com/phishing
→ 302 Location: https://evil.com/phishing
bank.com (довірений домен), яке перенаправляє на фішинговий сайт. Жертва бачить у рядку банківський домен і не підозрює.Захист: Ніколи не використовувати параметри запиту як URL для редиректу. Якщо redirect потрібен — перевіряти, що target URL є allowlisted відносним шляхом.using System.Net;
using System.Net.Http;
// HttpClient за замовчуванням автоматично слідує редиректам (до 50)
using var autoClient = new HttpClient();
// AllowAutoRedirect = true за замовчуванням
// Вимкнути автоматичні редиректи:
var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var manualClient = new HttpClient(handler);
HttpResponseMessage response = await manualClient.GetAsync("https://httpbingo.org/absolute-redirect/2");
while (response.StatusCode is HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect)
{
Uri? newLocation = response.Headers.Location;
if (newLocation is null) break;
// Якщо URL відносний — перетворимо на абсолютний
if (!newLocation.IsAbsoluteUri)
newLocation = new Uri(new Uri("https://httpbingo.org"), newLocation);
// Валідація: чи це очікуваний домен?
if (newLocation.Host != "httpbingo.org")
{
Console.WriteLine($"⚠ Підозрілий редирект на: {newLocation}");
break;
}
Console.WriteLine($"→ Редирект {(int)response.StatusCode}: {newLocation}");
var method = response.StatusCode == HttpStatusCode.SeeOther
? HttpMethod.Get // 303 завжди GET
: HttpMethod.Get; // спрощено; реально — зберегти оригінальний
response = await manualClient.GetAsync(newLocation);
}
Console.WriteLine($"Фінальна відповідь: {response.StatusCode}");
Практичний проєкт від A до Z: Auth-aware HTTP Client
Побудуємо повноцінний HTTP-клієнт з підтримкою JWT-аутентифікації, автоматичного оновлення токену та повторних запитів.
Архітектура
Реалізація TokenStorage
// Storage/TokenStorage.cs
namespace AuthClient.Storage;
public sealed class TokenStorage
{
private string? _accessToken;
private string? _refreshToken;
private DateTime _expiresAt;
// 30-секундний буфер щоб не надсилати майже-протухлий токен
public bool IsExpired =>
_accessToken is null || DateTime.UtcNow >= _expiresAt.AddSeconds(-30);
public string? AccessToken => _accessToken;
public string? RefreshToken => _refreshToken;
public void Store(string access, string refresh, int expiresInSeconds)
{
_accessToken = access;
_refreshToken = refresh;
_expiresAt = DateTime.UtcNow.AddSeconds(expiresInSeconds);
}
public void Clear()
{
_accessToken = null;
_refreshToken = null;
_expiresAt = default;
}
}
Реалізація AuthHandler
// Handlers/AuthHandler.cs
namespace AuthClient.Handlers;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using AuthClient.Storage;
public sealed class AuthHandler(TokenStorage storage, IHttpClientFactory factory)
: DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken ct)
{
// Пропускаємо login/refresh — вони не потребують Bearer токену
string? path = request.RequestUri?.AbsolutePath;
bool isPublicEndpoint = path?.EndsWith("/login") == true
|| path?.EndsWith("/refresh") == true;
if (isPublicEndpoint)
return await base.SendAsync(request, ct);
// Токен протух — оновлюємо ПЕРЕД відправкою запиту
if (storage.IsExpired && storage.RefreshToken is not null)
await RefreshTokenAsync(ct);
// Прикріплюємо актуальний токен
if (storage.AccessToken is not null)
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", storage.AccessToken);
HttpResponseMessage response = await base.SendAsync(request, ct);
// 401 — сервер відхилив токен → спробувати refresh і повторити
if (response.StatusCode == HttpStatusCode.Unauthorized
&& storage.RefreshToken is not null)
{
bool refreshed = await RefreshTokenAsync(ct);
if (refreshed)
{
var retryRequest = await CloneRequestAsync(request);
retryRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", storage.AccessToken!);
response = await base.SendAsync(retryRequest, ct);
}
}
// Якщо знову 401 — всі токени недійсні
if (response.StatusCode == HttpStatusCode.Unauthorized)
storage.Clear();
return response;
}
private async Task<bool> RefreshTokenAsync(CancellationToken ct)
{
using var refreshClient = factory.CreateClient("Auth");
var body = new { refreshToken = storage.RefreshToken, expiresInMins = 30 };
HttpResponseMessage response = await refreshClient.PostAsJsonAsync("auth/refresh", body, ct);
if (!response.IsSuccessStatusCode)
{
storage.Clear();
return false;
}
var result = await response.Content.ReadFromJsonAsync<TokenResponse>(ct);
if (result is null) return false;
// ExpiresIn не повертається dummyjson, використовуємо стандарт 1800с
storage.Store(result.AccessToken, result.RefreshToken, result.ExpiresIn > 0 ? result.ExpiresIn : 1800);
return true;
}
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage original)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
clone.Version = original.Version;
foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
if (original.Content is not null)
{
byte[] content = await original.Content.ReadAsByteArrayAsync();
clone.Content = new ByteArrayContent(content);
foreach (var header in original.Content.Headers)
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
}
record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn = 1800);
Program.cs — збірка та демонстрація
// Program.cs
using Microsoft.Extensions.DependencyInjection;
using AuthClient.Handlers;
using AuthClient.Storage;
using System.Net.Http.Json;
var services = new ServiceCollection();
var tokenStorage = new TokenStorage();
services.AddSingleton(tokenStorage);
services.AddTransient<AuthHandler>();
// "Auth" клієнт — для auth-запитів (без AuthHandler!)
services.AddHttpClient("Auth", c =>
c.BaseAddress = new Uri("https://dummyjson.com/"));
// "Api" клієнт — з AuthHandler у pipeline
services.AddHttpClient("Api", c =>
c.BaseAddress = new Uri("https://dummyjson.com/"))
.AddHttpMessageHandler<AuthHandler>();
var sp = services.BuildServiceProvider();
var factory = sp.GetRequiredService<IHttpClientFactory>();
// ── Логін ────────────────────────────────────────────────────────────────────
var authClient = factory.CreateClient("Auth");
// dummyjson.com: POST /auth/login {username, password}
var loginBody = new { username = "emilys", password = "emilyspass", expiresInMins = 30 };
var loginResp = await authClient.PostAsJsonAsync("auth/login", loginBody);
if (loginResp.IsSuccessStatusCode)
{
var tokens = await loginResp.Content.ReadFromJsonAsync<TokenResponse>();
int expiresIn = tokens!.ExpiresIn > 0 ? tokens.ExpiresIn : 1800;
tokenStorage.Store(tokens!.AccessToken, tokens.RefreshToken, expiresIn);
Console.WriteLine("✅ Аутентифікація успішна");
}
// ── Захищені запити — Bearer token додається автоматично ──────────────────────
var apiClient = factory.CreateClient("Api");
// GET /auth/me — профіль поточного користувача (dummyjson перевіряє Bearer)
var profile = await apiClient.GetAsync("auth/me");
Console.WriteLine($"Profile: {profile.StatusCode}");
// GET /auth/carts — кошики поточного користувача (dummyjson protected endpoint)
var carts = await apiClient.GetAsync("auth/carts");
Console.WriteLine($"Carts: {carts.StatusCode}");
record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn = 1800);
Практика та закріплення
HTTP Advanced охоплює механізми, що є основою реальних вебзастосунків.
Рівень 1. Базове розуміння
- Поясніть різницю між session cookie та persistent cookie. Коли слід використовувати кожен тип? Наведіть практичні сценарії.
- Що таке CSRF-атака і як атрибут
SameSite=Laxзахищає від неї? ЧомуSameSite=Noneнебезпечний безSecure? - Чому
__Host-prefix безпечніший за звичайний session cookie? Яку атаку він запобігає? - Поясніть різницю між
401 Unauthorizedта403 Forbiddenу контексті аутентифікації та авторизації. Наведіть сценарій для кожного. - Чому JWT не слід зберігати у
localStorage? Де правильно зберігати access token та refresh token в SPA? - Які три властивості забезпечує TLS? Чи може TLS захистити від підміни даних після того, як вони дійшли до сервера?
- Поясніть, чому HSTS захищає від SSLstrip навіть якщо сервер вже підтримує HTTPS.
- Що таке Certificate Transparency і чому вона важлива для безпеки?
Рівень 2. Практична реалізація
- Реалізуйте
CookieAuthHandler : DelegatingHandler, що:- При першому запиті перевіряє наявність збереженого
__Host-sessioncookie - Якщо відсутній — виконує логін і зберігає session ID
- Додає
Cookie: __Host-session=...до кожного наступного запиту - При отриманні
401очищає cookie і повторює логін один раз
- При першому запиті перевіряє наявність збереженого
- Напишіть консольний застосунок, що демонструє conditional GET:
- Перший
GET /api/products→ зберегти ETag та Last-Modified - Другий запит з
If-None-MatchтаIf-Modified-Since - Порівняти кількість завантажених байт між двома запитами
- Перший
- Реалізуйте CORS-аудитор як консольну утиліту:
- Приймає origin URL та цільовий API URL
- Надсилає
OPTIONSpreflight - Виводить дозволені методи, заголовки, credentials, max-age
- Перевіряє наявність security headers (HSTS, CSP, X-Content-Type-Options)
- Реалізуйте
SecurityHeadersAuditor, що аналізує сайт на наявність:- HSTS з
preload - CSP з директивою
script-src 'self' X-Content-Type-Options: nosniffReferrer-Policy- Виводить оцінку безпеки від A до F
- HSTS з
Рівень 3. Архітектурне мислення
- Спроектуйте
TokenManagerз підтримкою:- Concurrent refresh protection: якщо кілька потоків одночасно виявили протухлий токен — refresh відбувається лише один раз (
SemaphoreSlim) - Exponential backoff при невдалих спробах refresh (1s, 2s, 4s, max 30s)
- Secure storage: токени не зберігаються у plaintext
- Concurrent refresh protection: якщо кілька потоків одночасно виявили протухлий токен — refresh відбувається лише один раз (
- Проаналізуйте безпеку підходів до зберігання JWT:
localStorage— які атаки можливі?sessionStorage— чим відрізняється від localStorage?HttpOnly Cookie— які атаки можливі? Як поєднати з CSRF-захистом?- Memory-only — як відновити після reload?
- Спроектуйте систему відкликання JWT токенів для API з:
- Short-lived access tokens (15 хв) + long-lived refresh tokens (30 днів)
- Refresh token rotation при кожному оновленні
- Виявлення компрометованих refresh tokens (reuse detection)
- Відкликання всіх сесій користувача ("вийти з усіх пристроїв")
Контрольні питання
- В чому принципова різниця між HTTP аутентифікацією (authentication) та авторизацією (authorization)?
- Чому сервер відповідає
Set-Cookie: __Host-session=; Max-Age=0; Secureпри logout, а не просто закриває з'єднання? - Що означає атрибут
HttpOnlyу cookie? Від якої конкретної атаки він захищає і чому він не захищає від CSRF? - Поясніть, чому
Access-Control-Allow-Origin: *несумісний зAccess-Control-Allow-Credentials: true. - В чому різниця між
301та308редиректами приPOST-запиті? - Що таке HSTS і навіщо він потрібен, якщо сервер вже підтримує HTTPS? Що відбудеться при першому візиті до сайту без HSTS Preload?
- Чому JWT access tokens зазвичай мають короткий термін (15–60 хвилин)?
- Що таке PKCE і від якої атаки він захищає в Authorization Code Flow?
- Яку роль відіграє
Vary: Accept-Encodingу HTTP-кешуванні CDN? Що відбудеться без нього? - Поясніть різницю між HS256 (symmetric) та RS256 (asymmetric) підписом JWT. Коли слід використовувати кожен?
- Що таке Refresh Token Rotation і як він допомагає виявляти компрометовані refresh tokens?
- Чому
stale-while-revalidateкраще підходить для API, ніж простоmax-age=Nбез можливості фонового оновлення? - Що таке Session Fixation і яке єдине ефективне рішення проти неї?
- Поясніть різницю між ETag і Last-Modified для conditional GET. Коли ETag точніший?
- Що таке
Content-Security-Policyі як він допомагає навіть за наявності XSS-вразливості в коді?
Повний робочий приклад: HttpPlayground
Консольний застосунок, що демонструє всі ключові концепції HTTP Advanced із реальними публічними API — без моків, без localhost, прямий копіпаст і запуск.
Структура та залежності
dotnet new console -n HttpPlayground
cd HttpPlayground
dotnet run
Потрібен лише .NET 8+. Жодних додаткових пакетів —
System.Net.Http.JsonтаSystem.Text.Jsonвходять у SDK.
Program.cs — повний код
// HttpPlayground — демонстрація HTTP Advanced з реальними API
// Запуск: dotnet new console -n HttpPlayground && cd HttpPlayground && dotnet run
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
Console.OutputEncoding = Encoding.UTF8;
// ════════════════════════════════════════════════════════════════════════════
// 1. JWT АУТЕНТИФІКАЦІЯ — dummyjson.com
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("═══ 1. JWT Authentication (dummyjson.com) ═══");
using var jwtClient = new HttpClient { BaseAddress = new Uri("https://dummyjson.com/") };
// POST /auth/login → справжній JWT access + refresh token
var loginResp = await jwtClient.PostAsJsonAsync("auth/login", new
{
username = "emilys",
password = "emilyspass",
expiresInMins = 30
});
loginResp.EnsureSuccessStatusCode();
var tokenData = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
string accessToken = tokenData.GetProperty("accessToken").GetString()!;
string refreshToken = tokenData.GetProperty("refreshToken").GetString()!;
Console.WriteLine($"✅ JWT отримано: {accessToken[..40]}...");
// Декодуємо payload (Base64URL) — демонстрація структури JWT без перевірки підпису
string[] parts = accessToken.Split('.');
string payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(PadBase64(parts[1])));
var payload = JsonDocument.Parse(payloadJson).RootElement;
Console.WriteLine($" sub (id): {payload.GetProperty("id")}");
Console.WriteLine($" username: {payload.GetProperty("username").GetString()}");
// Захищений запит — GET /auth/me з Bearer токеном
jwtClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var meResp = await jwtClient.GetAsync("auth/me");
meResp.EnsureSuccessStatusCode();
var me = await meResp.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($" Профіль: {me.GetProperty("firstName")} {me.GetProperty("lastName")}");
Console.WriteLine($" Email: {me.GetProperty("email")}");
// Оновлення токену через Refresh Token
var refreshResp = await jwtClient.PostAsJsonAsync("auth/refresh",
new { refreshToken, expiresInMins = 30 });
var newTokens = await refreshResp.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"✅ Токен оновлено: {newTokens.GetProperty("accessToken").GetString()![..40]}...");
// ════════════════════════════════════════════════════════════════════════════
// 2. COOKIE MANAGEMENT — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 2. Cookie Management (httpbingo.org) ═══");
var cookieContainer = new CookieContainer();
var cookieHandler = new HttpClientHandler { CookieContainer = cookieContainer };
using var cookieClient = new HttpClient(cookieHandler)
{
BaseAddress = new Uri("https://httpbingo.org/")
};
// /cookies/set встановлює cookie через 302-редирект
// CookieContainer автоматично слідує і зберігає Set-Cookie
await cookieClient.GetAsync("cookies/set?session=token_xyz123");
await cookieClient.GetAsync("cookies/set?theme=dark");
var storedCookies = cookieContainer.GetCookies(new Uri("https://httpbingo.org/"));
Console.WriteLine($"✅ CookieContainer має {storedCookies.Count} cookies:");
foreach (Cookie c in storedCookies)
Console.WriteLine($" {c.Name}={c.Value} HttpOnly={c.HttpOnly}");
// CookieContainer автоматично надсилає cookies у наступних запитах
var cookiesResp = await cookieClient.GetAsync("cookies");
var cookieBody = await cookiesResp.Content.ReadFromJsonAsync<JsonElement>();
Console.Write(" httpbingo бачить: ");
foreach (var prop in cookieBody.GetProperty("cookies").EnumerateObject())
Console.Write($"{prop.Name}={prop.Value} ");
Console.WriteLine();
// ════════════════════════════════════════════════════════════════════════════
// 3. BASIC AUTH — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 3. Basic Auth (httpbingo.org) ═══");
using var basicClient = new HttpClient();
// Невірні облікові дані → 401 Unauthorized
var wrongReq = new HttpRequestMessage(HttpMethod.Get,
"https://httpbingo.org/basic-auth/alice/secret");
wrongReq.Headers.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("alice:wrong")));
var wrongResp = await basicClient.SendAsync(wrongReq);
Console.WriteLine($"✅ Невірний пароль: {wrongResp.StatusCode}");
// Правильні облікові дані → 200 OK
var rightReq = new HttpRequestMessage(HttpMethod.Get,
"https://httpbingo.org/basic-auth/alice/secret");
rightReq.Headers.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("alice:secret")));
var rightResp = await basicClient.SendAsync(rightReq);
var authBody = await rightResp.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"✅ Правильні дані: {rightResp.StatusCode}, authenticated={authBody.GetProperty("authenticated")}");
// ════════════════════════════════════════════════════════════════════════════
// 4. ETAG CACHING — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 4. ETag Caching (httpbingo.org) ═══");
using var cacheClient = new HttpClient { BaseAddress = new Uri("https://httpbingo.org/") };
// Перший запит — отримуємо ETag
var firstResp = await cacheClient.GetAsync("etag/v7-product-list");
string etag = firstResp.Headers.ETag?.Tag ?? "";
long firstSize = (await firstResp.Content.ReadAsByteArrayAsync()).Length;
Console.WriteLine($"✅ GET /etag/v7-product-list: {firstResp.StatusCode}, ETag={etag}, {firstSize} байт");
// Conditional GET — If-None-Match: якщо ресурс не змінився → 304 (0 байт тіла)
var condReq = new HttpRequestMessage(HttpMethod.Get, "etag/v7-product-list");
condReq.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag));
var condResp = await cacheClient.SendAsync(condReq);
Console.WriteLine($"✅ Conditional GET: {condResp.StatusCode} " +
$"— тіло {(condResp.StatusCode == HttpStatusCode.NotModified ? "відсутнє (0 байт!)" : "отримано")}");
// ════════════════════════════════════════════════════════════════════════════
// 5. COMPRESSION — httpbingo.org
// ════════════════════════════════════════════════════════════════════════════
Console.WriteLine("\n═══ 5. Compression (httpbingo.org) ═══");
var compHandler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
using var compClient = new HttpClient(compHandler);
// /gzip — завжди повертає gzip-стиснений JSON
var gzipResp = await compClient.GetAsync("https://httpbingo.org/gzip");
string gzipBody = await gzipResp.Content.ReadAsStringAsync();
Console.WriteLine($"✅ GZIP: {gzipResp.StatusCode}, розпаковано {gzipBody.Length} символів");
Console.WriteLine("\n═══ Готово! Всі 5 концепцій продемонстровано. ═══");
// ── Допоміжна функція ────────────────────────────────────────────────────────
static string PadBase64(string base64Url)
{
string base64 = base64Url.Replace('-', '+').Replace('_', '/');
return base64.Length % 4 switch
{
2 => base64 + "==",
3 => base64 + "=",
_ => base64
};
}
HttpListener — вбудований HTTP-сервер .NET
Детальний розгляд класу HttpListener — нативного HTTP-сервера .NET без ASP.NET Core. Архітектура, повний API, конфігурація, аутентифікація, обробка запитів та два практичні приклади — статичний файловий сервер та REST API.
SMTP та протоколи електронної пошти
Детальне вивчення SMTP-протоколу — сесія, команди, аутентифікація, MIME, STARTTLS/SMTPS; відправлення листів через System.Net.Mail у .NET 10; огляд суміжних протоколів IMAP та POP3.