پنج‌شنبه گواهی را نصب کردیم و همه‌چیز درست به‌نظر می‌رسید. دامنه با https باز می‌شد، صفحه‌ی اصلی می‌آمد، لاگ لاراول هم تمیز بود. ولی کنار نوار آدرس به‌جای قفل، آن علامت کوچکِ خنثی نشسته بود و در صفحه‌ی محصول، اسلایدر اصلاً اجرا نمی‌شد. کنسول مرورگر یک خط داشت که بعداً فهمیدیم اسمش mixed content است.

فردایش یک تیکت آمد: کاربر می‌گفت سایت اصلاً بالا نمی‌آید و مرورگر می‌گوید «تعداد ریدایرکت‌ها بیش از حد است». برای ما همان لحظه باز می‌شد. برای او نه. وقتی یک مشکل برای نصف آدم‌ها هست و برای نصف دیگر نیست، آدم شروع می‌کند به عوض‌کردنِ تصادفیِ تنظیمات — و ما یک بعدازظهر همین کار را کردیم.

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

سه علامت جدا که همه شبیه یک مشکل‌اند#

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

  1. صفحه باز می‌شود ولی قفل کامل نیست، یا بعضی فایل‌ها لود نمی‌شوند. استایل نمی‌آید، اسکریپت اجرا نمی‌شود، تصویر خالی است. این mixed content است: خودِ سند با https آمده ولی چیزی داخلش با http درخواست شده.
  1. مرورگر می‌گوید تعداد ریدایرکت‌ها بیش از حد است. صفحه اصلاً رندر نمی‌شود. این حلقه‌ی ریدایرکت است و ربطی به محتوای صفحه ندارد؛ نوبت به رندر نمی‌رسد.
  1. صفحه سالم است ولی لینک‌های تولیدشده با http هستند. لینک داخل ایمیل، آدرس داخل نقشه‌ی سایت، canonical صفحه، یا مسیر تصویری که سال قبل داخل محتوا ذخیره شده. این‌جا مرورگر شکایتی ندارد چون خودش دارد یک صفحه‌ی http را می‌بیند؛ فقط شما دارید کاربر را از مسیر ناامن رد می‌کنید.

این‌ها سه ریشه دارند. علامت اول از سمت مرورگر است، علامت دوم از سمت لایه‌های ریدایرکت، و علامت سوم از سمت تولید لینک در برنامه. ما اول همه را با هم انداختیم گردنِ «تنظیمات گواهی» و دو ساعت وقت تلف کردیم.

mixed content: تفاوت passive و active#

وقتی یک سند با https بارگذاری می‌شود، مرورگر توقع دارد هر چیزی که آن سند درخواست می‌کند هم امن باشد. اگر نباشد، بسته به نوع منبع دو رفتار متفاوت می‌بیند.

نوع منبعمثالرفتار مرورگر
نمایشی (passive)تصویر، ویدیو، صوتمعمولاً اول تلاش می‌کند آدرس را به https ارتقا بدهد؛ اگر نشد منبع را نمی‌آورد و قفل کامل نمایش داده نمی‌شود
اجرایی (active)اسکریپت، استایل، iframe، فونت، درخواست‌های fetch و XHRبدون تعارف بلاک می‌شود

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

صفحه‌ی امن با سه منبع که یکی از آن‌ها ناامن است و بلاک شده

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

پیدا کردن مقصر در کنسول مرورگر#

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

تب Network#

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

و یک جست‌وجوی ساده در پروژه هم معمولاً نصف مقصرها را رو می‌کند:

Bash
grep -rn "http://" resources/views public/js public/css | grep -v "http://localhost"

ریشه‌ی رایج در لاراول: APP_URL و لینک‌هایی که asset() و url() می‌سازند#

این‌جا یک باور غلطِ رایج هست که باید صافش کنیم. خیلی از راهنماها می‌گویند «APP_URL را روی https بگذار تا mixed content حل شود». این فقط نصفِ داستان است.

در جریان یک درخواست HTTP، url() و asset() و route() ریشه و شمای آدرس را از خودِ درخواست جاری می‌گیرند، نه از APP_URL. یعنی اگر لاراول درخواست را امن ببیند، لینک‌ها هم https می‌شوند؛ حتی اگر APP_URL را دست نزنید.

APP_URL جایی حیاتی است که درخواستی در کار نیست: دستورهای artisan، کارهای صف، ایمیل‌هایی که در پس‌زمینه رندر می‌شوند، نقشه‌ی سایت، فید. لاراول برای اجرای کنسول یک درخواست ساختگی از روی همین مقدار می‌سازد. پس اگر APP_URL اشتباه باشد، سایت در مرورگر ممکن است بی‌عیب باشد ولی لینکِ داخل ایمیل فعال‌سازی http://localhost/... دربیاید.

مقدار درست، بدون اسلش انتهایی و با شمای درست:

INI
APP_URL=https://di9.ir

اگر فایل‌های ساخته‌شده را از دامنه یا زیردامنه‌ی دیگری سرو می‌کنید، برای asset() متغیر جدا هست:

INI
ASSET_URL=https://cdn.di9.ir

دو تله‌ی عملی این‌جا: اول این‌که اگر config:cache گرفته باشید، تغییر .env تا وقتی کش پاک نشود هیچ اثری ندارد. دوم این‌که اگر مقدار را از ویندوز کپی کرده باشید، ممکن است یک کاراکتر نامرئی ته خط چسبیده باشد و آدرس عملاً خراب شود؛ پایان‌خط ویندوزی در فایل env دقیقاً همین است و تشخیصش سخت‌تر از چیزی است که به‌نظر می‌رسد.

آدرس‌های مطلق http که در دیتابیس مانده‌اند#

بخشی از mixed content ما اصلاً از کد نمی‌آمد. از محتوا می‌آمد. توضیحات محصول‌هایی که دو سال پیش با ویرایشگر متن نوشته شده بودند، داخلشان <img src="http://di9.ir/uploads/..."> داشتند. هیچ تنظیمی در لاراول این‌ها را عوض نمی‌کند، چون از دید برنامه فقط یک رشته‌ی متن است.

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

SQL
UPDATE products
SET description = REPLACE(description, 'http://di9.ir/', 'https://di9.ir/')
WHERE description LIKE '%http://di9.ir/%';

یک هشدار جدی: ستون‌هایی که داده‌ی سریالایزشده‌ی PHP دارند را با REPLACE دست نزنید. در آن قالب، طول هر رشته کنارش ذخیره شده؛ چهار کاراکتر که اضافه شود، طول دیگر نمی‌خواند و کل مقدار غیرقابل خواندن می‌شود. برای آن ستون‌ها باید مقدار را در PHP باز کنید، عوض کنید و دوباره ذخیره کنید. برای ستون‌های JSON معمولاً مشکلی نیست، ولی باز هم اول روی یک کپی امتحان کنید.

درس بلندمدتش این است که آدرس مطلق در محتوا ذخیره نکنید. مسیر نسبی مثل /uploads/x.jpg هم از این مشکل در امان است و هم روزی که دامنه عوض شود دردسر نمی‌سازد.

پروکسی و ترمیناسیون TLS: چرا isSecure() روی سی‌پنل false برمی‌گردد#

این بخش کلید هر دو مشکلِ باقی‌مانده است.

روی خیلی از هاست‌های اشتراکی، رمزگذاری در یک لایه‌ی جلویی باز می‌شود و از آن‌جا به بعد، درخواست به‌صورت ساده به سرویس‌دهنده‌ی PHP می‌رسد. یعنی از دید برنامه‌ی شما، این یک درخواست http است. $request->isSecure() مقدار false برمی‌گرداند، $request->getScheme() می‌شود http، و متغیر HTTPS در محیط سرور یا خالی است یا off.

اطلاعاتِ گم‌شده در هدرهایی مثل X-Forwarded-Proto منتقل می‌شود — لایه‌ی جلویی می‌گوید «کاربر با https آمده بود». ولی چون هر کسی می‌تواند چنین هدری بفرستد، هیچ فریم‌ورکِ عاقلی به‌طور پیش‌فرض به آن اعتماد نمی‌کند. تا وقتی شما نگویید کدام پروکسی قابل‌اعتماد است، این هدر نادیده گرفته می‌شود.

برای دیدن واقعیت، یک مسیر موقت بگذارید:

PHP
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/_scheme', function (Request $request) {
    return [
        'secure' => $request->isSecure(),
        'scheme' => $request->getScheme(),
        'xfp' => $request->header('X-Forwarded-Proto'),
        'root' => url('/'),
    ];
});

اگر secure مقدار false بود ولی xfp مقدار https داشت، تشخیص قطعی است. این مسیر را بعد از تست حتماً بردارید و برای دیدنش هم سراغ روشن‌کردن APP_DEBUG روی هاست اشتراکی نروید؛ لازم نیست.

TrustProxies و forceScheme: دو درمان با دو ریسک متفاوت#

دو راه هست و فرقشان مهم است.

راه اول، درست کردن ریشه. به لاراول بگویید به پروکسی اعتماد کند تا خودِ درخواست امن دیده شود. در نسخه‌های جدید این تنظیم در bootstrap/app.php است (در نسخه‌های قدیمی‌تر میدل‌ور TrustProxies همین کار را می‌کرد):

PHP
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

// ...
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: ['10.0.0.0/8', '127.0.0.1'],
        headers: Request::HEADER_X_FORWARDED_FOR
            | Request::HEADER_X_FORWARDED_HOST
            | Request::HEADER_X_FORWARDED_PORT
            | Request::HEADER_X_FORWARDED_PROTO,
    );
})

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

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

PHP
use Illuminate\Support\Facades\URL;

// در متد boot کلاس AppServiceProvider
public function boot(): void
{
    if ($this->app->environment('production')) {
        URL::forceScheme('https');
    }
}

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

و یک نکته‌ی ظریف که ما را ساعت‌ها معطل کرد: forceScheme فقط آدرس‌های تولیدشده را عوض می‌کند و درخواست را امن نمی‌کند. $request->isSecure() همچنان false می‌ماند. اگر جایی میدل‌وری دارید که بر اساس isSecure() تصمیم به ریدایرکت می‌گیرد، forceScheme مشکل آن را حل نمی‌کند — و درست همان‌جا حلقه ساخته می‌شود.

حلقه‌ی ریدایرکت: وقتی چند لایه هم‌زمان به https می‌فرستند#

روی یک هاست سی‌پنلی، دست‌کم چهار جا می‌توانند ریدایرکت به https بزنند و معمولاً هیچ‌کدام از وجود بقیه خبر ندارند:

  • گزینه‌ی اجباری‌کردن https در بخش دامنه‌های سی‌پنل
  • فایل .htaccess در ریشه‌ی حساب
  • فایل .htaccess در پوشه‌ی public پروژه
  • میدل‌ور یا کد داخل خود لاراول

اگر دوتا از این‌ها هم‌زمان روشن باشند، همیشه حلقه نمی‌شود؛ ممکن است فقط یک ریدایرکت اضافه بخورید. حلقه وقتی بسته می‌شود که شرطِ ریدایرکت هیچ‌وقت ارضا نشود. و این دقیقاً چیزی است که پشت پروکسی اتفاق می‌افتد: کاربر با https می‌آید، پروکسی رمز را باز می‌کند و درخواست ساده را به سرور می‌دهد، سرور می‌بیند HTTPS روشن نیست و با ۳۰۱ به https می‌فرستد، مرورگر دوباره می‌آید، پروکسی دوباره ساده تحویل می‌دهد، و همین‌طور تا وقتی مرورگر خسته شود.

حلقه‌ی ریدایرکت بین مرورگر و پروکسی و اپلیکیشن

این توضیح می‌دهد چرا برای بعضی کاربرها خراب بود و برای بعضی نه: کسانی که آدرس را با www باز می‌کردند از مسیر متفاوتی رد می‌شدند.

تشخیص حلقه با curl و خواندن زنجیره‌ی ۳۰۱#

مرورگر فقط می‌گوید «زیاد شد». curl کل زنجیره را نشان می‌دهد و همین یک دستور معمولاً پرونده را می‌بندد:

Bash
curl -sIL --max-redirs 10 http://di9.ir | grep -i -E '^(HTTP/|location:)'

خروجی سالم کوتاه است: یک 301 با یک Location به نسخه‌ی https و بعد یک 200. خروجی بیمار الگوی تکراری دارد — همان Location بارها و بارها، یا دو آدرس که مدام همدیگر را صدا می‌زنند (کلاسیک‌ترینش رفت‌وبرگشت بین نسخه‌ی با www و بدون www).

هر چهار حالت را جدا امتحان کنید، چون هرکدام ممکن است زنجیره‌ی متفاوتی داشته باشد:

Bash
for u in http://di9.ir http://www.di9.ir https://di9.ir https://www.di9.ir; do
  echo "== $u"
  curl -sIL --max-redirs 10 "$u" | grep -i -E '^(HTTP/|location:)'
done

و یک تست تأییدی که فرضیه‌ی پروکسی را قطعی می‌کند:

Bash
curl -sI -H 'X-Forwarded-Proto: https' http://di9.ir | head -n 1

اگر با این هدر پاسخ 200 شد و بدون آن 301، یعنی منطق ریدایرکت شما به همان هدر نگاه می‌کند و در مسیر عادی آن را نمی‌بیند.

ریدایرکت درست در .htaccess و اهمیت ترتیب قانون‌ها#

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

Apache
RewriteEngine On

RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

دو شرط پشت سر هم یعنی «و». پس اگر یا اتصال مستقیم امن باشد یا پروکسی گفته باشد امن بوده، ریدایرکتی در کار نیست و حلقه بسته نمی‌شود.

جای این تکه مهم است. باید در public/.htaccess، بلافاصله بعد از RewriteEngine On و قبل از قانون‌های خود لاراول بیاید — یعنی قبل از آن بلوکی که همه‌چیز را به index.php می‌فرستد. اگر بعدش بگذارید، درخواست پیش از رسیدن به قانون شما به کنترلر جلویی تحویل داده شده و قانون عملاً هیچ‌وقت اجرا نمی‌شود. قانون‌های مربوط به هدر Authorization و اسلش انتهایی را هم دست نزنید و جابه‌جا نکنید.

و مهم‌تر از همه: یکی را انتخاب کنید. اگر این قانون را در .htaccess گذاشتید، گزینه‌ی اجباری‌کردن https در سی‌پنل را خاموش کنید و میدل‌ور ریدایرکت داخل لاراول را هم بردارید. سه لایه‌ای که همه کار درستی می‌کنند، با هم کار غلطی می‌سازند.

یک تذکر صادقانه هم بدهیم: شرط X-Forwarded-Proto وقتی معنا دارد که پروکسی این هدر را خودش بازنویسی کند. اگر لایه‌ی جلویی هدر ورودی کاربر را دست‌نخورده رد کند، کسی می‌تواند با فرستادن همین هدر از ریدایرکت فرار کند. در عمل روی سی‌پنل مسئله‌ی بزرگی نیست، ولی اگر امنیت برایتان جدی است، به‌جای آن روی HSTS حساب کنید.

HSTS و کش: چرا اصلاح درست هم بلافاصله دیده نمی‌شود#

HSTS#

هدر Strict-Transport-Security به مرورگر می‌گوید «از این به بعد برای این دامنه اصلاً http را امتحان نکن». نتیجه‌اش این است که ریدایرکت اول حذف می‌شود و حمله‌ی پایین‌آوردن اتصال بی‌اثر می‌شود. خوب است — ولی فقط وقتی مطمئن شده باشید.

Apache
<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=300"
</IfModule>

عمداً max-age را کوچک گذاشتیم. با یک عدد کوچک شروع کنید، چند روز سایت را زیر نظر بگیرید و بعد بالا ببرید. دلیلش این است که برگشت از HSTS سخت است: اگر عدد را روی یک سال بگذارید و بعد معلوم شود یکی از زیردامنه‌ها گواهی ندارد، فرستادن max-age=0 هم فوری کارساز نیست — هر مرورگر تا وقتی دوباره به سایت سر نزند، سیاست قبلی را نگه می‌دارد. includeSubDomains را هم تا وقتی مطمئن نشده‌اید همه‌ی زیردامنه‌ها روی https سالم‌اند اضافه نکنید. و به فهرست پیش‌بارگذاری مرورگرها تا وقتی همه‌چیز چند ماه بی‌دردسر نچرخیده نزدیک نشوید؛ خارج‌شدن از آن فهرست هفته‌ها طول می‌کشد.

کش سرور و کش مرورگر#

بعد از هر اصلاحی، دو تا کش می‌توانند شما را متقاعد کنند که هیچ‌کاری نکرده‌اید.

سمت سرور، کش پیکربندی مقدارهای .env را منجمد می‌کند. پس:

Bash
php artisan config:clear
php artisan view:clear
php artisan route:clear

config:clear واجب است. view:clear احتیاطِ ارزانی است: قالب‌های کامپایل‌شده معمولاً با تغییر فایل منبع خودشان دوباره ساخته می‌شوند، ولی وقتی فایل‌ها را با اف‌تی‌پی آپلود می‌کنید و زمان تغییر فایل درست منتقل نمی‌شود، ممکن است نسخه‌ی قدیمی بماند. اگر روی پروداکشن config:cache می‌گیرید، بعد از پاک‌کردن دوباره بگیرید.

سمت مرورگر، مسئله‌ی جدی‌تری هست: پاسخ ۳۰۱ دائمی است و مرورگر آن را کش می‌کند. یعنی بعد از این‌که حلقه را در سرور بستید، مرورگر خودتان ممکن است هنوز در حلقه باشد. برای تست، پنجره‌ی ناشناس باز کنید یا داده‌های همان سایت را پاک کنید — بارگذاری مجدد با کش خالی کافی نیست. و توصیه‌ی عملی: وقتی دارید قانون ریدایرکت را می‌سازید، موقتاً R=302 بگذارید و فقط بعد از این‌که زنجیره درست شد، به R=301 تغییرش بدهید. ۳۰۲ به‌طور پیش‌فرض کش نمی‌شود و شما را از این تله بیرون نگه می‌دارد.

اگر بعد از همه‌ی این‌ها هنوز فایل قدیمی می‌گیرید، مشکل احتمالاً جای دیگری است؛ کش immutable و نرسیدن CSS دقیقاً همان حالتی است که فایل درست روی سرور هست ولی به کاربر نمی‌رسد.

تست نهایی: زنجیره، کنسول، با www و بدون www#

قبل از این‌که بگویید تمام شد، این شش تا را رد کنید:

  1. زنجیره‌ی هر چهار آدرس (با و بدون www، با و بدون https) حداکثر یک ریدایرکت داشته باشد و به 200 برسد.
  2. کنسول مرورگر در صفحه‌ی اصلی، صفحه‌ی محصول و صفحه‌ی پرداخت هیچ پیام mixed content نداشته باشد — این سه صفحه معمولاً منبع‌های متفاوتی دارند.
  3. قفل در نوار آدرس کامل باشد، در پنجره‌ی ناشناس.
  4. یک ایمیل تراکنشی واقعی بفرستید و لینک داخلش را نگاه کنید؛ این‌جاست که APP_URL غلط خودش را نشان می‌دهد.
  5. نقشه‌ی سایت و آدرس canonical صفحه‌ها را ببینید که https باشند.
  6. یک محتوای قدیمی را باز کنید و مطمئن شوید تصاویر داخلش هم امن آمده‌اند.

اگر تازه دارید پروژه را روی هاست بالا می‌آورید، یک بار چک‌لیست هاست اشتراکی برای لاراول را هم رد کنید؛ نصف تله‌های این مقاله همان‌جا از اول بسته می‌شود.

درس اصلی#

«اس‌اس‌ال کار نمی‌کند» یک تشخیص نیست، یک شکایت است. سه مشکلِ کاملاً جدا زیرش می‌نشیند و هرکدام لایه‌ی خودش را دارد: mixed content در مرورگر، حلقه‌ی ریدایرکت در لایه‌ی وب‌سرور و پروکسی، و لینک‌های http در تولیدکننده‌ی آدرسِ برنامه.

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