Обратный прокси, HTTPS и заголовки

выкладка≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Наружу боевая машина смотрит одним процессом: обратным прокси. Он получает сертификат, слушает 80 и 443 и раздаёт запросы контейнерам — это вы уже видели в части IV, одной строкой reverse_proxy. Здесь строк будет больше, и каждая отвечает на вопрос, который до выкладки не возникает: почему у сайта один хост, а не два; что прокси дописывает к запросу и что делает с тем, что клиент написал о себе сам; откуда берётся сертификат и почему его не выпустить с ноутбука. Главный вопрос — последний: какой адрес окажется в req.ip, когда ограничитель частоты будет решать, кого не пускать. У Fastify это одна настройка, и у неё есть два неверных значения: при одном перебор проходит целиком, при другом ограничитель закрывает вход всем читателям разом. Сборка при обеих зелёная.

Разделы темы:·

Начнём с того, что видно снаружи. В compose-файле автора строка ports есть у прокси (80:80, 443:443 и 443:443/udp), а у базы и у консоли хранилища она ограничена адресом 127.0.0.1 самого сервера. У приложения и у веба её нет вовсе: внутрь сети docker прокси ходит по имени сервиса, api:8000 и web:80. Почему раскладка именно такая и чем опасен лишний опубликованный порт, разобрано в «Порты: слушать нужное, публиковать одно». Здесь важно следствие: интернет видит один процесс, и всё, что этот процесс решает, решается за приложение.

А решает он немало: какому контейнеру достанется запрос, по какому имени сайт вообще отвечает, какие заголовки уедут дальше и какой заголовок кеша получит ответ. У автора граница между контейнерами проходит по путям одного домена — вот сокращённый боевой конфиг.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Хост здесь один — {$DOMAIN}. Второй блок, {$WWW_DOMAIN}, содержимого не отдаёт: он переселяет на apex постоянным редиректом — permanent у Caddy значит 301. Причина не в аккуратности: один и тот же учебник, доступный по двум именам, делит между ними сигналы поисковиков, а ради поиска сайт и затевался. Сертификат для второго имени Caddy выпустит сам, ему нужна только A-запись www на тот же адрес.

У этой пары блоков есть слабость, которую видно только на выкате. Имена приходят из окружения контейнера, и если переменная до контейнера не доехала, подстановка молча становится пустой строкой — как это устроено в compose, разбирает «Переменные: кто и когда подставляет». Прогноз: что скажет caddy validate, если WWW_DOMAIN пуста?

запись с машины: caddy 2.11.4 из Homebrew, macOS 27 на arm64, 28.09.2026; вывод сценария целиком · 28 сентября 2026
Один и тот же конфиг проверяется дважды: с обеими переменными окружения и без одной
# Каркас боевого конфига: имена хостов приходят из окружения контейнера
{$WWW_DOMAIN} {
	redir https://{$DOMAIN}{uri} permanent
}

{$DOMAIN} {
	handle /api/* {
		reverse_proxy api:8000
	}

	handle {
		reverse_proxy web:80
	}
}
sh validate.sh

От пустой подстановки остаётся { … }, а фигурные скобки в начале файла Caddy читает как блок глобальных настроек, где директива redir не значит ничего. Конфиг не разбирается целиком, то есть с пустой переменной не поднимется и apex: на боевой машине это не «www не работает», а сайт в цикле рестартов. Отсюда правило выката: конфиг проверяется validate до того, как контейнер пересоздан, и проверяется с тем окружением, с которым поедет.

Теперь о том, что было обещано раньше. Разбирая статический экспорт, мы говорили, что границу между файлами и приложением проводят или по домену, или по путям одного домена. У автора она по путям, и поддомена api.java-script.pro в выкладке нет — набросок «файлы на одном имени, приложение на другом» остался наброском. Уточнить стоит и вторую половину: с диска Caddy читает только /app. Статьи учебника — тоже статический экспорт, но лежат они в своём образе с файловым сервером на :80, и прокси их проксирует, как и API. Плата — лишний прыжок внутри сети, выгода — один способ обновить любой контейнер.

Разберём эту границу на модели. Запрос приезжает в разборщик из главы 4 — тот самый, что копит буфер и разбирает голову, — а решение принимает список правил. Прогноз: какой из семи запросов уедет не туда, куда его отправил бы порядок правил в списке?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок в списке не решает ничего: перед проверкой он переставлен от самого длинного пути к самому короткому, и всеохватное правило /, стоящее первым, оказывается последним. Так делает и сам Caddy — это проверено запуском: handle без матчера, написанный в файле первым, уступает handle /api/*, написанному после него, а caddy adapt показывает уже переставленные маршруты. Для читателя, пришедшего из главы 4, это разворот привычки: в своём маршрутизаторе и в своём конвейере порядок был порядком объявления, а здесь конфиг сортируется сам.

Остальное в выводе — перевод конфига на русский. /trpc и /api уезжают в приложение с no-store: ответы персональные, а Vary: Authorization сервер не ставит, и дисковый кеш браузера показал бы данные прошлого пользователя после смены учётной записи. /uploads уходит в хранилище фотографий без срезания префикса — путь /uploads/<файл> и есть запрос к бакету uploads, а о самой загрузке будет своя статья. И только у /app префикс срезается: файл на диске называется login.html, а не app/login.html.

У директив Caddy первый аргумент, начинающийся со слэша, — это не значение, а путь-матчер: respond /uploads/ 404 значит «на этом пути ответить 404», а не «ответить текстом». Пустой ответ вместо ожидаемого текста — первый признак, что аргумент ушёл в матчер.

К приложению соединение приходит не от читателя, а от прокси: в req.socket.remoteAddress лежит адрес контейнера caddy в сети docker, один и тот же у всех запросов мира. Всё, что было известно о клиенте, прокси передаёт заголовками семейства X-Forwarded-*: адрес, схему, хост. Глава 4 уже говорила, что такой заголовок дописывает прокси и что клиенту ничто не мешает прислать его самому — тогда выдумка встанет слева, а справа прокси допишет единственный адрес, который видел своими глазами. Пора уточнить: допишет, если поверит. По умолчанию Caddy не верит.

Вот эта развилка моделью. Функция собирает заголовки, которые уедут вверх, из заголовков, которые пришли снизу. Прогноз: что достанется приложению в каждом из двух случаев?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

По умолчанию прокси не продолжает чужую цепочку, а заменяет её тем, что знает сам: адресом соединения, своей схемой, своим хостом. Клиентские выдумки не просто игнорируются — до приложения они не доезжают вовсе. Второй случай — прокси, у которого клиент записан в доверенные: цепочка продолжается справа, схема и хост остаются клиентскими. Оба поведения разумны, и выбор между ними осознанный: первое — когда перед прокси никого нет, второе — когда перед ним стоит ещё один прокси, CDN или балансировщик и его заголовки нужно сохранить.

Модель могла бы и врать, поэтому то же самое — на настоящем Caddy. В прогоне два входа одного процесса, и отличаются они одной настройкой: у второго в доверенные записан тот, кто к нему стучится. Запрос обоим уходит один и тот же, с тремя подделанными заголовками. Прогноз: что напечатает приложение — и по какой версии протокола до него доедет запрос, если клиент говорит по HTTP/2?

запись с машины: caddy 2.11.4 из Homebrew и node 26.5.0, macOS 27 на arm64, 28.09.2026; прокси и приложение подняты сценарием на одной машине, вывод сценария целиком · 28 сентября 2026
Один запрос с подделанными X-Forwarded-* в два входа одного прокси и то, что увидело приложение
# Два входа отличаются одной настройкой: кому прокси верит на слово.
# На боевой машине это порты 443 и 443/udp одного хоста; здесь высокие порты
# и собственный CA, потому что 80 и 443 на ноутбуке заняты не нами.
{
	local_certs
	skip_install_trust
	admin off
	http_port 3932
	https_port 3933

	# у второго входа в доверенные записан тот, кто к нему стучится
	servers :3934 {
		trusted_proxies static 127.0.0.1/8 ::1
	}
}

localhost:3933 {
	handle {
		reverse_proxy 127.0.0.1:3931
	}
}

localhost:3934 {
	handle {
		reverse_proxy 127.0.0.1:3931
	}
}
sh probe.sh

Три вещи в этом выводе стоит заметить. Первая: клиент получил HTTP/2 и обещание h3 в alt-svc, а приложение получило HTTP/1.1 — двоичные кадры кончились на входе. Вторая: host у приложения внешний — тот, который набрал клиент, а не имя сервиса внутри сети. Через этот вход подделать его не вышло бы: имя названо в конфиге, и запрос с чужим host ни в один блок не попадает — вход отвечает на него сам, пустым ответом, а приложение такого запроса не видит вовсе (проверено на том же стенде). Но само приложение об этом не знает и знать не может — ему видно только то, что заголовок пришёл. Третья, главная: во входе по умолчанию затёрты все три подделки — вместо цепочки клиента приложение получило адрес соединения (в этом запуске это ::1, локальная петля, а на боевой машине был бы адрес контейнера caddy), — а во входе с доверием сохранены все три, включая x-forwarded-proto: http: клиент смог сказать приложению, что пришёл без TLS.

Последнее неприятнее, чем кажется. Приложение за прокси не знает ни своей схемы, ни своего имени: TLS кончился на входе, а host — то, что написал клиент. Значит, абсолютные адреса — ссылка в письме, редирект после входа, canonical — собираются из настройки, а не из запроса. В API автора адрес письма берётся из переменной APP_URL, и это не перестраховка: задача 3 показывает, что получается иначе.

trusted_proxies — не украшение и не «сделать правильно»: это переключатель «верить тому, что клиент написал о себе». Он пишется, только когда перед входом действительно стоит свой прокси, и перечисляет его адреса, а не все частные сети на всякий случай.

Теперь к тому, ради чего вся возня с адресами. Ограничитель частоты из главы 8 считает по ключу «процедура плюс адрес»: десять вызовов в минуту на пару. Первая половина ключа известна точно. Вторая — та самая, которую прокси только что затирал и дописывал.

Сравним три способа назвать клиента на одной и той же нагрузке: тридцать запросов перебора с одного адреса, каждый с новой выдумкой в X-Forwarded-For, и восемь честных запросов читателя. Ограничитель один и тот же, предел десять. Прогноз: сколько пройдёт в каждом случае?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
разбор вывода
Левый элемент цепочки — это то, что прислал клиент, поэтому тридцать выдумок дают тридцать разных ключей: ограничителя как будто нет вовсе. Адрес соединения — один на всех, кто пришёл через прокси: перебор съедает предел за десять запросов, а дальше не пускают никого, включая читателя, который ничего не сделал. Третий способ считает то, что нужно: выдумка ключ не меняет, потому что смотрят не на неё, а на адрес, дописанный прокси, — а читатель со своим ключом проходит весь.

У Fastify всё это — одна настройка, trustProxy. В боевом main.ts автора стоит trustProxy: 1 с комментарием «доверяем ровно одному прыжку — тому X-Forwarded-For, который дописал Caddy». Прогноз: что окажется в request.ip при четырёх значениях настройки и четырёх видах запроса — и верен ли комментарий?

прогон node 26.5.0 · 28 сентября 2026
request.ip и request.ips на четырёх значениях trustProxy, включая запрос, пришедший мимо прокси
// Четыре приложения Fastify отличаются одной настройкой — тем, каким соседям
// сервер верит, когда достаёт адрес клиента. Запросы идут через inject: порт
// не нужен, а адрес соединения задаётся полем remoteAddress
import Fastify from "fastify";

const настройки = [
  ["без trustProxy", {}],
  ["trustProxy: true", { trustProxy: true }],
  ["trustProxy: 1", { trustProxy: 1 }],
  ['trustProxy: "127.0.0.1"', { trustProxy: "127.0.0.1" }],
];

// remoteAddress — адрес того, кто соединился с сервером: прокси или сам клиент
const запросы = [
  ["заголовка нет", { remoteAddress: "127.0.0.1" }],
  ["прокси дописал один адрес", { remoteAddress: "127.0.0.1", "x-forwarded-for": "203.0.113.9" }],
  ["клиент прислал свой, прокси дописал свой", { remoteAddress: "127.0.0.1", "x-forwarded-for": "198.51.100.7, 203.0.113.9" }],
  ["мимо прокси, со своим заголовком", { remoteAddress: "198.51.100.200", "x-forwarded-for": "9.9.9.9" }],
];

for (const [подпись, опции] of настройки) {
  const app = Fastify(опции);
  app.get("/ping", (запрос) => ({ ip: запрос.ip, ips: запрос.ips }));
  console.log(подпись);
  for (const [что, { remoteAddress, ...headers }] of запросы) {
    const ответ = await app.inject({ method: "GET", url: "/ping", headers, remoteAddress });
    const { ip, ips } = ответ.json();
    console.log(`  ${что}`);
    console.log(`    request.ip  = ${ip}`);
    console.log(`    request.ips = ${ips === undefined ? "undefined" : ips.join(" | ")}`);
  }
  await app.close();
}
node proxy.mjs

Комментарий верен не на всякой версии, и это худший вид неверности. На fastify 5.12.4, которым снят прогон, trustProxy: 1 не читает X-Forwarded-For вовсе: в request.ip приходит адрес соединения, то есть адрес контейнера прокси — ровно тот случай, от которого комментарий обещал спасти. Ограничитель на такой настройке ведёт себя как средняя строка модели: десять запросов в минуту на весь интернет, и первый же перебор закрывает вход остальным.

Значение trustProxyПрокси дописал адресКлиент подделал слеваЗапрос пришёл мимо прокси
нет вовсеадрес проксиадрес проксинастоящий адрес того, кто пришёл
trueадрес, дописанный проксиподделка клиентаподделка клиента
1 (fastify 5.12.4)адрес проксиадрес проксинастоящий адрес того, кто пришёл
"127.0.0.1" — адрес прокси строкойадрес, дописанный проксиадрес, дописанный проксинастоящий адрес того, кто пришёл

Ветку с числом переписали в патче 5.12.1: на 5.12.0 она ещё читала счётчик прыжков. Сравнение двух версий одного файла объясняет прогон лучше любого пересказа.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Причина названа в самом комментарии: счётчик прыжков не проверяет, кто именно соединился, поэтому клиент, достучавшийся до приложения напрямую, набирает нужное число прыжков сам. На 5.11.0 — той версии, что стоит в apps/api учебника, — число работало как задумано: request.ip брал адрес, дописанный доверенным прыжком. Но и там запрос мимо прокси получал в request.ip свою же подделку; проверено запуском на обеих версиях.

Что писать вместо числа: адрес или подсеть прокси строкой (trustProxy: "172.18.0.0/16") либо свою функцию. Тогда настройка отвечает не на вопрос «сколько прыжков читать», а на вопрос «кому верить», и ответ перестаёт зависеть от версии. Обновление патча не должно менять смысл строки, от которой зависит защита, — и убедиться в этом можно единственным способом: прогоном, который печатает request.ip.

Сеть — половина замка. Ограничитель верит прокси только потому, что мимо прокси до приложения не добраться: у сервиса api нет опубликованных портов. Открыли порт «на время отладки» — и адрес в ключе снова стал тем, что прислал клиент.

Сертификат — единственная часть выкладки, которую вы не пишете и почти не настраиваете: Caddy получает его сам по ACME и сам продлевает. Нужны три вещи: публичное имя, A-запись этого имени на адрес сервера и доступный снаружи вход. Проверку центр проводит сам, постучавшись к вам по этому имени, и Caddy умеет обе её разновидности — по HTTP на порту 80 и по TLS на 443. Порт 80 нужен и после выпуска: с него уезжает редирект на HTTPS для всех, кто пришёл по привычке, причём редирект 308, а не 301, — при нём сохраняются и метод, и тело запроса. И HTTPS нужен не только ради шифрования: Chrome не поставит приложение с сайта без него, а localhost считается безопасным ровно для того, чтобы разработка обходилась без сертификата.

Посмотреть на выпуск можно и не на боевой машине — с одной оговоркой, о которой сразу после. В конфиге прогона два блока и высокие порты вместо 80 и 443 — на ноутбуке настоящие заняты не нами. Прогноз: сколько сертификатов возьмёт Caddy при первом старте и что он скажет про порт без TLS?

запись с машины: caddy 2.11.4 из Homebrew, macOS 27 на arm64, 28.09.2026; запуск в пустом хранилище, остановлен сигналом через несколько секунд после выпуска. Сертификат здесь выдал внутренний CA самого Caddy (local_certs): в имя .localhost никакой ACME не поверит, а публичного имени и открытого порта 80 у ноутбука нет. На боевой машине те же строки печатает образ caddy:2 в docker compose logs caddy, но issuer у выпуска — ACME Let's Encrypt · 28 сентября 2026
Первый старт Caddy в пустом хранилище: журнал от чтения конфига до остановки по сигналу
# Тот же каркас, что на боевой машине: блок www только редиректит на apex,
# весь контент живёт на одном хосте. Отличия локального запуска — три строки
# в глобальном блоке и высокие порты.
{
	# на боевой машине сертификат выдаёт ACME по публичному имени; здесь —
	# собственный CA Caddy, потому что в имя .localhost никакой ACME не поверит
	local_certs
	# корневой сертификат этого CA в системное хранилище НЕ ставим
	skip_install_trust
	admin off
	http_port 3932
	https_port 3933
}

www.jsp.localhost:3933 {
	redir https://jsp.localhost{uri} permanent
}

jsp.localhost:3933 {
	handle /api/* {
		reverse_proxy 127.0.0.1:3931
	}

	handle {
		reverse_proxy 127.0.0.1:3931
	}
}
caddy run --config Caddyfile

Сертификатов два — по одному на имя, включая то, которое только редиректит: чтобы ответить редиректом по HTTPS, сертификат нужен и ему. Дальше по журналу видно устройство входа. На порту HTTPS (здесь 3933, на боевой машине 443) поднят отдельный слушатель HTTP/3 и объявлены протоколы h1, h2, h3; на порту без TLS (3932 вместо 80) и h2, и h3 пропущены с пометкой «requires TLS» — согласовать версию там нечем. Выпуск идёт под замком на имя (acquiring lock, lock acquired) — два процесса с одним хранилищем не полезут за одним сертификатом вдвоём. А последние строки — остановка по сигналу: «servers shutting down with eternal grace period», то есть вход по умолчанию ждёт дослуживающие запросы без ограничения по времени. Что это значит для приложения и почему у него срок должен быть, разбирает «Остановка: перестать принимать, дослужить, закрыть».

Теперь оговорка. Издатель в этих строках — local: сертификат выписал собственный центр Caddy, потому что в имя .localhost никакой ACME не поверит, а публичного имени у ноутбука нет. На боевой машине те же строки печатаются с издателем ACME, и разница между ними одна — имя издателя. Поэтому прогон помечен как снятый руками: выпуск настоящего сертификата воспроизводится только там, где есть публичное имя.

Что нужно для сертификатаЧего не будет без этого
A-запись имени на адрес серверацентру некуда стучаться: попытки будут повторяться, а браузер — ругаться на сертификат
порты 80 и 443, открытые снаружипроверять имя нечем: обе проверки идут снаружи, а с закрытым 80 теряется ещё и редирект с http
имя в конфиге, а не адресна голый адрес Caddy выпишет сертификат своего внутреннего центра, а не публичного, и браузер ему не поверит
том под хранилище /dataкаждое пересоздание контейнера — новый выпуск, а повторные выпуски упираются в ограничения центра

И про версии протокола, раз журнал их назвал. Наружу вход отдаёт h2 и h3 — в compose для этого опубликованы и 443/tcp, и 443/udp, — а до приложения запрос доезжает по HTTP/1.1; это видно в прогоне с двумя входами одного прокси. Поэтому ваш сервер из главы 4, знающий только HTTP/1.1, остаётся честной моделью того, что происходит в контейнере.

Том с сертификатами стоит в compose не для порядка: потеряли /data — потеряли выпущенное, а новые выпуски упираются в ограничения центра. Теряют этот том чаще всего вместе с «давайте пересоздадим всё с нуля».

Осталось место, где Caddy работает не прокси, а файловым сервером: путь /app с веб-версией мобильного приложения. Здесь handle_path срезает префикс, file_server читает диск, а рядом с каждым текстовым файлом на этапе сборки образа положены .br и .gz — precompressed отдаёт готовый вариант по Accept-Encoding клиента и не тратит процессор на сжатие каждого ответа.

И ровно здесь живёт ловушка, из-за которой у хешированных имён и появилась отдельная ветка. Заголовок кеша выставляется по адресу запроса, то есть до того, как выяснилось, есть ли такой файл. Прогноз: что получит браузер, попросив чанк, которого на диске уже нет, если на все адреса стоит один фолбэк на оболочку?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Пропавший чанк с общим фолбэком получает 200, тело index.html и заголовок «неизменяемо, год» — по адресу, который кончается на .js. Браузер падает на Unexpected token '<' и держит этот ответ год, а кеш service worker кладёт его к себе, потому что ответ успешный. Приложение после этого не загружается ни у кого, кто попал в окно выката, и чинится только сменой адреса. Отдельная ветка без фолбэка отвечает 404 — и это правильный ответ: отсутствующий файл остаётся отсутствующим.

Отсюда две ветки в конфиге вместо одной: у хешированных имён (/_expo и /assets) своя, с годовым immutable и без try_files, у всего остального — no-cache и фолбэк на оболочку, потому что адрес страницы и должен отдавать оболочку. А handle_errors снимает кеш с любой ошибки: 404, закешированный на год, — это файл, который появился через секунду после выката и до которого браузер больше не пошёл.

Вход собран: одна дверь, граница по путям, сертификат, честный адрес клиента и заголовки кеша, которые не врут. Чего у него ещё нет — ответа на вопрос «а почему 502?». За этим следующая статья, где журнал прокси и журнал приложения читаются вместе.

  • Наружу опубликован только прокси: у приложения нет строки ports вовсе, и это половина защиты ограничителя, а не деталь конфига.
  • Граница между контейнерами у автора идёт по путям одного домена: /api и /trpc — в приложение, /uploads — в хранилище, /app — файлы с диска, остальное — в контейнер с учебником. Второе имя только редиректит на apex, а порядок блоков в конфиге не порядок проверки: побеждает самый длинный путь.
  • До приложения едет не тот запрос, что прислал клиент: адрес, схема и хост приезжают в X-Forwarded-*, и по умолчанию Caddy затирает в них клиентские выдумки. Абсолютные адреса приложение всё равно собирает из настройки, а не из запроса.
  • request.ip — не факт, а следствие настройки: true берёт выдумку клиента, число на fastify 5.12.4 не читает цепочку вовсе, и только адрес прокси строкой даёт тот адрес, который дописал доверенный прыжок.
  • Сертификат берёт и продлевает вход: нужны публичное имя, A-запись и открытый порт 80, а хранилище — том, который нельзя терять. Наружу идут h2 и h3, до приложения — HTTP/1.1.
  • Заголовок кеша ставится по адресу до проверки файла, поэтому у хешированных имён своя ветка без фолбэка, а с ошибок кеш снимается всегда.

1/4Клиент прислал X-Forwarded-For: 1.2.3.4, перед Caddy никого нет, trusted_proxies не настроен. Что увидит приложение?

2/4В приложении на fastify 5.12.4 стоит trustProxy: 1. Что окажется в request.ip у запроса, пришедшего через прокси?

3/4В конфиге handle без матчера написан первым, а handle /api/* — последним. Куда уедет запрос /api/health?

4/4Почему у хешированных имён под /app своя ветка без try_files?

Адрес, по которому считает ограничитель

Напишите адресКлиента: она получает цепочку X-Forwarded-For строкой, адрес соединения и список адресов своего прокси, а возвращает адрес, по которому ограничитель будет вести счёт. Доверять можно одному прыжку — тому, кто соединился с сервером, — и только если этот адрес есть в списке. Список случаев и печать не трогайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Проверок две, и первая — про соединение, а не про заголовок. Если соединился не наш прокси, цепочку читать нельзя вовсе: её целиком написал тот, кто пришёл. Если наш — доверяем ровно одному прыжку, то есть берём последний элемент цепочки: его дописал прокси, а всё, что левее, приехало снизу.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Меняются только первые две строки вывода: подделка слева отброшена, в ключ идёт 203.0.113.9. Третий случай — прокси не дописал ничего (так не бывает, но падать на этом нельзя), поэтому filter(Boolean) и запасной вариант. Последние две строки не меняются, и это не недоработка: запрос пришёл мимо прокси, и его собственный адрес — единственное, чему тут можно верить. Обратите внимание, что от подделки в пятом случае защищает не эта функция, а отсутствие опубликованного порта у приложения.

Правило для фотографий

Допишите в список правил два: корень бакета (/uploads/ ровно) наружу отдавать нельзя — это запрос листинга; объект под тем же префиксом уезжает в хранилище, и раз его имя неизменяемо, ответ можно кешировать на год. Правило с флагом точный сравнивает путь целиком, остальные — по началу; функцию решить и список путей не трогайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Оба правила про один и тот же префикс, поэтому сортировка их не разведёт: у них одинаковая длина пути, а sort устойчив и сохраняет порядок объявления. Значит, точное правило пишется первым — иначе объектное поймает и корень.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В боевом конфиге это те же два правила, только матчерами: @bucketRoot path /uploads/ с respond 404 стоит выше reverse_proxy minio:9000, а годовой immutable висит на @bucketObject not path /uploads/. Второй матчер нужен ровно затем, чтобы 404 корня не уехал клиенту с пометкой «неизменяемо, год».

Ссылка в письме из-за прокси

Ссылку на сброс пароля собирает приложение, которое стоит за прокси: ни своей схемы, ни своего имени оно не знает. Сейчас адрес собирается из запроса. Перепишите ссылкаСброса так, чтобы адрес в письме не зависел ни от одного заголовка.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Из запроса тут верить нельзя ничему: x-forwarded-proto зависит от настройки доверия на входе, а host приложение проверить не может — оно не знает, отсекает ли прокси чужое имя. У автора отсекает, потому что имя названо в конфиге; у входа с матчером-звёздочкой или при прямом доступе к порту приложения — нет. Поэтому базовый адрес берётся из настройки, а аргумент запрос в теле больше не нужен.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

До правки вывод показывает обе беды сразу: во второй строке схема съехала на http, потому что доверенного заголовка не было, а в третьей письмо уводит на evil.example — достаточно, чтобы запрос с таким Host доехал до приложения. Письмо со ссылкой сброса — это пароль, который сервер сам разослал по почте, и адрес в нём подделывать нельзя тем более. В API автора база берётся из переменной APP_URL.