REST API وردپرس چه چیزی از سایتت علنی می‌کند؛ آنچه با یک درخواست ساده خوانده می‌شود

امید بداق عمومی بدون دیدگاه ۲۱ دقیقه مطالعه

این مقاله عضو خوشهٔ «امنیت هاست و وردپرس» است و خواهرِ همان چک‌لیست امنیتی؛ آنجا گفتم این مسیر را محدود کن، اینجا نشان می‌دهم دقیقاً چه چیزی را باید ببندی و بهای هر انتخاب چیست.

حدس نمی‌زنم. چند هفته پیش برای چیدن یک دستهٔ محتوایی تازه، می‌خواستم بدانم روی همین سایت چه چیزهایی نوشته شده تا موضوع تکراری تولید نکنم. به‌جای نوشتن اسکریپت و باز کردن دیتابیس، فهرست نوشته‌های منتشرشده را از wp-json صدا زدم؛ بدون نام کاربری، بدون رمز، بدون افزونه. در دو فیلدِ سرِ پاسخ، شمار کل موجودی را دقیق خواندم. آن عدد را تا آن روز روی هیچ صفحه‌ای از سایت ندیده بودم و هرگز هم قصد نداشته‌ام پنهانش کنم؛ فقط باورم این بود که «فهرست نوشته‌ها» چیزی است که باید برایش کاری کرد.

نکتهٔ این مقاله همین است: REST API یک قابلیت جانبی نیست که روشن باشد و کسی نگاهش نکند. روی هر نصب پیش‌فرض وردپرس، این مسیر یک راهِ خواندنِ ساختارمندِ کل سایت است؛ ساختارمند، یعنی خروجی‌اش برای ماشین تمیزتر از HTML است. اول اندازه می‌گیریم، بعد تصمیم می‌گیریم؛ چون بستنِ کورکورانهٔ این مسیر، ویرایشگر نوشته را می‌شکند.

رابط برنامه‌نویس کجاست و چرا پیش‌فرض روشن است

وردپرس از نسخهٔ 4.7 لایه‌ای JSON روی هسته گذاشته که داده‌های قابل‌دسترسش را از مسیرهای مشخصی بیرون می‌دهد. دو راه برای رسیدن به همان‌جا وجود دارد: اگر پیوندهای ساده روشن باشد، مسیر /wp-json/ کار می‌کند؛ اگر نباشد، یا اگر کسی فقط همان مسیر را بسته باشد، شکل دوم با پارامتر ?rest_route= همان پاسخ را می‌دهد. همین دوراهی، مهم‌ترین تلهٔ بستن است و پایین‌تر سراغش می‌روم.

چرا روشن است؟ چون ویرایشگر بلوک روی همین لایه سوار است: ذخیرهٔ خودکار، آپلود تصویر، خواندن الگوها، ویرایش دسته‌ها، همه از این مسیر رد می‌شوند. افزونه‌ها هم برای خودشان فضای نام اختصاصی ثبت می‌کنند و بسیاری از قابلیت‌های سمت کاربر از همین‌جا داده می‌گیرند. پس وقتی می‌گوییم «REST را ببندیم»، در واقع داریم یک سرویس درون‌سایتی را هدف می‌گیریم که ترافیکش از مرورگر خودِ ما هم می‌آید، نه فقط از ربات‌ها.

علامت روشن بودن را در همان پاسخ اول می‌بینی: سرِ link با rel="https://api.w.org/" که وردپرس به HTML هر صفحه اضافه می‌کند. این سرِ اطلاع‌رسانی است، نه کلید دسترسی؛ بستنش جلوی کسی را نمی‌گیرد و فقط نشانه‌ای را که در ابردادهٔ HTML پیدا می‌شود، حذف می‌کند.

شمارش دقیق موجودی: ?per_page و X-WP-Total

ارزشمندترین چیزی که این مسیر علنی می‌کند، متن نوشته‌ها نیست؛ عدد است. هر پاسخِ فهرستی دو سرِ مخصوص دارد: X-WP-Total که شمار کل آیتم‌های مطابقِ آن پرس‌وجو را می‌گوید، و X-WP-TotalPages که تعداد صفحه‌ها را. با ?per_page=100 که سقف مجازِ هسته است و ?page=n می‌توانی کل موجودی را ورق بزنی، و X-WP-Total را بدون خواندن بدنهٔ پاسخ هم ببینی.

در همان آزمایشِ خودم، X-WP-Total روی فهرست نوشته‌ها 272 بود: دقیقاً همان اندازهٔ موجودی، بدون باز کردن پیشخوان. با سه درخواستِ پیاپی، کل فهرست بیرون آمد. یک نکتهٔ دوم هم داشت که جالب‌تر بود: 272 شناسهٔ یکتا، ولی فقط 257 نام یکتا. آن اختلاف 15 تایی از خودِ ساختار سایت من می‌آید و چیزی است که اگر از بیرون خوانده شود، دربارهٔ چندزبانه بودن سایت توضیح می‌دهد. این را نگفتم تا بگویم سایت من ویژه است؛ گفتم تا ببینی این مسیر چه سطحی از جزئیات را بی‌صدا بیرون می‌دهد.

چیزی که یک مهاجمِ جدی از این عدد می‌گیرد، دو چیز است: اندازهٔ دقیقِ حملهٔ بعدی، و تشخیص الگوی انتشار. X-WP-Total روی هر فیلتر قابل تکرار است: بنویس ?categories=63 و ببین چند نوشته در آن دسته است، بنویس ?after=2026-08-01T00:00:00 و ببین در یک بازهٔ زمانی چند چیز منتشر شده. همین، تبدیل به نقشه می‌شود.

curl -s -D - -o /dev/null "https://example.com/wp-json/wp/v2/posts?per_page=100&page=1"

HTTP/2 200
content-type: application/json; charset=utf-8
x-wp-total: 272
x-wp-totalpages: 3
link: <https://example.com/wp-json/>; rel="https://api.w.org/"
last-modified: Tue, 16 Sep 2026 07:41:12 GMT
x-litespeed-cache: miss

اگر x-wp-total را ندیدی، یا پاسخ از کش آمده یا پرس‌وجو فهرستی نبوده؛ آیتم تکی، مثلاً یک نوشته با شناسهٔ مشخص، این سرها را ندارد.

آنچه از مسیر نوشته‌ها بیرون می‌آید

مسیر /wp-json/wp/v2/posts برای یک بازدیدکنندهٔ ناشناس، نوشته‌های منتشرشده را می‌دهد. همین جمله را باید بزرگ‌تر بخوانی، چون دامنه‌هایش از چیزی است که معمولاً تصور می‌شود: «منتشرشده» یعنی هر چیزی که روزی از وضعیت پیش‌نویس خارج شده، حتی اگر از منو حذفش کرده باشی، حتی اگر هیچ‌جا لینکش را نگذاشته باشی، و حتی اگر کاربر فقط با یک لینک مستقیم به آن رسیده باشد.

میدان‌های هر آیتم که برای انگشت‌گذاری روی سایت تو استفاده می‌شوند:

  • id و slug: هم شناسهٔ دیتابیس، هم نشانی نهایی. یعنی ساختار آدرس‌ها و الگوی نام‌گذاری محتوای تو.
  • date و date_gmt: زمان انتشار با دقت دقیقه. دو ستون با اختلاف ساعت سرور، که خودشان اثر انگشتی از تنظیمات و منطقهٔ زمانی می‌دهند.
  • modified: آخرین ویرایش. فهرست modified روی 100 نوشتهٔ آخر، الگوی تیم محتوایی و بازهٔ کاری‌ات را توضیح می‌دهد.
  • author: شناسهٔ عددی نویسنده، که مهم‌ترین ورودیِ بخشِ «کاربران» است و پایین‌تر می‌بینی چطور به درِ ورود وصل می‌شود.
  • title.rendered, excerpt.rendered, content.rendered: بله، متن کامل نوشته در JSON هم هست؛ با همان HTML بلوک‌ها.
  • featured_media, categories, tags: شناسهٔ تصویر شاخص و عضویت در طبقه‌بندی‌ها.
  • _links: شامل about, collection, self, author, replies, version-history. از این‌ها می‌شود فهمید چه مسیر دیگری هم روی همین سایت ثبت است.

نکته‌ای که کار خواننده را أرزان می‌کند: پارامتر _fields. با ?_fields=id,slug,date,author,categories پاسخ را به چند کیلوبایت می‌رسانی و کل موجودی را در چند درخواست بیرون می‌کشی. پس اگر کسی بخواهد فهرست محتوای تو را بدزدد یا با آن سایت آینه‌ای بسازد، نیازی به خزشِ HTML ندارد و به ترافیک صفحه‌ها دست نمی‌زند؛ همان یک مسیر، تمیزتر و کامل‌تر می‌دهد.

پیش‌نویس‌ها و نوشته‌های زمان‌بندی‌شده بیرون نمی‌آیند: ?status=draft برای ناشناس پاسخ مجاز نمی‌گیرد. این را همین‌جا روشن بگویم تا با خیال دقیق‌تری تصمیم بگیری؛ چیزی که فاش می‌شود گذشتهٔ منتشرشده است، نه آیندهٔ آماده‌شده.

دسته‌ها، برچسب‌ها و نوع‌ها: نقشهٔ محتوا و ردپای افزونه‌ها

سه مسیر دیگر هسته را کمتر از نوشته‌ها درباره‌شان می‌نویسند و بیشتر از آن‌ها می‌شود سایت را شناخت:

/wp-json/wp/v2/categories هر دسته را با id, slug, name, parent, count می‌دهد. یعنی درخت کامل طبقه‌بندی، به‌همراه دقیقاً چند نوشته در هر گره. این نقشهٔ موضوعی سایت توست؛ همان چیزی که برای ساختن یک محتوای کپی، یا برای شناخت کلمات کلیدیِ تمرکزت، کافی است.

/wp-json/wp/v2/tags برچسب‌ها را با شمارشان می‌دهد. وقتی دو مسیر را کنار هم می‌گذاری، استراتژی محتوایی خوانده می‌شود: کدام دسته سنگین است، کدام دسته ساخته شده و خالی مانده، و روی چه موضوعاتی تکرار کرده‌ای.

/wp-json/wp/v2/types و /wp-json/wp/v2/taxonomies فهرست نوع‌های نوشته و طبقه‌بندی‌های سفارشی را می‌دهند، و اینجاست که پای افزونه‌ها وسط می‌آید. هر افزونه‌ای که نوع نوشته یا طبقه‌بندی سفارشی‌اش را در این لایه نمایان گذاشته، یعنی show_in_rest روشن است، در این فهرست با نام انگلیسی‌اش ظاهر می‌شود؛ بدون آنکه هیچ‌جا در منوی سایت لینکش را گذاشته باشی. اگر در پاسخ این دو مسیر نامی دیدی که خودت نساخته‌ای، یا افزونه‌ای فراموش‌شده است، یا نوع نوشته‌ای داخلی که نباید عمومی می‌مانده.

/wp-json/wp/v2/search هم یک مسیر فهرستی است که همهٔ نوع‌های قابل‌جستجو را در بر می‌گیرد. برای کاربر واقعی مفید است و برای خوانندهٔ بیرونی، همان نقشه را بدون دانستن نام نوع‌ها می‌دهد: یک پرس‌وجوی آزاد در کل سایت.

curl -s "https://example.com/wp-json/wp/v2/categories?per_page=100&_fields=id,slug,name,count,parent"
curl -s "https://example.com/wp-json/wp/v2/types?_fields=slug,name,rest_base"

شناسهٔ کاربر و مسیر کوتاهش تا wp-login.php

خطرناک‌ترین زنجیرهٔ این مقاله کوتاه است: شناسهٔ عددی نویسنده، بعد نام کاربری، بعد درِ ورود. هرجای دیگری که هسته باز است، این سه با هم وصل می‌شوند.

دو راه برای رسیدن به نام کاربری وجود دارد. راه کلاسیک در خودِ HTML است: ?author=N را درخواست کن؛ وردپرس به بایگانی نویسنده ریدرکت می‌کند و مسیر مقصد، نام کاربری را در خودش دارد. راه دوم همان لایهٔ JSON است که در پاسخ‌های _embed می‌آید: با ?_embed شیءِ نویسنده درون پاسخ نوشته گنجانده می‌شود و فیلد linkِ آن، آدرس بایگانی است؛ یعنی همان نام کاربری، بدون هیچ درخواست اضافه.

curl -s -o /dev/null -D - "https://example.com/?author=1"

HTTP/2 301
location: https://example.com/author/admin/
x-redirect-by: WordPress

curl -s "https://example.com/wp-json/wp/v2/posts/123?_embed" | head -c 400

چرا این را «خطرناک» می‌نامم و نه «بد»؟ چون تلاشِ رمز روی درِ ورود، یک نام کاربری معتبر می‌خواهد. اگر نام کاربریِ مدیر از قبل قابل‌حدس باشد، مثلاً admin, administrator, info, support, نام خودِ دامنه، یا ترکیب نام و نام خانوادگی که در بایگانی نویسندگان دیده می‌شود، نیمی از مسیرِ حملهٔ زورآزمایی باز شده؛ و نیمِ دیگر را حجم درخواست انجام می‌دهد. اگر رمز قوی و ورود دومرحله‌ای داشته باشی، این افشا فقط یک آزار است. اگر نه، دعوت‌نامه است.

پیش از آنکه هر چیز را ببندی، این چهار مورد را بررسی کن، چون همه‌شان به همان فهرست کاربران وابسته‌اند:

  1. صفحه‌های نوشته‌ات لینک «نویسنده» دارند که به بایگانی می‌رود؟ قالب‌های مجله‌ای و سایت‌های چندنفره معمولاً دارند.
  2. نویسندهٔ مهمان داری، یا حسابی که فقط برای نوشتن یک مقاله ساخته شده؟ آن‌ها هم در همان فهرست‌اند.
  3. ووکامرس، انجمن گفتگو، و هر افزونهٔ عضویت‌محور، برای پروفایل کاربر از این مسیر داده می‌گیرد؛ اگر ببندیش، صفحهٔ «حساب من» می‌شکند.
  4. هر افزونه‌ای که در /wp-json/ برای خودش فضای نام ثبت کرده، فهرستش را در همان مسیر ریشه می‌بینی. این فهرست، دقیقاً همان لیست وابستگی‌های توست؛ چیزی که چند خط بالا یک افشا بود، اینجا نقشهٔ قبل از بستن است.

اثر انگشت قالب و افزونه: stylesheet، template و ?ver=

هسته برای ناشناس، نام قالب و افزونه‌ها را در یک JSON آماده بیرون نمی‌دهد؛ /wp-json/wp/v2/themes و /wp-json/wp/v2/plugins نیازمند ورود با دسترسی مدیریت‌اند. اگر سایت تو به این دو مسیر پاسخ 200 برای کاربر ناشناس داد، مشکل از وردپرس نیست؛ افزونه‌ای یا قاعده‌ای دلخواه آن‌ها را باز کرده و همین را باید پیدا کنی.

ولی همان اطلاعات از راه‌های غیرمستقیم خوانده می‌شود و این‌ها را باید جدی بگیری:

فهرست فضاهای نام. /wp-json/ و همان ?rest_route=/ آبجکتی می‌دهند با دو کلید namespaces و routes. اولی نام همهٔ لایه‌هایی است که ثبت شده؛ یعنی هر افزونه‌ای که در این لایه حضور دارد، با نام انگلیسی‌اش آنجا نوشته شده. دومی دقیق‌تر است: برای هر مسیر، متدها و فهرست آرگومان‌ها را می‌دهد. از روی نام‌ها و شکل آرگومان‌ها، افزونه و حتی بازهٔ نسخهٔ آن قابل حدس است.

stylesheet و template. نام پوشهٔ قالب در چند جا خاموش است: پیوند style.css با مسیر /wp-content/themes/<stylesheet>/style.css بارگذاری می‌شود، کلاس بدنه معمولاً theme-<stylesheet> را دارد، و در قالب‌های بلوکی نام فایلِ الگو هم در ردپای همان صفحه پیدا می‌شود. هر سه، نام پوشه را می‌دهند؛ و نام پوشه همان چیزی است که بعد از آن در مخزن رسمی و در فهرست آسیب‌های منتشرشده جستجو می‌شود.

?ver= روی فایل‌های بارگذاری‌شده. این پارامتر را بیشتر «برای شکستن کش» می‌شناسند، ولی عملاً تابلوی نسخه است. سه حالت دارد و هر سه چیزی می‌گویند: اگر سازندهٔ قالب نسخهٔ خودش را پاس داده باشد، عددِ نسخهٔ قالب را می‌بینی؛ اگر false باشد، چیزی نمی‌بینی که خودش نشانهٔ دقت سازنده است؛ و اگر هیچ‌کدام نباشد، وردپرس نسخهٔ هسته را می‌چسباند. یعنی از روی هر فایل CSS در صفحه می‌شود گفت دقیقاً روی کدام نسخهٔ وردپرس ایستاده‌ای. یکی از اولین کارها بعد از دیدن این عدد، مقایسه با آخرین نسخهٔ منتشرشده است.

مسیر فایل‌ها. content.rendered در پاسخ نوشته‌ها، همان HTML بلوک‌ها را دارد؛ و HTML پر است از wp-content/plugins/<slug>/... برای استایل و اسکریپت یک بلوک، و از کلاس‌های اختصاصی که نام افزونه را در خودِ نام کلاس نگه می‌دارند. /wp-json/wp/v2/media هم source_url را با ساختار تاریخی پوشهٔ بارگذاری می‌دهد و در media_details جزئیات تصویر را؛ یعنی هم ساختار مسیرها، هم آنچه روزی در متادیتای فایل مانده است.

جمعش این می‌شود: بدون هیچ ورودی، یک خواننده می‌تواند بگوید سایت تو وردپرسِ چه نسخه‌ای است، با چه قالبی، با چه افزونه‌هایی، و با چه موجودی محتوایی. این همان چیزی است که اسکنرهای خودکار دقیقه‌ای صدها بار انجام می‌دهند، و بعد نوبت امتحان‌کردنِ آسیبِ منتشرشدهٔ همان ترکیب می‌رسد.

جدول افشا: مسیر، آنچه می‌دهد، وضعیت پیش‌فرض

ستون سوم را صادقانه بخوان: «باز» یعنی روی نصب پیش‌فرض، بدون ورود پاسخ می‌گیرد؛ «نیازمند ورود» یعنی هسته بسته است و اگر باز باشد، تقصیر تنظیمات یا افزونهٔ توست.

| مسیر | آنچه علنی می‌کند | وضعیت پیش‌فرض | | — | — | — | | /wp-json/ | فهرست namespaces و routes، به‌همراه آرگومان‌های هر مسیر؛ ردپای افزونه‌ها | باز | | /wp-json/wp/v2/posts | شناسه، نامک، عنوان، متن کامل و تاریخِ همهٔ نوشته‌های منتشرشده | باز | | ?per_page=100&page=n | X-WP-Total و X-WP-TotalPages: اندازهٔ دقیق موجودی | باز | | ?_fields=... | بیرون‌کشیدن سریعِ فهرست کامل با حجم بسیار کم | باز | | ?rest_route=/wp/v2/posts | همان مسیر نوشته‌ها، بدون پیوندهای ساده؛ راه دورزدنِ قاعده‌ای که فقط /wp-json/ را می‌بندد | باز | | /wp-json/wp/v2/categories | درخت دسته‌ها، شناسهٔ هر دسته و شمار نوشته‌های آن | باز | | /wp-json/wp/v2/tags | برچسب‌ها با شمارشان: سرفصل‌های واقعی سایت | باز | | /wp-json/wp/v2/types | نوع‌های نوشتهٔ سفارشی که show_in_rest دارند | باز | | /wp-json/wp/v2/taxonomies | طبقه‌بندی‌های سفارشیِ افزونه‌ها | باز | | /wp-json/wp/v2/media | مسیر فایل‌ها، ساختار پوشهٔ بارگذاری، شناسهٔ نوشتهٔ والد، جزئیات تصویر | باز | | /wp-json/wp/v2/search | جستجو در همهٔ نوع‌های نمایان، حتی آنچه در منو نیست | باز | | /wp-json/wp/v2/posts/123?_embed | پیوند بایگانی نویسنده، یعنی همان نام کاربری | باز | | ?author=1 روی HTML | ریدرکت به آدرس بایگانی، با نام کاربری در مسیر | باز | | /wp-json/oembed/1.0/embed?url=... | شناسه، نام نویسنده و HTML آمادهٔ جاسازی یک نوشته | باز | | /wp-json/wp/v2/users | فهرست کاربران با نام و نام کاربری | نیازمند ورود؛ اگر 200 گرفتی، افزونه‌ای باز کرده | | /wp-json/wp/v2/comments برای فهرست کامل | دیدگاه‌ها با نام، ایمیلِ مخفی‌شده و نوشتهٔ والد | نیازمند ورود | | /wp-json/wp/v2/themes | stylesheet, template و نسخهٔ قالب‌ها | نیازمند دسترسی مدیریت | | /wp-json/wp/v2/plugins | فایل، نسخه و وضعیت فعال یا غیرفعال هر افزونه | نیازمند دسترسی مدیریت |

یک سطر را که در هیچ فهرستی نیست، اضافه می‌کنم: _links.version-history در پاسخ هر نوشته، شناسهٔ هر بازنگری را می‌دهد و بازنگری‌ها با _embed برمی‌گردند. اگر روی نوشته‌های حساس متن‌های قدیمی داشته باشی، تاریخچه همان‌جا است.

خودسنجی با curl؛ شش درخواست که باید بزنی

این بخش، هستهٔ عملی مقاله است. شش درخواست زیر را از محیطی بدون کوکی بزن، نه از مرورگرِ واردشده. curl هیچ کوکی نمی‌فرستد و به همین دلیل نزدیک‌ترین شبیه‌سازیِ یک غریبه است.

curl -s "https://example.com/wp-json/" -o /tmp/rest-index.json   # root: registered namespaces
head -c 1200 /tmp/rest-index.json

curl -s -D - -o /dev/null "https://example.com/wp-json/wp/v2/posts?per_page=100"   # exact inventory size

curl -s "https://example.com/wp-json/wp/v2/posts?per_page=100&_fields=id,slug,date,modified,author,categories"

curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/wp-json/wp/v2/users"   # expect 401 or 403

curl -s -o /dev/null -D - "https://example.com/?author=1" | grep -i '^location'
curl -s "https://example.com/wp-json/wp/v2/posts/1?_embed" | grep -o '"link":"[^"]*author[^"]*"'

curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?rest_route=/wp/v2/posts&per_page=1"

نتیجه‌ای که باید انتظار داشته باشی: در درخواست دوم، سوم و ششم پاسخ 200 با بدنهٔ JSON، در چهارم پاسخ 401 یا 403، و در پنجم خط location حاوی نام کاربری. سه وضعیت را این‌طور معنی کن: 404 یعنی مسیر ثبت نیست، 401 یعنی نشانی نیازمند ورود است، 403 یعنی ورود کرده‌ای ولی دسترسی کافی نداری. اگر چهارم را روی سایتت با 200 دیدی، یک چیز را باید درست کنی؛ اگر ششم با همان قاعده‌ای که /wp-json/ را می‌بندد همچنان 200 داد، قاعده‌ات ناقص است.

دو احتیاط: تعداد درخواست را کم نگه دار، همان شش یا ده، نه هزار، تا روی هاست اشتراکی خودت را گلوگاه نکنی؛ و این آزمایش را روی سایت خودت انجام بده، نه روی سایت کسی که به تو اجازه نداده است.

سه پاسخ پلکانی، به‌جای بله یا خیر

تصمیم درست، دودویی نیست. سه سطح داریم و هر سطح بهای خودش را دارد؛ از ارزان‌ترین شروع کن و همان‌جا بایست اگر مشکلت حل شد.

سطح یک: خواندن نوشته‌ها آزاد، فهرست کاربران بسته. این تعادلِ پیش‌فرضِ منصفانه است. با یک فایل در مسیر wp-content/mu-plugins دو مسیرِ کاربران را از جدول مسیرها حذف می‌کنی؛ آیتم‌های نوشته دست‌نخورده می‌مانند، ویرایشگر کار می‌کند، ووکامرس و جاسازی نمی‌شکنند:

add_filter( 'rest_endpoints', function ( $endpoints ) {
    foreach ( array( '/wp/v2/users', '/wp/v2/users/(?P<[\d]+>)' ) as $route ) {
        unset( $endpoints[ $route ] );
    }
    return $endpoints;
} );

بهایش: افزونه‌ای که پروفایل کاربر را از این مسیر می‌خواند، در همان بخش می‌شکند. با curl و یک تست در حالت ناشناس، قبل از اعمال بفهم آن افزونه را داری یا نه.

سطح دو: بستن شمارش و پیمایش برای ناشناس، نگه‌داشتن خواندن آیتم تکی. در این حالت درخواستِ فهرستی بی‌پاسخ می‌ماند، ولی نشانی یک نوشته باز خوانده می‌شود؛ یعنی لینک مستقیم پخش نمی‌شود و اسکرپِ انبوه گران می‌شود. روی وب‌سرور:

RewriteCond %{QUERY_STRING} (?:^|&)rest_route=/wp/v2/(posts|pages|media|categories|tags|users|types|taxonomies|search)(&|$) [NC,OR]
RewriteCond %{REQUEST_URI} ^/wp-json/wp/v2/(posts|pages|media|categories|tags|users|types|taxonomies|search)/?$ [NC]
RewriteCond %{HTTP_COOKIE} !wordpress_logged_in_ [NC]
RewriteRule ^ - [F]

بهایش سنگین‌تر است: صفحهٔ دسته و برچسبی که از این لایه تغذیه می‌کنند، افزونه‌های جستجوی بلوکی، هر قطعه‌ای که «نوشته‌های مرتبط» را سمت مرورگر می‌گیرد، و بلوک‌های کوئری‌محورِ ویرایشگر. اگر سایتت فقط HTML کش‌شده سرو می‌کند، احتمالاً چیزی نمی‌شکند؛ اگر قالب بلوکی داری، اول روی استیج.

سطح سه: فقط برای دامنهٔ خودت و فقط با ورود. کامل‌ترین بستن، که باید با لیست سفید برای نشانی پیشخوان و admin-ajax همراه باشد، وگرنه چیزهای دیگری را هم می‌بندد که نامشان در پاسخ ریشه بوده. بهایش: اپلیکیشن موبایل وردپرس از کار می‌افتد، جاسازی نوشته‌ات در سایت‌های دیگر، همهٔ افزونه‌های فرمی که ارسال را به فضای نام اختصاصی خودشان می‌سپارند، و هر «خواندنِ بی‌نیاز از ورود» که امروز کاربرانت استفاده می‌کنند.

در هر سه سطح، دو کار جانبی ارزش دارد: ?rest_route= را هم در قاعده بگیر، وگرنه یک در را بسته‌ای و یکی باز مانده است؛ و اگر فقط می‌خواهی حجم اسکرپینگ کم شود، بدانی robots.txt ابزار امنیتی نیست: دستور خزش را اجرا نمی‌کند و عملاً فقط یک یادداشت دوستانه است. برای بستن واقعی، لایهٔ وب‌سرور یا فیلترِ درون‌برنامه‌ای لازم است.

اگر این لایه را ببندی، دقیقاً چه چیزی می‌شکند

این بخش را از تجربهٔ خودم می‌نویسم: بیشترِ خرابی‌هایی که در پروژه‌ها دیدم، کارِ خودِ محدودسازی نبود، تست‌نشدنِ آن بود. فهرستِ آنچه می‌شکند:

ویرایشگر نوشته. ویرایشگر بلوک روی همین لایه سوار است: ذخیرهٔ خودکار و بازگشت به نسخهٔ قبل، آپلود تصویر در کتابخانهٔ رسانه، خواندن و ذخیرهٔ الگوها و بلوک‌های مشترک، فهرست نوع‌ها و طبقه‌بندی‌ها در نوار کناری، و در قالب‌های بلوکی خودِ ویرایشگر سایت با مسیرهای templates و global styles. قاعده‌ای که «ناشناس» را می‌بندد معمولاً مدیر را نمی‌بندد؛ ولی اگر استثنای ورود را فراموش کنی، با صفحه‌ای روبه‌رو می‌شوی که درِ پیشخوان باز است و ویرایشگر روی «ذخیرهٔ خودکار ناموفق» گیر کرده.

افزونه‌های فرم. بسیاری از سازنده‌های فرم، ارسال را به فضای نام اختصاصی خودشان می‌سپارند، شکلی مثل /wp-json/<slug>/v1/.... قاعدهٔ عمومیِ «بستن کل /wp-json/» یا «بستن هر POST بدون ورود» این‌ها را هم می‌بندد و بدترین نوع خرابی را می‌سازد: فرم روی صفحه هست، کاربر دکمه را می‌زند، هیچ خطایی نمی‌بینی، و هیچ ایمیلی نمی‌رسد. این خرابی را با یک ارسال آزمایشیِ واقعی تست کن، نه با نگاه‌کردن به صفحه.

قطعه‌های کش‌شده. سبد خرید، شمار اعلان، بنر عضویت: هر کدام که در HTML کش‌شده جای ندارند و مرورگر موقع نمایش از این لایه می‌گیرد. اگر فروشگاهت با Store API کار می‌کند، بلوک‌های سبد و صورت‌حساب همان مسیر را صدا می‌زنند و بستنشان یعنی صفحهٔ پرداخت سفید.

جاسازی و خزش. سرویس جاسازیِ خودت، یعنی oembed، یک مسیرِ این لایه است؛ بستنش یعنی نوشته‌ات در پلتفرم‌های دیگر ساده نمایش داده می‌شود. در مورد خزش هم نکته را روشن بگویم: ایندکس شدن HTML به این لایه نیاز ندارد، ولی اگر ربات‌ها روی صفحه‌های تو با 403 یا 401 روبه‌رو شوند، سیگنال بدی از دسترس‌پذیری می‌گیرند و این را نمی‌خواهی.

روشی که همیشه کار می‌کند: اول قاعده را در حالت «فقط ثبت، بدون اعمال» یا با استثنای IP خودت بگذار، بعد از بیرون و در حالت ناشناس، این چهار مسیر را برو: یک بار ویرایش و ذخیرهٔ یک نوشته، صفحه‌ای با فرم تماس و یک ارسال آزمایشی، صفحهٔ سبد یا هر قطعهٔ زندهٔ دیگر، و لینک مستقیم یک نوشته از دستگاهی دیگر. اگر هر چهارتا پاس دادند، تغییر را عمومی کن.

لایت‌اسپید کش: چرا قاعده‌ات اثر نکرده به نظر می‌رسد

روی هاست سی‌پنل با لایت‌اسپید، بخشِ زیادی از پاسخ‌ها هرگز به PHP یا به قاعدهٔ تو نمی‌رسند؛ از کشِ سطح وب‌سرور سرو می‌شوند. این دو جهت دارد و هر دو گمراه‌کننده‌اند.

حالت اول: قانونی که اثر نکرده به نظر می‌رسد. کش، پاسخِ قدیمی را نگه داشته؛ تو قاعدهٔ بستن را اضافه کرده‌ای و همان JSON سالم برگردانده می‌شود. تشخیص یک خط است: سرِ x-litespeed-cache با مقدار hit یعنی پاسخ از کش آمده و قاعدهٔ تو در این درخواست اصلاً اجرا نشده. پیش از هر نتیجه‌گیری، کش را یک‌جا پاک کن و دوباره درخواست بزن؛ اگر این‌بار 403 دیدی، مشکل از قاعده نبوده، از زمان‌بندیِ تست بوده.

حالت دوم: قانونی که بیش از حد اثر می‌کند. اگر پاسخِ این لایه کش‌شونده باشد، نسخهٔ «ناشناس» همان پاسخ بین همه سرو می‌شود؛ یعنی محتوای صفحه‌ای که باید برای کاربرِ وارد متفاوت باشد، برای همه یکسان می‌شود. و برعکسش هم اتفاق می‌افتد: اگر 403 کش برود، کاربرِ وارد هم رد می‌شود. بدترین شکلش روی JSON پیمانه‌دار است: اگر کش روی رشتهٔ پرس‌وجو روشن باشد، هر ?per_page=...&page=... یک نسخهٔ جدا در کش می‌سازد. نتیجه دو تا است: مجموعه‌ای از نسخه‌ها که ظرف کش را می‌خورند، و X-WP-Total که کهنه سرو می‌شود و عددِ امروز را با دادهٔ پارسال نشان می‌دهد.

کاری که باید بکنی روشن و کوتاه است: مسیر /wp-json/ و شکل ?rest_route= را از کش مستثنی کن؛ در تنظیمات افزونهٔ لایت‌اسپید، فهرست نشانی‌های «بدون کش» وجود دارد، و از سمت PHP هم می‌شود با سرِ Cache-Control: no-store روی پاسخ‌های JSON همان کار را کرد. اگر افزونه برای تشخیص نسخهٔ صفحه از کوکی استفاده می‌کند، هر مقدارِ آن کوکی یک نسخه می‌سازد؛ پس قاعده‌ای با شرط «اگر کوکیِ ورود نبود ببند» روی مسیر کش‌شونده، عملاً تعداد نسخه‌ها را دو‌برابر می‌کند و بهایش را در ظرف کش و در بی‌ثباتیِ تست می‌پردازی.

curl -s -D - -o /dev/null "https://example.com/wp-json/wp/v2/posts?per_page=1&lstest=714"

HTTP/2 403
x-litespeed-cache: miss
content-type: text/html; charset=UTF-8

پارامتر lstest یک مقدار تصادفیِ بی‌اثر است تا نسخهٔ کش‌شده را دور بزند؛ اگر با آن هم hit گرفتی، کش روی رشتهٔ پرس‌وجو روشن است و همان گزینه را باید ببندی. یک نکتهٔ آخر: تغییر .htaccess روی لایت‌اسپید معمولاً سریع اعمال می‌شود، ولی وب‌سرور نسخهٔ تجزیه‌شدهٔ آن را نگه می‌دارد؛ اگر قاعده‌ات اجرا نشد، اول کش را پاک کن و اگر باز جواب نگرفت، از میزبان بخواه سرویس وب‌سرور را بازخوانی کند.

اثبات از بیرون، نه از نوار مدیریت

تغییری که از بیرون تأیید نشده، اعمال نشده است. سه دام دارد و هر سه را دیده‌ام:

دام اول: تست در حالت وارد. مرورگرِ تو کوکی ورود می‌فرستد؛ استثنای «کاربرِ وارد» همان‌جا عمل می‌کند و تو 200 را موفقیت می‌خوانی، درحالی‌که هیچ‌چیز نبسته شده. یا برعکس: قاعده‌ات درست است، ویرایشگر کار می‌کند، و تو از همان تبِ واردشده نتیجه می‌گیری «بی‌تغییری». حالت ناشناس را با curl تست کن، یا با پنجرهٔ خصوصی که هرگز در آن وارد نشده‌ای.

دام دوم: پاسخ کش‌شده. همان سرِ x-litespeed-cache را چک کن؛ تا miss نگرفته‌ای، چیزی که دیدی متعلق به دنیایِ قبل از تغییر توست.

دام سوم: مسیرِ جایگزین. همان curl را با ?rest_route= تکرار کن. اگر یکی بسته شد و دیگری باز ماند، عملاً چیزی نبسته‌ای و فقط یک در را قفل کرده‌ای درحالی‌که پنجره باز است.

چک‌لیست تأیید، در پنج خط:

  1. X-WP-Total را با curl بخوان؛ اگر هنوز عددِ کل موجودی را می‌دهد، شمارش بسته نشده است.
  2. روی ?author=1 خط location را ببین؛ اگر هنوز نام کاربری دارد، نگاشتِ شناسه به نام کاربری باز است.
  3. /wp-json/ را بگیر و namespaces را بشمار؛ هر نامی که نمی‌شناسی، یا افزونهٔ فراموش‌شده است یا چیزی که باید حذف می‌شد.
  4. از شبکه‌ای دیگر هم تکرار کن، مثلاً دادهٔ موبایل، نه از مسیرِ سرور خودت؛ کشِ محلی و مسیرِ لوکال، تشخیص را عوض می‌کنند.
  5. پیش و پس از تغییر را در یک فایل یادداشت کن، با تاریخ. اگر ماهِ دیگر کسی بپرسد «اینجا چه عوض شده»، جوابت یک مقایسهٔ ساده است.

و یک بازرسی دوره‌ای: همین شش درخواست را هر سه ماه تکرار کن. سطح حمله با هر افزونهٔ تازه و هر به‌روزرسانی قالب عوض می‌شود؛ ممکن است چیزی که امروز 401 می‌دهد، بعد از یک به‌روزرسانی 200 بدهد.

چه چیزی را در این مقاله ادعا نمی‌کنم

سه ادعا ندارم و ترجیح می‌دهم صریح بگویم. اول: بستن این لایه جلوی یک مهاجمِ مصمم را نمی‌گیرد. همان اطلاعات از HTML هم قابل خواندن است، فقط کندتر و کثیف‌تر؛ چیزی که این کار واقعاً کم می‌کند، حجم ترافیکِ اسکرپینگ و تعداد اسکنرهای خودکاری است که با اطلاعاتِ آماده به سراغت می‌آیند. دوم: این لایه را جایگزین رمز قوی و ورود دومرحله‌ای نکن؛ اگر نام کاربری‌ات قابل‌حدس است، راه‌حل، نام کاربریِ غیرقابل‌حدس است، نه پنهان‌کردنش، هرچند هر دو با هم بهترند. سوم: عددهایی که اینجا دیدی، یعنی 272 و 257، از موجودیِ واقعیِ سایت خودم در یک روزِ مشخص است، نه میانگینی از بیرون؛ سایت تو عددِ خودش را با همان X-WP-Total می‌دهد.

اگر فقط بیست دقیقه وقت داری، به همین ترتیب برو: همان شش curl را بزن، روی نتیجهٔ مسیرِ کاربران و ?author=1 نگاه کن، اگر نام کاربری بیرون آمد همان سطحِ اول را بگذار، کش را پاک کن و از بیرون دوباره چک کن. بقیهٔ خوشه هر کدام یک لایه را باز می‌کنند: چک‌لیست امنیتی روی سی‌پنل، تشخیص آلودگی روی هاست اشتراکی، و اینکه قالب و افزونه‌هایت را از کجا دقیق بشماری تا بدانی چه چیزی باید بسته باشد.

مقالبی بخوانی

من متخصص سئو که عاشق اینه به کسب‌وکارها کمک کنه تو گوگل دیده بشن!از وردپرس گرفته تا سئو، هرچیزی که برای رشد آنلاین نیاز داری میتونم کمکت کنم.

دیدگاه خود را بنویسید