В OSI семь уровней: физический, канальный, сетевой, транспортный, сеансовый, представления и прикладной. В TCP/IP, модели, на которой работает интернет, их четыре: канальный, межсетевой, транспортный и прикладной. IP находится на 3-м уровне, TCP и UDP на 4-м, HTTP на 7-м, а балансировщик работает на 4-м или 7-м.
TLS находится поверх TCP и под HTTP (в OSI это сеансовый уровень или уровень представления). Сам HTTP не меняется, он просто передаётся в зашифрованном виде.
Каждый уровень оборачивает верхний: HTTP лежит внутри TLS-записей, те внутри TCP-сегментов, те внутри IP-пакетов, а те внутри Ethernet-кадров.
Адреса: MAC-адреса на 2-м уровне (один сегмент сети), IP-адреса на 3-м (от отправителя до получателя), порты на 4-м (какой процесс).
Балансировщики: L4 пересылает TCP-соединения по IP и порту, L7 разбирает HTTP и маршрутизирует по хосту, пути или заголовку.
GoMiddle
Сети и безопасность для бэкенда
TCP и модель OSI, DNS, TLS, HTTP/3 и QUIC, балансировщики и прокси, WebSocket, CORS, хеширование паролей, OAuth 2.0 и JWT, SSRF, mTLS
Закрыто вопросов
0 / 19
- 0Повторить
Вопросы
Браузер узнаёт IP example.com через DNS, открывает TCP-соединение на порт 443, проводит TLS-рукопожатие и отправляет по нему GET /orders. Обычно TLS завершает балансировщик или прокси, он пересылает запрос бэкенду, и ответ возвращается по тому же соединению.
DNS: сначала кэши браузера и ОС, затем рекурсивный резолвер, который спрашивает корневые серверы, серверы зоны .com и собственные name-серверы example.com; ответ кэшируется на время его TTL.
TCP: SYN, SYN-ACK, ACK, то есть один RTT (круг туда и обратно) до любых данных.
TLS 1.3: ещё один RTT: обмен ключами, проверка сертификата по доверенным CA и имени хоста, а ALPN выбирает h2 или http/1.1.
HTTP: запрос с Host, cookie и заголовками попадает на балансировщик, затем на обратный прокси или ingress и дальше в хендлер на Go.
Следующий запрос переиспользует соединение в keep-alive и пропускает DNS, TCP и TLS.
HTTPS устроен как HTTP поверх TLS: рукопожатие согласует шифр и проверяет сертификат сервера, после чего обе стороны шифруют трафик сеансовым ключом, так что он закрыт от посторонних и защищён от подмены.
TLS и SSL: TLS стал преемником SSL; SSL устарел, сегодня работают TLS 1.2 и 1.3.
TCP и UDP: TCP даёт упорядоченную надёжную доставку с повторной отправкой; UDP не даёт ни того, ни другого, поэтому его выбирают, когда запоздавшие данные бесполезны или приложение само восстанавливает потери: DNS, видеозвонки, игры и QUIC (HTTP/3).
В Go: http.ListenAndServeTLS(addr, certFile, keyFile, handler) поднимает HTTPS, autocert из golang.org/x/crypto получает сертификаты Let's Encrypt, а tls.Config{MinVersion: tls.VersionTLS12} в http.Server отсекает старые версии протокола.
Перед сервисом обычно стоит прокси или балансировщик, который завершает TLS и общается с сервисом по обычному HTTP внутри частной сети.
CORS позволяет серверу сообщить браузеру, каким другим origin разрешено читать его ответы, а соблюдает это правило сам браузер. Поэтому CORS ограничивает только JavaScript с чужих сайтов внутри браузера: curl, скрипт или другой бэкенд его игнорируют и всё равно получают ответ.
Origin состоит из схемы, хоста и порта, поэтому вызов api.example.com со страницы app.example.com считается кросс-доменным, и в ответе нужен Access-Control-Allow-Origin.
Простые запросы уходят сразу: GET, HEAD или POST с разрешёнными заголовками и типом содержимого формы или text/plain. Хендлер выполняется, блокируется только чтение ответа.
Preflight: о любом другом методе, Content-Type: application/json или заголовке Authorization браузер сначала спрашивает через OPTIONS; Access-Control-Max-Age кэширует ответ.
Cookie уходят на чужой origin только с credentials: "include", и тогда сервер должен назвать точный origin и отправить Access-Control-Allow-Credentials: true. Если при этом отражать любой Origin, API открыт всем сайтам.
Это не контроль доступа: аутентифицируйте каждый запрос. Простой POST с чужого сайта всё равно выполняет ваш хендлер: это CSRF, от него защищают cookie с SameSite или http.CrossOriginProtection.
Клиент отправляет обычный GET с Upgrade: websocket и Connection: Upgrade, сервер отвечает 101 Switching Protocols, и дальше то же TCP-соединение несёт WebSocket-фреймы в обе стороны. Каждый прокси на пути должен передать upgrade дальше и позволить соединению жить часами.
Рукопожатие: клиент шлёт случайный Sec-WebSocket-Key, а сервер доказывает, что понимает WebSocket, возвращая его хэш в Sec-WebSocket-Accept. websocket.Accept отвечает на обычный GET кодом 426 Upgrade Required.
Прокси: Upgrade и Connection относятся к hop-by-hop заголовкам, которые прокси по умолчанию отбрасывают. В nginx оба нужно задать через proxy_set_header (а до версии 1.29.7 ещё и proxy_http_version 1.1); httputil.ReverseProxy в Go обрабатывает upgrade сам.
Таймауты простоя: AWS ALB и nginx по умолчанию закрывают молчащее соединение через 60 с, поэтому шлите ping каждые 20–30 с.
Origin: CORS к WebSocket не применяется, а cookie уходят вместе с рукопожатием, поэтому проверяйте Origin, иначе любой сайт откроет сокет от имени вашего пользователя.
**Shutdown** не ждёт перехваченных (hijacked) соединений: закрывайте их со StatusGoingAway и дайте клиентам переподключиться с backoff.
TCP открывает соединение через SYN, SYN-ACK, ACK, то есть один RTT до любых данных, и закрывает его парой FIN и ACK в каждую сторону. Сторона, закрывшая первой, держит сокет в TIME_WAIT 60 с (в Linux), поэтому у клиента, открывающего новое соединение на каждый запрос, они копятся.
Зачем рукопожатие: каждая сторона выбирает случайный начальный sequence number и подтверждает чужой; по этим номерам TCP упорядочивает байты и замечает потерянные и повторные сегменты.
Закрытие идёт по направлениям: FIN значит «больше ничего не отправлю», а RST обрывает соединение сразу, это и есть connection reset by peer.
**Зачем нужен TIME_WAIT: если последний ACK потеряется, собеседник повторит FIN и должен получить ответ, а заблудившиеся пакеты старого соединения должны истечь до повторного использования той же пары адресов и портов.
Ловушка**: каждое соединение к одному апстриму занимает эфемерный порт, а их в Linux около 28 000. При 500 новых соединениях в секунду 60 с TIME_WAIT требуют 30 000 портов, и подключения падают с connect: cannot assign requested address.
Лечится переиспользованием: один общий http.Client, достаточный MaxIdleConnsPerHost, тела ответов дочитаны и закрыты. tcp_tw_reuse только прячет симптом; посчитать сокеты можно через ss -tan state time-wait.
Рекурсивный резолвер идёт от корня к серверам TLD и дальше к авторитативным серверам домена, и каждый ответ кэшируется на время его TTL. Go разрешает имя только при подключении, поэтому соединения из пула продолжают ходить на старый IP, пока не закроются, а при постоянном трафике они не закрываются никогда.
Записи: A и AAAA хранят адреса, CNAME делает имя псевдонимом другого; TTL говорит, сколько кэш может хранить ответ.
Кэши на пути: рекурсивный резолвер, CoreDNS в кластере (30 с в стандартном конфиге), иногда ОС. Понижайте TTL за день до миграции, чтобы старый, более длинный TTL успел истечь везде.
Своего DNS-кэша у Go нет: каждое подключение разрешает имя заново. Задерживаются соединения: загруженное соединение HTTP/1.1 или HTTP/2 никогда не простаивает, а ClientConn в gRPC перечитывает DNS в основном тогда, когда соединение рвётся.
Решение: заставьте соединения завершаться: MaxConnectionAge на gRPC-сервере (он отправляет GOAWAY), CloseIdleConnections() после переключения, а старый адрес пусть закрывает свои соединения во время переезда. IdleConnTimeout помогает, только когда трафик затихает.
Клиент строит цепочку от сертификата сервера через промежуточные до корня, которому уже доверяет, проверяет сроки действия каждого сертификата и проверяет, что имя хоста есть в Subject Alternative Names листового сертификата. Для приватного CA добавьте этот CA в RootCAs; проверку не отключайте никогда.
Цепочка: сервер присылает свой листовой сертификат и промежуточные; корень уже должен быть в пуле клиента (хранилище ОС, /etc/ssl/certs в Linux), а каждая подпись проверяется ключом сертификата уровнем выше.
Имя: Go сверяет ServerName, к которому подключался (он же уходит как SNI), только с SAN; Common Name игнорируется.
Ключ: сервер доказывает, что владеет закрытым ключом сертификата, подписывая рукопожатие, поэтому скопированный сертификат без ключа бесполезен.
Ошибки: certificate signed by unknown authority означает, что нет корня (приватный CA или образ scratch без CA-файлов); certificate is valid for X, not Y говорит про имя; has expired or is not yet valid про сроки или сбитые часы.
**InsecureSkipVerify: true** пропускает и цепочку, и имя, так что любой на пути может предъявить любой сертификат и читать трафик.
HTTP/3 работает поверх QUIC, транспорта на UDP со встроенным TLS 1.3: один RTT на подключение вместо RTT для TCP плюс RTT для TLS, потоки, которые не стопорят друг друга при потере пакета, и соединения, переживающие смену IP. API на Go за балансировщиком редко нужно говорить на нём самому.
Рукопожатие: TCP плюс TLS 1.3 занимают два RTT до запроса, QUIC один. Возобновление 0-RTT отправляет запрос уже в первом пакете, но эти данные можно воспроизвести повторно, поэтому оно годится только для идемпотентных запросов.
Head-of-line blocking: потоки HTTP/2 делят один байтовый поток TCP, поэтому один потерянный сегмент останавливает их все, пока его не перешлют. QUIC восстанавливает потери отдельно для каждого потока, и сильнее всего это окупается в мобильных сетях с потерями.
Миграция соединения: QUIC-соединение определяется connection ID, а не IP и портом, поэтому телефон, переходящий с Wi-Fi на LTE, его сохраняет.
Обнаружение: браузер начинает с TCP и переключается после Alt-Svc: h3=":443"; где UDP заблокирован, он остаётся на HTTP/2.
В Go: в net/http нет HTTP/3; его даёт quic-go: http3.Server и http3.Transport. Обычная схема: CDN, Caddy или nginx на краю говорят с клиентами на HTTP/3, а с сервисом на Go на HTTP/1.1 или HTTP/2, где потерь мало и выигрыш невелик.
Балансировщик L4 пересылает TCP- или UDP-соединения по IP и порту, не читая их содержимое, поэтому подходит для любого протокола, но балансирует целые соединения. L7 завершает соединение и разбирает HTTP, поэтому может маршрутизировать по хосту, пути или заголовку и балансировать каждый запрос.
L4 видит только IP и порты. TLS проходит насквозь до бэкенда, и работает любой протокол: Postgres, Kafka, голый TCP. Примеры: AWS NLB, HAProxy в mode tcp, kube-proxy за Service типа ClusterIP.
L7 видит путь, заголовки, cookie и метод gRPC. Он может развести /api и /static, завершить TLS, добавить X-Forwarded-For, повторить запрос и сам ответить 502, расплачиваясь CPU и хранением сертификатов. Примеры: AWS ALB, nginx, Envoy.
Соединения или запросы: L4 привязывает долгоживущее соединение HTTP/2 или gRPC к одному бэкенду, поэтому gRPC за обычным Service попадает на один под (см. тему про микросервисы); L7 распределяет запросы.
Health checks: L4 видит только, принимает ли порт соединения; L7 вызывает /readyz и читает статус.
IP клиента: за L4 бэкенд видит балансировщик, если тот не сохраняет исходный IP и не использует PROXY protocol; за L7 бэкенд читает X-Forwarded-For.
Прямой (forward) прокси действует от имени клиентов и ходит на любые серверы, как корпоративный прокси для исходящего трафика; обратный (reverse) прокси стоит перед серверами, как nginx, ingress или балансировщик. За прокси r.RemoteAddr указывает на него самого, поэтому читайте X-Forwarded-For и доверяйте только записям, которые добавили ваши прокси.
Как растёт заголовок: каждый прокси дописывает адрес, с которого к нему пришло соединение: <что угодно от клиента>, <клиент>, <ваш прокси>. Всё левее записи вашего первого прокси выбирает клиент.
Классический баг: брать самую левую запись. Клиент отправляет X-Forwarded-For: 1.2.3.4 и становится 1.2.3.4 для вашего rate limiter, журнала аудита и списка разрешённых IP.
Правило: если RemoteAddr не ваш прокси, игнорируйте заголовок; иначе идите по нему справа, пропускайте свои прокси, и первый оставшийся адрес принадлежит клиенту.
Варианты: Forwarded (RFC 7239), X-Real-IP от nginx, CF-Connecting-IP от CDN. Доверяйте тому, который выставляет ваш edge, и пусть edge перезаписывает его, а не пропускает дальше.
Прямые прокси в Go: http.DefaultTransport учитывает HTTPS_PROXY и NO_PROXY через http.ProxyFromEnvironment.
Храните медленный хэш с солью, а не сам пароль и не его быстрый хэш. SHA-256 создан ради скорости, поэтому утёкшую таблицу перебирают со скоростью миллиардов попыток в секунду на одной видеокарте; bcrypt и argon2id медленные намеренно, используют свою соль для каждого пароля, а argon2id к тому же требует много памяти на каждую попытку.
Соль: случайное значение для каждого пароля, которое хранится вместе с хэшем, поэтому одинаковые пароли дают разные хэши, а заранее вычисленные таблицы бесполезны.
Стоимость: bcrypt со стоимостью 12 тратит около 200 мс на хэш на современном ядре, и каждый шаг удваивает время. При входе этого не видно, а перебор по словарю становится разорительным.
argon2id (RFC 9106) рекомендуют сейчас; минимум по OWASP: 19 МиБ, t=2, p=1. argon2.IDKey возвращает только ключ, поэтому соль и параметры храните вместе с ним.
Ограничения bcrypt: на вход длиннее 72 байт он возвращает bcrypt.ErrPasswordTooLong, а его стоимость нагружает только CPU. Если действуют требования FIPS, используйте PBKDF2, он есть в стандартной библиотеке как crypto/pbkdf2 начиная с Go 1.24.
Сравнивайте через bcrypt.CompareHashAndPassword или subtle.ConstantTimeCompare. При успешном входе проверьте bcrypt.Cost и пересчитайте хэш, если с тех пор подняли стоимость.
Ограничивайте частоту запросов ко входу: каждый хэш занимает ядро CPU на сотни миллисекунд.
Сессия представляет собой случайный ID, указывающий на состояние на сервере, поэтому её можно отозвать сразу; JWT устроен как подписанный токен, который проверяется без обращения к хранилищу, поэтому масштабируется без общего состояния, но действует до истечения срока.
Сессии: выход и отзыв сводятся к удалению записи; платить за это приходится обращением к хранилищу (Redis, база) на каждый запрос.
JWT: фиксируйте алгоритм подписи в коде, а не доверяйте alg из токена, и проверяйте exp, iss и aud. Делайте access-токены короткими (5–15 минут) и обновляйте их через refresh-токен.
Refresh-токены: храните их на сервере и меняйте при каждом использовании; повторное использование старого означает кражу, поэтому отзывайте всю цепочку.
В браузере: cookie с HttpOnly; Secure; SameSite=Lax держит токен вне досягаемости JavaScript и XSS; localStorage этого не делает. Аутентификации через cookie нужна защита от CSRF, и в Go 1.25 для этого появился http.CrossOriginProtection.
IDOR (insecure direct object reference): GET /orders/124 срабатывает для любого, кто поменяет число. Ограничивайте каждый запрос вызывающим пользователем, WHERE id = $1 AND user_id = $2, и отвечайте 404, а не 403, чтобы ID нельзя было перебирать.
golang-jwt проверяет exp, только если он есть в токене: по умолчанию это поле необязательно, поэтому токен скрипта, выпущенный без exp, действует вечно. Сделайте его обязательным через jwt.WithExpirationRequired() и заодно закрепите издателя и аудиторию.
Почему он проходит: у скрипта был продакшен-секрет, поэтому подпись верна, а без exp, nbf и iat проверять по времени нечего. jwt.Parse не возвращает ошибку.
**iss и aud**: ничто не привязывает токен к этому издателю и этому API, поэтому пройдёт и токен для staging, и токен для другого сервиса с тем же секретом. WithIssuer и WithAudience отвергают оба, а также токен без этого поля.
**WithValidMethods** закрепляет алгоритм, а не доверяет alg из заголовка. golang-jwt и так отвергает ключ неподходящего типа, но явное закрепление показывает намерение и переживёт будущий переход на ключи RSA.
Смените секрет: уже выпущенный токен действует, пока не сменится ключ. Дайте каждому окружению свой ключ и кладите kid в заголовок, чтобы старый и новый ключи могли действовать одновременно во время переключения.
Приложение отправляет пользователя к провайдеру идентификации, тот проводит вход и возвращает пользователя с одноразовым кодом; бэкенд обменивает этот код вместе с PKCE-verifier, который знает только он, на токены. OAuth даёт доступ к API; OpenID Connect добавляет ID-токен, подписанный JWT, который сообщает, кто вошёл.
Шаги: /authorize с client_id, redirect_uri, scope, state и code_challenge; пользователь входит; браузер возвращается на redirect_uri?code=...&state=...; бэкенд отправляет код POST-запросом на /token.
**state выбирается случайно, привязывается к браузеру и проверяется при возврате, чтобы никто не мог заставить ваше приложение завершить начатый им вход (login CSRF).
PKCE**: приложение сначала отправляет SHA-256(verifier), а сам verifier только при обмене, поэтому код, украденный из редиректа или лога, бесполезен. Его придумали для приложений, которые не могут хранить секрет; OAuth 2.1 (пока ещё черновик) требует его от всех клиентов и разрешает бэкенду обойтись без него, только если тот проверяет nonce из OIDC.
Что добавляет OIDC: scope openid, ID-токен (sub, iss, aud, равный вашему client ID, exp), эндпоинт userinfo и discovery по адресу /.well-known/openid-configuration с ключами подписи. Проверьте ID-токен, заведите собственную сессию и никогда не отправляйте его в API вместо access-токена.
Она сравнивает подписи через !=, которое работает не за постоянное время, поэтому по времени ответа можно узнать, какая часть поддельной подписи угадана; и подписывается только тело, поэтому перехваченный запрос можно повторять вечно. Сравнивайте через hmac.Equal и пусть отправитель подписывает метку времени, которую вы проверяете.
Тайминг: != может остановиться на первом отличающемся байте или машинном слове, поэтому время сравнения зависит от входа. Go сравнивает блоками, и утечка невелика, но секреты принято сравнивать за постоянное время: hmac.Equal тратит одно и то же время на любой вход заданной длины. Декодируйте hex и сравнивайте байты.
Повтор (replay): HMAC доказывает, что тело когда-то пришло от провайдера, но не то, что оно новое, поэтому одну перехваченную доставку payment.succeeded можно отправлять бесконечно.
Исправление: отправитель подписывает timestamp.body; отвергайте всё, что старше примерно пяти минут, и в пределах этого окна отсекайте дубли по ID события. Так работает заголовок Stripe t=...,v1=...; GitHub подписывает только тело, поэтому там дубли отсекают по X-GitHub-Delivery.
Подписывайте сырые байты: проверяйте тело ровно в том виде, в каком оно пришло, до декодирования; перекодированный JSON уже не совпадёт.
Имя из query string попадает прямо в filepath.Join, а тот разрешает ..: ?name=../../../etc/passwd превращается в /etc/passwd, а ?name=../config.yaml в конфиг сервиса. Открывайте имена от пользователя через os.Root (Go 1.24+), который отказывается открывать любой путь, выходящий за каталог.
**Почему ServeFile не помешал**: он отвергает .. только в r.URL.Path. Здесь путь равен /download, а опасная часть приходит в query.
**Почему strings.HasPrefix(full, filesDir) не решение**: /srv/app/uploads-old/x проходит эту проверку, а симлинк внутри каталога всё равно может указывать куда угодно. filepath.IsLocal(name) даёт корректную лексическую проверку, но симлинков тоже не видит.
**os.Root** разрешает каждую часть пути внутри каталога, включая симлинки, и для всего, что снаружи, возвращает path escapes from parent. root.FS() даёт ту же гарантию в виде fs.FS.
Каталоги: root.Open(".") выполняется успешно, поэтому проверьте Stat и отказывайте для каталогов до http.ServeContent.
Что ещё нужно: правило IDOR касается и файлов. Храните загрузки под сгенерированными id, проверяйте, что файл принадлежит вызывающему, а исходное имя используйте только в Content-Disposition.
Server-side request forgery (SSRF) заставляет ваш сервер отправлять запросы, которые атакующий не может отправить напрямую: к сервису метаданных облака, админским портам на localhost или внутренним API. Проверяйте IP в момент подключения в net.Dialer.Control, а не URL заранее, тогда rebinding и редиректы проверку не обойдут.
Почему проверки URL мало
Имя хоста может разрешаться в 10.0.0.5, а схема «проверить, потом запросить» разрешает имя дважды: DNS-сервер для rebinding с TTL 0 отвечает проверке публичным IP, а подключению адресом 127.0.0.1.
Публичный URL может отправить редирект на http://169.254.169.254/, и http.Client по нему пойдёт.
[::ffff:127.0.0.1], 0.0.0.0 и имена вроде localtest.me ведут на локальную машину; ни один строковый фильтр по хосту не поймает все формы.
Проверка самого соединения
Control вызывается при каждом подключении, после разрешения имени, с точным IP, к которому идёт соединение, поэтому проверяются и каждый шаг редиректа, и каждое новое соединение пула.
IsGlobalUnicast отсекает loopback, link-local (адрес метаданных), multicast и 0.0.0.0, но пропускает 10.0.0.0/8, поэтому добавьте IsPrivate и 100.64.0.0/10, предварительно вызвав Unmap.
Proxy: nil: через прокси вы подключаетесь к прокси, и уже он запросит внутренний URL за вас.
Разрешайте только https и ожидаемые порты и ограничивайте число редиректов, время и размер ответа.
Эшелонированная защита
Никогда не показывайте пользователю полученное тело или подробную ошибку: так слепой SSRF превращается в сканер портов.
Делайте запросы из изолированного сегмента сети или через прокси для исходящего трафика с тем же правилом (Smokescreen от Stripe) и требуйте IMDSv2 в AWS, чтобы простой GET к сервису метаданных ничего не получал.
В mTLS сервер тоже запрашивает сертификат, и каждая сторона проверяет цепочку другой по приватному CA: сервер задаёт ClientAuth: tls.RequireAndVerifyClientCert и ClientCAs, клиент RootCAs, свои Certificates и ServerName. Затем каждая сторона проверяет, чью личность она получила.
Поля на каждой стороне
Сервер: Certificates, ClientCAs и ClientAuth: tls.RequireAndVerifyClientCert. RequireAnyClientCert и VerifyClientCertIfGiven звучат похоже, но принимают непроверенный или отсутствующий сертификат.
Клиент: Certificates, RootCAs только с приватным CA, чтобы сертификата публичного CA на то же имя было недостаточно, и ServerName, который есть в SAN сервера.
Обе стороны: MinVersion: tls.VersionTLS13 и никогда InsecureSkipVerify.
Аутентифицирован ещё не значит авторизован
Проверенная цепочка доказывает только то, что сертификат выпустил ваш CA. Сверяйте личность, обычно URI SAN в формате SPIFFE вроде spiffe://prod/ns/billing/sa/api, со списком разрешённых: в VerifyConnection для всего сервера или по маршрутам через r.TLS.PeerCertificates[0].
Эксплуатация
Выпускайте сертификаты на часы или дни автоматически (cert-manager, SPIRE, Vault) и отдавайте их через GetCertificate и GetClientCertificate, тогда ротация не требует перезапуска. Go не проверяет CRL и OCSP: роль отзыва играют короткие сроки.
Отлаживайте по логу сервера: клиент видит только TLS-alert remote error: tls: ... (bad certificate, unknown certificate authority, certificate required), причём часто при первом чтении, а не во время рукопожатия.