Перейти к содержимому
WebShield Docs

Доступ к серверу только через WebShield

Как закрыть свой сервер от прямых обращений по секретному заголовку WebShield — примеры для nginx, Apache, IIS, Caddy и других, плюс автобан через fail2ban.

Домен вы спрятали за WebShield, а IP-адрес сервера остался прежним. Кто его знает — старые DNS-записи, утёкший заголовок письма, скан подсети, — тот обращается к сайту напрямую и проходит мимо защиты целиком.

Лечится это так: сервер перестаёт отвечать всем, кроме нас.

К каждому запросу, который мы отправляем на ваш сервер, добавляется заголовок с секретом вашего хоста:

X-WebShield-Origin-Token: wso_…

Ваш сервер проверяет заголовок и отвечает только на запросы с ним. Всё остальное — отказ. Заголовок посетителя с тем же именем до вас не дойдёт никогда: мы подставляем своё значение поверх.

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

Токен — в личном кабинете: Хосты → нужный хост → Доступ только через WebShield. Значение у каждого хоста своё.

  1. Весь трафик уже идёт через нас. В DNS запись сайта проксируется, прямых A-записей на сервер не осталось. Иначе вы закроете сайт от собственных посетителей.
  2. У вас есть запасной вход. SSH, панель хостинга — что угодно, чем откатить правку, не открывая сайт.

Про сертификат беспокоиться не нужно: проверка Let’s Encrypt приходит на ваш домен, то есть к нам, и мы передаём её на сервер тем же путём и с тем же заголовком. Отдельное исключение для /.well-known/acme-challenge/ не требуется — а если его сделать, вы оставите приоткрытой ровно ту дверь, которую пришли закрывать.

Прямой запрос должен получить отказ, запрос через нас — работать:

Окно терминала
# Напрямую по адресу сервера — ожидаем 403.
curl -sk -o /dev/null -w '%{http_code}\n' https://203.0.113.10/ -H 'Host: example.com'
# Через WebShield — ожидаем нормальный ответ.
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/

Код ответа выбираете вы: 403 честнее 401 (никакой авторизацией тут не пахнет), а 444 в nginx вообще обрывает соединение молча. Для сайта разницы нет — до посетителей эти ответы не доходят, их видят только те, кто пришёл мимо нас.

server {
server_name example.com;
# Не от WebShield — дальше не идём.
if ($http_x_webshield_origin_token != "wso_ВАШ_ТОКЕН") { return 403; }
# ... ваша обычная конфигурация: proxy_pass, root, fastcgi_pass ...
}

Проверить и применить: nginx -t && systemctl reload nginx.

Apache 2.4, в конфигурации сайта или в .htaccess — дополнительных модулей не нужно:

<If "%{HTTP:X-WebShield-Origin-Token} != 'wso_ВАШ_ТОКЕН'">
Require all denied
</If>

Проверить и применить: apachectl configtest && systemctl reload apache2.

Тот же синтаксис подходит для LiteSpeed и OpenLiteSpeed с включённой совместимостью с .htaccess.

example.com {
@notWebShield not header X-WebShield-Origin-Token "wso_ВАШ_ТОКЕН"
respond @notWebShield 403
reverse_proxy localhost:3000
}

Применить: caddy reload --config /etc/caddy/Caddyfile.

Нужен модуль URL Rewrite. В web.config сайта:

<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="WebShield only" stopProcessing="true">
<match url=".*" />
<conditions>
<add input="{HTTP_X_WEBSHIELD_ORIGIN_TOKEN}"
pattern="^wso_ВАШ_ТОКЕН$" negate="true" />
</conditions>
<action type="CustomResponse" statusCode="403" statusReason="Forbidden"
statusDescription="Forbidden" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

Токен добавляется прямо в правило роутера:

http:
routers:
site:
rule: "Host(`example.com`) && Header(`X-WebShield-Origin-Token`, `wso_ВАШ_ТОКЕН`)"
service: site

Запрос без токена под правило не подходит, маршрута для него нет — Traefik отвечает 404.

frontend https-in
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
acl from_webshield req.hdr(X-WebShield-Origin-Token) -m str wso_ВАШ_ТОКЕН
http-request deny deny_status 403 unless from_webshield
default_backend site
const TOKEN = process.env.WEBSHIELD_ORIGIN_TOKEN;
app.use((req, res, next) => {
if (req.get("x-webshield-origin-token") === TOKEN) return next();
res.status(403).end();
});

Токен держите в переменной окружения, а не в репозитории.

В самом начале точки входа (index.php), до подключения фреймворка:

$token = getenv('WEBSHIELD_ORIGIN_TOKEN');
$sent = $_SERVER['HTTP_X_WEBSHIELD_ORIGIN_TOKEN'] ?? '';
if (!hash_equals($token, $sent)) {
http_response_code(403);
exit;
}

Для WordPress, Битрикса и прочих готовых систем правку лучше делать не в коде, а на уровне веб-сервера — обновление CMS её не затрёт.

Прослойкой WSGI/ASGI — до маршрутизации приложения:

import os
from hmac import compare_digest
TOKEN = os.environ["WEBSHIELD_ORIGIN_TOKEN"]
class WebShieldOnly:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
sent = environ.get("HTTP_X_WEBSHIELD_ORIGIN_TOKEN", "")
if not compare_digest(sent, TOKEN):
start_response("403 Forbidden", [("Content-Type", "text/plain")])
return [b"Forbidden"]
return self.app(environ, start_response)

Отказ — это уже не ошибка, а улика: до вашего сервера напрямую не достучится ни один живой посетитель, все они приходят через нас. Значит, адрес в таком логе принадлежит тому, кто ищет ваш origin. Его можно банить, не разбираясь, — и сразу надолго.

Пишите отказы в отдельный файл, чтобы не вылавливать их из общего лога.

nginx. Формат объявляется рядом с остальными (контекст http), проверка переезжает внутрь location:

log_format ws_denied '$remote_addr $host "$request" $status';
server {
server_name example.com;
location / {
if ($http_x_webshield_origin_token != "wso_ВАШ_ТОКЕН") {
access_log /var/log/nginx/origin-denied.log ws_denied;
return 403;
}
# ... ваша обычная конфигурация ...
}
}

Apache. Условное логирование по той же проверке:

SetEnvIfExpr "%{HTTP:X-WebShield-Origin-Token} != 'wso_ВАШ_ТОКЕН'" ws_denied
CustomLog /var/log/apache2/origin-denied.log common env=ws_denied
<If "%{HTTP:X-WebShield-Origin-Token} != 'wso_ВАШ_ТОКЕН'">
Require all denied
</If>

Фильтр — /etc/fail2ban/filter.d/webshield-origin.conf. В файл попадают только отказы, поэтому ловить достаточно адрес в начале строки:

[Definition]
failregex = ^<HOST>
ignoreregex =

Правило — /etc/fail2ban/jail.d/webshield-origin.conf:

[webshield-origin]
enabled = true
filter = webshield-origin
logpath = /var/log/nginx/origin-denied.log
maxretry = 1
findtime = 1d
bantime = 30d
banaction = nftables-allports

maxretry = 1 здесь не опечатка: второго шанса такому адресу давать незачем. banaction выберите под свой файрвол — nftables-allports, iptables-multiport или ufw.

Проверить, что правило подхватилось: fail2ban-client status webshield-origin.

Отказы попадают в общий access.log — там они неотличимы от чужих 403 вашего приложения, и фильтр ^<HOST> забанит всех подряд, включая живых посетителей. Нужна примета, по которой строку опознать.

nginx — отвечать 444. Соединение обрывается молча, а в лог идёт статус, которого не вернёт ни одно приложение:

if ($http_x_webshield_origin_token != "wso_ВАШ_ТОКЕН") { return 444; }
[Definition]
failregex = ^<HOST> -.*" 444 \d+
ignoreregex =
[webshield-origin]
enabled = true
filter = webshield-origin
logpath = /var/log/nginx/access.log
maxretry = 1
findtime = 1d
bantime = 30d
banaction = nftables-allports

nginx — оставить 403 и дописать метку в формат лога. Пригодится, если 444 уже занят другими правилами:

log_format ws_combined '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" $ws_denied';
server {
server_name example.com;
access_log /var/log/nginx/access.log ws_combined;
# Объявление обязательно: иначе nginx ругается на неизвестную переменную.
set $ws_denied "-";
if ($http_x_webshield_origin_token != "wso_ВАШ_ТОКЕН") {
set $ws_denied "ws-denied";
return 403;
}
# ... ваша обычная конфигурация ...
}

Apache — та же метка через переменную окружения:

SetEnvIfExpr "%{HTTP:X-WebShield-Origin-Token} != 'wso_ВАШ_ТОКЕН'" ws_denied=ws-denied
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %{ws_denied}e" ws_combined
CustomLog ${APACHE_LOG_DIR}/access.log ws_combined

В обоих случаях строка отказа заканчивается словом ws-denied, у остальных там прочерк. Фильтр ловит именно её:

[Definition]
failregex = ^<HOST> .* ws-denied$
ignoreregex =

Правило то же, только logpath указывает на общий лог.

Кнопка Перевыпустить в карточке хоста выдаёт новое значение, старое перестаёт работать в течение минуты.

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

Перевыпускайте, если значение попало в общий репозиторий, в скриншот или в переписку.

  • 403 у всех посетителей. Токен на сервере не совпадает с тем, что в личном кабинете, — сравните значения целиком, включая префикс wso_.
  • Не работает часть путей. Правило применили не ко всему сайту: у вложенных location, <Directory> или маршрутов бывает своя проверка.
  • Файлы в CDN отдаются, а страницы нет (или наоборот). Некоторые панели обслуживают статику отдельным сервером — правило нужно и там.

Токен защищает от обращений мимо нас, но не заменяет файрвол: закрытые от интернета порты базы данных, панели и SSH остаются вашей задачей.