<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>xmlrpc &#8211; REMADMIN</title>
	<atom:link href="https://remadmin.com/tags/xmlrpc/feed/" rel="self" type="application/rss+xml" />
	<link>https://remadmin.com</link>
	<description>Удалённый системный администратор</description>
	<lastBuildDate>Tue, 22 Sep 2026 20:30:05 +0000</lastBuildDate>
	<language>ru-RU</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>
	<item>
		<title>Application Passwords vs XML-RPC: что оставлять и как не открыть лишнюю дыру</title>
		<link>https://remadmin.com/blog/vebmasteru/application-passwords-vs-xml-rpc-chto-ostavlyat-i-kak-ne-otkryt-lishnjuju-dyru/</link>
					<comments>https://remadmin.com/blog/vebmasteru/application-passwords-vs-xml-rpc-chto-ostavlyat-i-kak-ne-otkryt-lishnjuju-dyru/#respond</comments>
		
		<dc:creator><![CDATA[Cursor Blog]]></dc:creator>
		<pubDate>Tue, 22 Sep 2026 20:30:05 +0000</pubDate>
				<category><![CDATA[Вебмастеру]]></category>
		<category><![CDATA[Application Passwords]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Wordpress]]></category>
		<category><![CDATA[xmlrpc]]></category>
		<category><![CDATA[безопасность]]></category>
		<category><![CDATA[логи]]></category>
		<category><![CDATA[Уязвимость]]></category>
		<category><![CDATA[шпаргалка]]></category>
		<guid isPermaLink="false">https://remadmin.com/?p=7429</guid>

					<description><![CDATA[XML-RPC и Application Passwords — разные входы. Как я закрываю xmlrpc.php на nginx, почему фильтр xmlrpc_enabled не считается закрытием, и как не оставить токен приложения с правами администратора.]]></description>
										<content:encoded><![CDATA[<p>Типичная заявка: «поставил плагин безопасности, галочка Disable XML-RPC стоит, 2FA на админке включена, а в логах всё равно долбят xmlrpc.php». Вариант помягче: «выдал подрядчику логин администратора, чтобы скрипт заливал новости, потом сменил пароль — скрипт отвалился, а через неделю на сайте чужие черновики». Ещё чаще путают двери: закрыли xmlrpc, зато в профиле пользователя висит Application Password с именем «test», который никто не отзывал полгода.</p>
<p>Это не одна настройка. XML-RPC — старый вход в PHP-файл. Application Passwords — отдельные пароли к REST API (и, если xmlrpc не закрыт, ещё и к нему же). Двухфакторка на <code>wp-login.php</code> вторую дверь не закрывает. Ниже — как я решаю, что оставлять, что резать на nginx, и где «плагин запретил xmlrpc» оставляет дыру, которой хватает боту.</p>
<p>Хосты в примерах вымышленные: <code>shop.example</code>. Свои пути, пользователя и префикс логов подставляйте.</p>
<h5>Две двери, не «один API WordPress»</h5>
<p>На живом сайте обычно торчат три способа что-то сделать «не из браузера»:</p>
<ul>
<li><code>/xmlrpc.php</code> — XML-RPC. Методы вроде <code>wp.getUsersBlogs</code>, <code>wp.newPost</code>, pingback. Клиент шлёт логин и пароль в теле XML на каждый запрос.</li>
<li><code>/wp-json/</code> — REST API. Gutenberg, приложение, ваш скрипт, куча плагинов. С WordPress 5.6 туда ходят Application Passwords через HTTP Basic Auth.</li>
<li><code>/wp-login.php</code> — человек в браузере. Сюда Application Password <em>не подходит</em>: это не замена пароля учётки для формы входа.</li>
</ul>
<p>Путаница начинается здесь. Человек включает 2FA и считает, что «без телефона никто не войдёт». Для формы логина — да. Для xmlrpc, если он принимает основной пароль пользователя, второго фактора нет. Бот не открывает браузер и не вводит код из приложения.</p>
<p>Вторая путаница: «раз есть Application Passwords, xmlrpc больше не нужен, WordPress сам его выключит». Не выключит. Это разные стеки. Пароль приложения, если xmlrpc жив, подходит и туда: в документации ядра прямо сказано, что Application Password можно подставить вместо обычного пароля в XML-RPC. То есть вы завели «безопасный» токен для REST, а старый файл его тоже съест.</p>
<p>Третья: галочка в плагине «Disable XML-RPC». Часто это фильтр <code>xmlrpc_enabled</code>. Он отключает методы, которым нужна авторизация. Файл при этом открывается, PHP крутится, pingback и часть методов без логина могут отвечать. Для бота, которому нужно занять php-fpm, этого достаточно. Для меня критерий «закрыто» — nginx/apache не отдают этот URI в PHP, а не «в админке галочка зелёная».</p>
<h5>Зачем xmlrpc вообще существует в 2026-м</h5>
<p>Когда-то это был нормальный способ писать с телефона и цеплять Jetpack. Сейчас на большинстве витрин, которые мне отдают, в access.log по <code>xmlrpc.php</code> нет ни одного своего User-Agent. Есть словари паролей, <code>system.multicall</code> и pingback на чужие сайты.</p>
<p>Две атаки, из-за которых я не оставляю файл «на всякий случай»:</p>
<ul>
<li>Брутфорс пачкой. <code>system.multicall</code> в одном HTTP-запросе прогоняет десятки <code>wp.getUsersBlogs</code> с разными паролями. Лимитер по запросам на IP видит один POST. Лимит без закрытия multicall — полумера: словарь укладывается в один хит.</li>
<li>Pingback как чужой молоток. Сайт принимает <code>pingback.ping</code> и сам идёт на URL, который подсунули. Либо участвует в отражённой нагрузке, либо светит внутренние адреса, если кто-то целится в SSRF. Даже без успешного логина файл уже не бесплатный.</li>
</ul>
<p>Jetpack, официальное приложение WordPress старых версий, часть сервисов рассылки и редкие десктопные клиенты xmlrpc ещё используют. Поэтому я не ставлю <code>deny all</code> вслепую на проде, пока не посмотрел неделю логов. Но «вдруг пригодится» — не причина держать вход, который умеет перебирать пароль администратора без 2FA.</p>
<h5>Что такое Application Passwords на практике</h5>
<p>С WordPress 5.6 у пользователя в профиле появляется блок «Пароли приложений». Вы даёте имя («backup-bot», «магазин-контент», «телефон редактора»), ядро показывает 24 символа один раз, дальше пароль лежит хешем в user meta. Пробелы в отображении можно не копировать — перед проверкой их срезают.</p>
<p>Зачем это, если «и так есть пароль админа»:</p>
<ul>
<li>Основной пароль не разъезжается по скриптам, телефону и подрядчику. Сменили пароль учётки после увольнения — бот с Application Password продолжает жить, пока его явно не отозвали. Это и плюс, и минус: ротация учётки дыру не закрывает.</li>
<li>Отзыв точечный. Удалили один пароль — отвалилось одно приложение, редактор в браузере не вылетел.</li>
<li>2FA на логине можно держать жёсткой. Скрипту второй фактор не нужен, человеку — нужен. Раньше из-за xmlrpc люди 2FA не включали: «приложение не зайдёт».</li>
<li>В профиле видно, когда пароль последний раз использовали и с какого IP. Не в реальном времени до секунды, с задержкой примерно до суток, но «висит полгода, last used — никогда» уже повод снести.</li>
</ul>
<p>Ходят они по HTTPS Basic Auth: логин учётки плюс этот пароль. Без HTTPS ядро по умолчанию блок даже не показывает: иначе пароль утечёт на первой же кофейне. Если сайт за прокси и WordPress думает, что он на http, в профиле пусто. Тогда сначала чинят <code>HTTPS</code>/<code>X-Forwarded-Proto</code>, а не «включают фильтр разрешить без SSL». Разрешить Application Passwords по http — сознательно оставить секрет в открытую на участке до прокси.</p>
<p>Права у такого пароля — права пользователя, не «только читать посты». Завели под администратором — получили администратора. Для заливки черновиков мне хватает роли автора или редактора и отдельной учётки, не «ещё один токен на admin».</p>
<h5>Как я смотрю, кто чем пользуется</h5>
<p>Пока нет картины за несколько дней, я ничего не отключаю на живом магазине. Сначала логи, потом решение.</p>
<p>Nginx, если access_log в обычном combined:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash"># пути к логам подставьте свои
awk '$7 ~ /xmlrpc\.php/ {print $1,$4,$9,$11}' /var/log/nginx/shop.example.access.log | tail -n 50
awk '$7 ~ /xmlrpc\.php/ {c[$1]++} END {for (i in c) print c[i], i}' /var/log/nginx/shop.example.access.log | sort -n | tail
awk '$7 ~ /wp-json\// {print $1,$9,$7}' /var/log/nginx/shop.example.access.log | awk '{print $3}' | sort | uniq -c | sort -n | tail</pre>
<p>Что я хочу увидеть:</p>
<ul>
<li>По xmlrpc: либо пусто, либо одни и те же /24 из сканеров, статус 200 на POST. Свой телефон обычно один-два IP, предсказуемый интервал, не 400 запросов в минуту.</li>
<li>User-Agent. Jetpack и приложение WordPress не маскируются под <code>Mozilla/5.0</code> с кривой кодировкой. Сканеры часто пишут <code>xmlrpc</code>, пусто или «WordPress» без версии.</li>
<li>По REST: Gutenberg с админки будет, это норма. Смотрю, есть ли POST/PUT с внешних IP на <code>/wp-json/wp/v2/posts</code> или media. Если есть — это живой клиент, его нельзя убить вместе с xmlrpc.</li>
</ul>
<p>Проверка «файл вообще отвечает», не из лога:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash">curl -sS -D - -o /dev/null https://shop.example/xmlrpc.php
curl -sS https://shop.example/xmlrpc.php \
  -H 'Content-Type: text/xml' \
  -d '&lt;?xml version="1.0"?&gt;&lt;methodCall&gt;&lt;methodName&gt;system.listMethods&lt;/methodName&gt;&lt;/methodCall&gt;' | head</pre>
<p>200 и список методов — дверь открыта, даже если «плагин безопасности установлен». 403/404 с nginx без <code>X-Powered-By</code> и без тела PHP — уже похоже на правду.</p>
<p>REST и доступность Application Passwords:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash">curl -sS https://shop.example/wp-json/ | python3 -m json.tool | head
# в корне смотрю ключ authentication.application-passwords
curl -sS -u 'editor:xxxx xxxx xxxx xxxx xxxx xxxx' \
  https://shop.example/wp-json/wp/v2/users/me</pre>
<p>Если в корне <code>authentication</code> пустой — либо HTTP, либо фича выключена фильтром, либо что-то по дороге срезает заголовок <code>Authorization</code>. Последнее на nginx с PHP-FPM встречается: CGI не пробрасывает Authorization, пока не сказать.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx"># внутри location ~ \.php$
fastcgi_param HTTP_AUTHORIZATION $http_authorization;</pre>
<p>Без этой строки «пароль приложения не работает, а обычный логин в браузере живой» — не баг WordPress, а FPM не видит Basic Auth.</p>
<h5>Если xmlrpc никому не нужен — режу на веб-сервере</h5>
<p>Это мой дефолт для витрины без Jetpack и без старого мобильного клиента. Фильтр в теме — вторым слоем, не вместо.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx"># server { ... }
location = /xmlrpc.php {
    access_log /var/log/nginx/shop.example.xmlrpc.denied.log;
    return 403;
}</pre>
<p><code>deny all</code> или <code>return 444</code> тоже работают. Мне удобнее 403 и отдельный лог: сразу видно, что сканеры не исчезли, просто больше не крутят PHP. На Apache в корне сайта или в конфиге хоста:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="apache">&lt;Files xmlrpc.php&gt;
    Require all denied
&lt;/Files&gt;</pre>
<p>Правило на WAF (если он есть) дублирует, не заменяет. Когда человек правит только Cloudflare и забывает origin, прямой IP сервера xmlrpc снова открыт.</p>
<p>После рестарта nginx проверяю тем же <code>curl</code>: не 200 со списком методов, а 403 без <code>text/html</code> от WordPress. Затем на час оставляю отдельный лог — если вдруг вылезет свой клиент, это будет видно по IP, а не «через неделю редактор звонит, что приложение молчит».</p>
<p>Плагины «выключить xmlrpc» я не ставлю как основную защиту. Лишний PHP на периметре, плюс половина из них делает только <code>xmlrpc_enabled</code>.</p>
<h5>Почему xmlrpc_enabled я не считаю закрытием</h5>
<p>В <code>functions.php</code> или mu-plugin часто копируют:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">add_filter( 'xmlrpc_enabled', '__return_false' );</pre>
<p>Это отключает авторизованные методы. Запрос всё равно доходит до WordPress: php-fpm воркер занят, плагины загрузились, объектный кеш дёрнули. Pingback на части сборок остаётся в списке. Сканер с <code>system.multicall</code> получает отказ, но уже после входа в PHP.</p>
<p>Если xmlrpc по какой-то причине оставляю, вычищаю хотя бы multicall и pingback:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">&lt;?php
// mu-plugins/disable-xmlrpc-extras.php
add_filter( 'xmlrpc_methods', function ( $methods ) {
    unset( $methods['system.multicall'], $methods['system.multitask'] );
    unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] );
    return $methods;
} );</pre>
<p>Это не «можно не закрывать файл». Это минимум, если Jetpack реально нужен и закрыть <code>location</code> нельзя. Плюс лимит запросов на этот URI отдельно от <code>admin-ajax.php</code> — иначе снова либо дыра, либо свои ловят 429.</p>
<h5>Когда xmlrpc оставляю</h5>
<p>Короткий список, не «все интеграции»:</p>
<ul>
<li>Jetpack и связанные сервисы Automattic, если они включены сознательно, а не «поставили и забыли».</li>
<li>Старое официальное приложение WordPress, которое ещё ходит в xmlrpc, а не в REST. Новые клиенты умеют Application Passwords и REST — их я перевожу.</li>
<li>Редкий десктопный клиент публикации, который заказчик не готов менять сегодня.</li>
</ul>
<p>Если оставляю, пароль приложения завожу под отдельным пользователем с нужной ролью, основной пароль этой учётки длинный и никуда не светится, 2FA на человеческих админах включена. xmlrpc тогда принимает Application Password, и утечка основного пароля админки не даёт боту этот вход. Обратное тоже верно: утечка пароля приложения — это вход в API с правами этой учётки, пока не отозвали.</p>
<p>Белый список IP на <code>xmlrpc.php</code> лучше, чем открытый мир. Для Jetpack это набор адресов Automattic, он плавает; без WAF с готовым правилом я не рисую его руками «по памяти» и не выкладываю чужие схемы. Практичнее: либо официальное правило у CDN, либо закрыть xmlrpc и жить на REST.</p>
<h5>Application Passwords: заводить, не размножать, уметь выключить</h5>
<p>Правила, без которых я токен не создаю:</p>
<ul>
<li>Одно приложение — один пароль. Не «один на все скрипты», чтобы потом не гадать, какой cron его светит.</li>
<li>Имя в профиле человеческое: <code>rsync-report bot</code>, <code>iPhone редактор</code>, не <code>test</code> и не <code>1</code>.</li>
<li>Учётка с минимальной ролью. Для черновиков не нужен администратор. Для медиа — часто хватает автора.</li>
<li>Пароль копирую в секрет хранилища один раз. В чат, в тикет и в тело статьи его не кладут. В логах nginx он не должен появиться: Basic Auth лучше не писать в access_log.</li>
<li>Раз в квартал смотрю last used. Не использовался — удаляю, не «вдруг пригодится».</li>
</ul>
<p>Через WP-CLI, если SSH есть:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash">cd /var/www/shop.example/public_html
wp user list --fields=ID,user_login,roles
wp user application-password list editor --fields=uuid,name,created,last_used,last_ip
# создать и сразу забрать только секрет, без таблицы
wp user application-password create editor 'content-bot' --porcelain
# отозвать по uuid из списка
wp user application-password delete editor 00000000-0000-4000-8000-000000000000</pre>
<p>Если Application Passwords на сайте никто не использует (нет мобильного клиента, нет внешних скриптов, черновики пишут руками), я их выключаю. Иначе любой администратор может завести себе токен, а через дырявый плагин или XSS это делают уже не только администраторы.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">&lt;?php
// mu-plugins/disable-application-passwords.php
add_filter( 'wp_is_application_passwords_available', '__return_false' );</pre>
<p>Точечно, только выбранным ролям:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="php">&lt;?php
add_filter( 'wp_is_application_passwords_available_for_user', function ( $allow, $user ) {
    if ( $user instanceof WP_User &amp;&amp; in_array( 'administrator', (array) $user-&gt;roles, true ) ) {
        return $allow;
    }
    return false;
}, 10, 2 );</pre>
<p>Выключить и забыть отозвать уже выданные пароли — мало. Фильтр закрывает новые и использование, но я всё равно прохожусь по списку и удаляю старые: меньше сюрпризов, когда фильтр кто-то снимет «чтобы приложение заработало».</p>
<h5>REST я не вырубаю «за компанию»</h5>
<p>После статьи про xmlrpc находится человек, который в плагине безопасности ставит Disable REST API. Gutenberg после этого рисует белый экран, кастомайзер полуживой, часть блоков не сохраняется. REST — не xmlrpc. Это штатный транспорт админки.</p>
<p>Что я ограничиваю, не выключая REST целиком:</p>
<ul>
<li>Публичный список пользователей. На многих сайтах <code>GET /wp-json/wp/v2/users</code> без авторизации отдаёт логины. Для брутфорса это подарок: не надо угадывать <code>admin</code>. Плагин безопасности или маленький mu-plugin, который на <code>rest_authentication_errors</code> закрывает <code>users</code> для анонимов, — нормальная гигиена. Слепой «REST off» — нет.</li>
<li>Лимит на <code>/wp-json/</code> на краю, отдельно от html, если вижу перебор. Не общий <code>location ~ \.php$</code>.</li>
<li>Не торчит ли создание Application Passwords для всех ролей, если фича нужна только админу.</li>
</ul>
<p>Проверка, светит ли сайт логины:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="bash">curl -sS 'https://shop.example/wp-json/wp/v2/users' | python3 -m json.tool | head</pre>
<p>Если в ответе массив с <code>slug</code> — аноним уже знает, кого подбирать. Это не «дыра нулевого дня», это дефолт, который я закрываю в тот же вечер, что и xmlrpc.</p>
<h5>Какие комбинации я считаю нормальными</h5>
<p>Свожу к трём схемам, без зоопарка галочек.</p>
<ol>
<li>Витрина, правки из браузера, нет Jetpack, нет внешних ботов. xmlrpc на nginx 403. Application Passwords выключены фильтром. 2FA на администраторах. REST живой, список users для гостя закрыт. Это большинство сайтов, которые мне отдают «просто посмотрите безопасность».</li>
<li>Нужен скрипт или телефон. xmlrpc всё равно 403, если клиент умеет REST. Одна учётка с ролью по задаче, один Application Password, HTTPS доведён до ядра, Authorization проброшен в FPM. Основной пароль этой учётки не совпадает с паролем приложения и никуда не записан в cron.</li>
<li>Jetpack или старый клиент, без них пока никак. xmlrpc открыт, multicall и pingback сняты, лимит на URI, по возможности ограничение по источнику на WAF. Пароли приложений для этого клиента, не основной пароль админа. Остальным пользователям Application Passwords не светятся.</li>
</ol>
<p>Схема, которую я снимаю сразу: 2FA «для галочки», xmlrpc открыт на мир, в cron лежит основной пароль администратора, в профиле три пароля приложения с именами test/test2/asdf, REST отдаёт users. Это не «чуть-чуть не донастроили». Это три входа с одним секретом и одним без второго фактора.</p>
<h5>Что проверить за ночь, а не сразу закрыть ноутбук</h5>
<p>После изменений я не верю одному <code>curl</code> с ноутбука. За ночь видно, не отвалился ли свой клиент и не начал ли сканер долбить соседнюю дверь.</p>
<ul>
<li>Отдельный лог 403 по xmlrpc: есть чужие POST, нет 200 в php-fpm slowlog по этому файлу.</li>
<li>В access.log нет всплеска 401/403 на <code>/wp-json/wp/v2/</code> с офисного IP. Если есть — вы закрыли не то, или срезали Authorization.</li>
<li>Админка открывается, запись сохраняется, медиа грузится. Gutenberg ходит в REST; если его прибили, узнаете утром от редактора, не из мониторинга.</li>
<li><code>wp user application-password list</code> по всем, у кого не пусто: имена понятные, last_ip свой, лишние удалены.</li>
<li>Повторный <code>curl</code> на <code>/wp-json/wp/v2/users</code> без куки — не список логинов.</li>
<li>Плагин безопасности не включил обратно xmlrpc своим «режимом совместимости» и не выключил REST. После обновления плагинов это проверяю отдельно.</li>
</ul>
<p>Если за ночь в xmlrpc-логе вдруг свой IP приложения — не открываю файл обратно «как было», а перевожу клиент на REST и Application Password. Старый xmlrpc ради одного телефона на витрине с заказами я оставляю только когда перевод реально нельзя сделать сегодня, и тогда это временная схема с датой, а не новая норма.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://remadmin.com/blog/vebmasteru/application-passwords-vs-xml-rpc-chto-ostavlyat-i-kak-ne-otkryt-lishnjuju-dyru/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Nginx: лимиты на wp-login и xmlrpc без отстрела нормальных пользователей</title>
		<link>https://remadmin.com/blog/vebmasteru/nginx-limity-na-wp-login-i-xmlrpc-bez-otstrela-normalnyh-polzovatelej/</link>
					<comments>https://remadmin.com/blog/vebmasteru/nginx-limity-na-wp-login-i-xmlrpc-bez-otstrela-normalnyh-polzovatelej/#respond</comments>
		
		<dc:creator><![CDATA[Cursor Blog]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 12:57:32 +0000</pubDate>
				<category><![CDATA[Вебмастеру]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Wordpress]]></category>
		<category><![CDATA[wp-login]]></category>
		<category><![CDATA[xmlrpc]]></category>
		<category><![CDATA[безопасность]]></category>
		<category><![CDATA[спам]]></category>
		<guid isPermaLink="false">https://remadmin.com/?p=7407</guid>

					<description><![CDATA[Как я режу брутфорс на wp-login.php и xmlrpc.php через nginx limit_req, не закрывая офис за NAT и не превращая отказ в 503 по всему PHP. Отдельные зоны, POST-only, real_ip и почему system.multicall лимитер не видит.]]></description>
										<content:encoded><![CDATA[<p>Типичная заявка: «сайт лежит, nginx отдаёт 503, в access.log сплошной wp-login.php». Открываю лог — не DDoS на главную, а брутфорс. Сотни POST в минуту на <code>wp-login.php</code> и <code>xmlrpc.php</code> с пары десятков адресов. Человек уже воткнул <code>limit_req</code> на весь <code>location ~ \.php$</code> «на всякий случай». Через час звонит редактор из офиса: не логинится, превью в админке крутится, мобильное приложение WordPress отвалилось.</p>
<p>Лимит сработал. Просто он отстрелил своих вместе с ботами. Офис сидит за одним белым IP, xmlrpc дергает приложение каждые несколько секунд, а общий php-location обслуживает и логин, и <code>admin-ajax.php</code>, и оформление заказа.</p>
<p>Ниже — как я режу именно логин и xmlrpc, оставляя сайт живым, и где <code>limit_req</code> бесполезен, сколько его ни крути. Без «поставьте плагин лимита логинов» и без слепого <code>deny all</code> на xmlrpc, пока не ясно, кто им пользуется.</p>
<h5>Что бьют на самом деле, и почему это две разные двери</h5>
<p>На типичном WordPress без WAF смотрят три URL:</p>
<ul>
<li><code>/wp-login.php</code> — форма входа. GET рисует форму, POST проверяет пароль.</li>
<li><code>/xmlrpc.php</code> — старый API. С него удобно перебирать пароли пачками через <code>system.multicall</code> и дёргать pingback.</li>
<li>Иногда <code>/wp-admin/</code> и REST <code>/wp-json/</code> — это уже другие истории, их в ту же зону, что логин, я не кладу.</li>
</ul>
<p>GET на логин — не атака. Человек открыл форму, браузер подтянул редирект, плагин 2FA нарисовал второй шаг. Если лимитировать любой метод, первый же заход «съедает» burst, а POST с паролем уже ловит 429. Поэтому ключ зоны я завязываю на POST, не на URL целиком.</p>
<p>xmlrpc почти всегда POST. Закрывать его наглухо можно, если им никто не пользуется: нет официального приложения WP, нет Jetpack, нет старых клиентов публикации. Если приложение нужно — <code>return 403</code> хуже брутфорса: свои отвалятся сразу, боты просто перейдут на <code>wp-login.php</code>.</p>
<h5>Почему общий limit_req на php убивает нормальных людей</h5>
<p><code>admin-ajax.php</code> на живом сайте стучит постоянно: пульс админки, автосохранение записи, корзина, фильтры каталога. Один редактор за минуту легко делает десятки POST. Посадили его в зону <code>1r/s burst=3</code> вместе с логином — получите «сайт тормозит», хотя CPU спокойный, а в логе 503 на ajax.</p>
<p>Вторая ловушка — общий IP. Офис, коворкинг, 4G-оператор с CGNAT, гостиница. Двадцать человек за одним адресом. Лимит «один запрос в секунду на IP» для брутфорса с ботнета ещё терпим, для офиса это коллективный бан после трёх неудачных паролей подряд.</p>
<p>Третья — статус по умолчанию. Nginx на превышение отдаёт 503. Мониторинг орёт «сайт мёртв», человек лезет в php-fpm и диск. Это не падение бэкенда, это отказ лимитера. Ставлю <code>limit_req_status 429</code> и в логах сразу видно, что именно отсекли, а не «бэкенд не отвечает».</p>
<h5>Две зоны, не одна</h5>
<p>Зоны объявляю в <code>http</code>, не в <code>server</code>. Размер <code>10m</code> для <code>$binary_remote_addr</code> — десятки тысяч IP, на обычный сайт с головой. Rate считаю отдельно:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx"># http { ... }
limit_req_zone $limit_key_wplogin zone=wplogin:10m rate=6r/m;
limit_req_zone $limit_key_xmlrpc  zone=xmlrpc:10m  rate=1r/s;
limit_req_status 429;
limit_req_log_level warn;</pre>
<p>Шесть POST в минуту на логин — человек с кривым паролем ещё влезет, словарь на тысячи попыток уже нет. xmlrpc чуть свободнее по частоте HTTP-запросов: живое приложение иногда стучит пачкой. Это не дыра — дыра в xmlrpc не частота, а толстое тело, про это ниже.</p>
<p>Ключ пустой строкой означает «этого запроса в зоне нет». Так я выключаю лимит для GET и для белого списка.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">map $request_method $limit_key_wplogin {
    POST $binary_remote_addr;
    default "";
}

# xmlrpc почти весь POST; GET на него мне не жалко тоже резать
map $request_method $limit_key_xmlrpc {
    default $binary_remote_addr;
}</pre>
<p>Если офис сидит на постоянном адресе, вычитаю его из ключа через <code>geo</code>. В примере — документационный диапазон, не чей-то боевой:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">geo $offnet {
    default 0;
    203.0.113.0/24 1;   # офис, подставьте свой
}

map $offnet $limit_key_wplogin {
    1      "";
    default $limit_wplogin_ip;
}

map $request_method $limit_wplogin_ip {
    POST $binary_remote_addr;
    default "";
}</pre>
<p>Два <code>map</code> на одну переменную nginx не даст — ключ собираю в одном месте. Рабочий вариант без сюрпризов:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">geo $is_office {
    default 0;
    203.0.113.0/24 1;
}

map "$is_office:$request_method" $limit_key_wplogin {
    "1:POST" "";
    "0:POST" $binary_remote_addr;
    default  "";
}

map $is_office $limit_key_xmlrpc {
    1       "";
    default $binary_remote_addr;
}</pre>
<p>Белый список — не «интернет офиса целиком». Только тот префикс, с которого реально заходят в админку. Домашний динамический IP туда не тащу: через месяц он уедет соседу.</p>
<h5>location: только эти два файла, тот же php, что у остальных</h5>
<p><code>location =</code> с точным именем бьёт точнее regex. Обработчик php не выдумываю — копирую то, что уже работает для <code>*.php</code>. На Debian это часто <code>snippets/fastcgi-php.conf</code> плюс ваш <code>fastcgi_pass</code>. Сокет не копируйте из чужой статьи: у вас может быть другой пул.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">server {
    # ...

    location = /wp-login.php {
        limit_req zone=wplogin burst=4 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;  # как у вас в общем php
    }

    location = /xmlrpc.php {
        limit_req zone=xmlrpc burst=8 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;
    }

    # общий php — БЕЗ limit_req этих зон
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;
    }
}</pre>
<p><code>burst=4 nodelay</code> на логине: четыре лишних POST проходят сразу, дальше 429, без очереди. Без <code>nodelay</code> nginx начинает задерживать запросы — форма «думает» секундами, человек жмёт ещё раз, вы сами себе множите POST. Для логина я предпочитаю жёсткий отказ, не задержку.</p>
<p>На xmlrpc burst больше: приложение может открыть несколько соединений подряд при синхронизации. Если xmlrpc вам не нужен совсем — не лимитируйте, закройте:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">location = /xmlrpc.php {
    return 403;
}</pre>
<p>Проверяю необходимость просто: <code>grep xmlrpc access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head</code> и user-agent. Если неделю стучат только словари и <code>Hello world</code> — закрываю. Если есть <code>wp-iphone</code> / <code>wp-android</code> с адресов редакции — оставляю зону.</p>
<h5>Настоящий IP, иначе режете Cloudflare, а не бота</h5>
<p><code>$binary_remote_addr</code> — это TCP-пир. Стоит сайт за Cloudflare, nginx-прокси или балансировщиком хостера — пир один на всех. Лимит превращается в глобальный рубильник: первый бот выжигает burst, посетители с того же эджа ловят 429.</p>
<p>Смотрю, откуда приходит запрос:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">log_format debugip '$remote_addr cf="$http_cf_connecting_ip" xff="$http_x_forwarded_for"';</pre>
<p>Дальше <code>set_real_ip_from</code> на префиксы своего прокси и <code>real_ip_header</code> — тот заголовок, которому вы доверяете. Не доверяю <code>X-Forwarded-For</code> с интернета без фильтра: его подставляет кто угодно. Для Cloudflare — их диапазоны и <code>CF-Connecting-IP</code>. Для своего nginx-прокси — только его адрес в <code>set_real_ip_from</code>.</p>
<p>Пока real_ip не настроен, <code>limit_req</code> на логин я не включаю в боевой <code>server</code>. Иначе «защита» — это лотерея, кто первый постучал в этот воркер.</p>
<h5>Где лимит бесполезен: system.multicall</h5>
<p>Брутфорс по xmlrpc часто укладывается в <em>один</em> HTTP POST. Внутри <code>system.multicall</code> — сотни <code>wp.getUsersBlogs</code> с разными паролями. Для nginx это один запрос. Зона <code>1r/s</code> его пропускает, php-fpm честно пережёвывает пачку, в <code>auth.log</code> WordPress — сотни неудачных логинов, в access.log — одна строка 200.</p>
<p>Поэтому «я поставил rate limit, xmlrpc больше не брутфорсят» — самообман, если multicall жив. Nginx <code>$request_body</code> в <code>if</code> на практике пустой: тело ещё не прочитано в той фазе, конфиг из гугла с <code>if ($request_body ~ multicall)</code> просто не срабатывает. Я это не рисую как рабочую схему.</p>
<p>Что работает без магии:</p>
<ul>
<li>xmlrpc не нужен — <code>return 403</code> на location, плюс фильтр в WordPress на всякий случай;</li>
<li>нужен только pingback — всё равно спорно, pingback сам по себе источник шума; чаще его выключают;</li>
<li>нужно приложение — оставляю xmlrpc под <code>limit_req</code> и отдельно отключаю multicall на стороне PHP (му-плагин на <code>xmlrpc_methods</code> выкидывает <code>system.multicall</code>), либо режу на WAF, который тело реально видит;</li>
<li>Application Passwords и REST — другая дверь, xmlrpc для них не обязателен. Если мобильный вход уже через REST, xmlrpc я закрываю, не лимитирую.</li>
</ul>
<p>Лимит по запросам и разбор методов xmlrpc — два разных слоя. Первый без второго на этой двери недоделан.</p>
<h5>Как проверить, что своих не задело</h5>
<p>Не тестирую с того же ноутбука, с которого админю. Иначе сам себя выкину и буду чинить по аварийной консоли. С другого адреса, лучше с маленького VPS:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic"># форма должна открываться сколько угодно
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-login.php; done

# POST — должны пойти 429 после burst
for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST \
    -d "log=nosuchuser&amp;pwd=wrong&amp;wp-submit=Log+In" \
    https://example.com/wp-login.php
done

# ajax и главная не в этой зоне
for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/wp-admin/admin-ajax.php
done</pre>
<p>Ожидание: GET логина — сплошные 200 (или 302, если уже есть редирект). POST — несколько 200/302, потом 429, не 503. ajax — без 429. Главная и карточки товара — без 429.</p>
<p>Потом то же с офисного IP, если он в <code>geo</code>: POST не должен упираться в 429, иначе белый список не попал в ключ. Частая ошибка — <code>geo</code> по <code>$remote_addr</code> до <code>real_ip</code>, и офис виден как адрес Cloudflare.</p>
<p>В логе ищу не «много 404», а явно статус и зону. С <code>limit_req_log_level warn</code> в error.log будет <code>limiting requests, excess: ... zone=wplogin</code>. Если зоны в логе нет, а 503 есть — вы смотрите не на лимитер, а на php-fpm/upstream.</p>
<h5>Что я не кладу в эти зоны</h5>
<ul>
<li><code>admin-ajax.php</code>, <code>async-upload.php</code>, cron <code>wp-cron.php</code> — это не брутфорс логина.</li>
<li>REST <code>/wp-json/</code> целиком. Там и легитимный редактор, и Application Passwords. Если резать — отдельные location на конкретные маршруты, не оптом.</li>
<li><code>wp-admin/</code> GET статики и <code>admin-post.php</code> с авторизованной сессией.</li>
<li>Статику и <code>wp-content/uploads</code> — к логину отношения не имеет.</li>
</ul>
<p>Отдельно: плагины «кастомной страницы входа» часто POST-ят не на <code>/wp-login.php</code>, а на <code>/</code> или на <code>/my-account/</code>. Тогда мой location логин не защищает, зато WooCommerce-чекаут внезапно оказывается под лимитом, если кто-то прикрутил зону на <code>location /</code>. Смотрю в access.log реальный URI при неудачном входе, не название плагина на коробке.</p>
<h5>fail2ban рядом, не вместо</h5>
<p>Nginx лимитер не помнит «этот IP вчера перебирал пароли». Он держит короткое окно. fail2ban по error.log с <code>zone=wplogin</code> или по auth-логу WordPress закрывает упёртых на часы. Но jail, который банит после трёх 429, снова отстрелит офис. Либо офис в ignoreip, либо порог заметно выше человеческого «забыл пароль, нажал пять раз».</p>
<p>Я не баняю 429 с ajax и не баняю 404 на <code>xmlrpc.php</code>, если сами его закрыли 403: боты будут стучать вечно, jail раздует ipset, а толку ноль — они и так получают отказ.</p>
<h5>Что оставляю на обычном сайте</h5>
<p>Рабочий минимум, которым я заканчиваю, если это один WordPress на VPS без приложения:</p>
<ul>
<li>real_ip настроен, в логе виден клиент, не эдж;</li>
<li><code>wp-login.php</code> — POST-only зона ~6r/m, burst 4 nodelay, статус 429;</li>
<li><code>xmlrpc.php</code> — 403, если за неделю нет своих клиентов;</li>
<li>если клиент есть — зона 1r/s + выключенный <code>system.multicall</code> в PHP;</li>
<li>офисный префикс в <code>geo</code>, не «весь провайдер»;</li>
<li>общий php-location без этих зон;</li>
<li>проверка циклом curl с чужого IP и глазами error.log, что падает зона, а не 503 от fpm.</li>
</ul>
<p>Плагин лимита логинов в WordPress я не считаю заменой: он срабатывает уже внутри PHP, после того как fpm взял воркер. Под словарём это дороже, чем отказ на nginx. Плагин имеет смысл как второй слой (капча, временная блокировка учётки), не как единственный.</p>
<p>Если после включения 429 сыпятся на живых редакторов — не поднимаю rate «пока не пройдёт». Сначала смотрю, какой URI и какой IP. Почти всегда это либо общий php-location, либо NAT офиса, либо xmlrpc приложения, которое я забыл, что оно существует.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://remadmin.com/blog/vebmasteru/nginx-limity-na-wp-login-i-xmlrpc-bez-otstrela-normalnyh-polzovatelej/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>