اگر در لاگ WAF یا کنسول مرورگرتان چیزی شبیه Refused to execute inline script because it violates the following Content Security Policy directive دیدهاید و همزمان کاربران گزارش میدهند «بعد از لاگین، خودش لاگین میشود»، احتمالاً با دو مسئلهٔ جدا طرفید که یکی را با دیگری اشتباه گرفتهاید. XSS و CSRF هر دو از مرورگر قربانی سوءاستفاده میکنند، اما یکی کد در صفحهٔ شما اجرا میکند و دیگری فقط درخواست میفرستد. تا وقتی این تفاوت را در ذهن جدا نکنید، هر دو دفاع را نصفه پیاده میکنید.
XSS چیست و چرا توکن CSRF جلوی آن را نمیگیرد
در XSS مهاجم کاری میکند که مرورگر قربانی، جاوااسکریپت او را در دامنهٔ شما اجرا کند. از آن لحظه، آن اسکریپت داخل origin شماست. یعنی به document.cookie دسترسی دارد، میتواند fetch بزند، فرم بسازد و مهمتر از همه، توکن CSRF را از خود صفحه بخواند و در همان درخواست بگذارد.
اینجا اشتباه میکنند: تیمها توکن CSRF را روی فرم میگذارند، تست نفوذ را پاس میکنند و خیالشان راحت میشود. بعد یک فیلد نظرات بدون escape میماند و همان توکن، توسط اسکریپت مهاجم خوانده و استفاده میشود. علامتش هم این است: در لاگ، درخواستها توکن معتبر دارند، Referer درست است، همهچیز سالم به نظر میرسد، ولی عملیات از IP کاربر واقعی انجام شده. توکن CSRF برای این ساخته نشده که جلوی کدی که داخل origin شما اجرا میشود را بگیرد. آن کار CSP و escape کردن خروجی است.
سه نوع XSS که در عمل میبینید
- Reflected: ورودی از URL میآید و بدون escape در HTML برمیگردد. کلاسیکترین شکلش
?q=<script>alert(1)</script>است. - Stored: ورودی در دیتابیس میماند و به همهٔ بازدیدکنندگان سرو میشود. خطرناکترین نوع، چون نیازی به فریب دادن قربانی برای کلیک ندارد.
- DOM-based: نه سرور دخیل است نه پاسخ HTML آلوده. کد سمت کلاینت خودش مقدار
location.hashرا داخلinnerHTMLمیریزد.
برای نوع سوم، هیچ فیلتری روی سرور کمکی نمیکند. باید کد جاوااسکریپت را بخوانید و ببینید کجا از innerHTML، document.write یا eval استفاده شده. جایگزین درست textContent است، نه یک regex که «script» را حذف کند.
CSRF چیست و چرا SameSite تنها کافی نیست
در CSRF مهاجم کد اجرا نمیکند؛ فقط کاری میکند مرورگر قربانی یک درخواست به دامنهٔ شما بفرستد، با کوکی نشست خودش. صفحهٔ مهاجم در دامنهٔ دیگری است و کوکی شما را نمیبیند، اما مرورگر بهطور خودکار آن را ضمیمه میکند. یک فرم مخفی با method="POST" و action روی endpoint تغییر ایمیل، بهمحض لود شدن صفحه ارسال میشود.
کوکی SameSite=Lax بخش بزرگی از این حالتها را میبندد، ولی دو جا کم میآورد. اول، مرورگرهای قدیمیتر که این ویژگی را نادیده میگیرند. دوم، زیردامنههای خودتان: اگر یک زیردامنه به هر دلیلی در اختیار مهاجم باشد یا XSS داشته باشد، درخواست از نظر SameSite همانسایت حساب میشود. پس SameSite را بگذارید، اما بهعنوان لایهٔ دوم، نه جایگزین توکن.
الگوی درست توکن CSRF
توکن باید per-session و تصادفی باشد، در سرور ذخیره شود، و در هر درخواست تغییردهندهٔ وضعیت بررسی شود. الگوی double-submit را فقط وقتی بپذیرید که کوکی توکن HttpOnly نباشد و امضاشده باشد؛ وگرنه زیردامنه میتواند آن را بازنویسی کند.
// Express + csurf-style
app.use(session({ cookie: { httpOnly: true, sameSite: 'lax', secure: true } }));
app.use(csrf({ cookie: false })); // توکن در session، نه کوکی جدا
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken();
next();
});
نکتهٔ عملی: توکن را در متاتگ نگذارید و بعد با جاوااسکریپت بخوانید. همان لحظه که توکن برای JS قابل خواندن شد، برای XSS هم قابل خواندن است. آن را در فرم HTML سمت سرور رندر کنید.
کوکی HttpOnly دقیقاً چه چیزی را میبندد
کوکی نشست را با HttpOnly بفرستید تا document.cookie آن را نبیند. این کار دزدی مستقیم نشست با XSS را میبندد، ولی جلوی CSRF را نمیگیرد؛ چون مرورگر همچنان کوکی را خودکار ضمیمه میکند. این دو مکانیزم روی دو محور مختلف کار میکنند و جای هم را نمیگیرند.
| اقدام | جلوی XSS | جلوی CSRF |
|---|---|---|
| Escape کردن خروجی | بله | خیر |
| کوکی HttpOnly | تا حدی (دزدی نشست) | خیر |
| توکن CSRF | خیر | بله |
| SameSite=Lax/Strict | خیر | تا حد زیادی |
| هدر CSP | بله (لایهٔ دفاعی) | خیر |
Content-Security-Policy را چطور واقعاً کار بیندازیم
یک CSP سختگیرانه، حتی اگر XSS وجود داشته باشد، اجرای اسکریپت مهاجم را متوقف میکند. اما CSP اشتباه، سایت را میشکند و تیم بعد از دو ساعت آن را برمیدارد. مسیر واقعبینانه این است:
- اول با
Content-Security-Policy-Report-Onlyشروع کنید و گزارشها را به یک endpoint جمع کنید. - بعد از یک هفته، دامنههای واقعی را در
script-srcبگذارید و'unsafe-inline'را حذف کنید. - برای اسکریپتهای inline از nonce استفاده کنید، نه هش، چون nonce با هر پاسخ تغییر میکند.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-8f3a...' https://cdn.example.ir;
object-src 'none';
base-uri 'self';
frame-ancestors 'self'
خط base-uri 'self' را دستکم نگیرید. بدون آن، مهاجم میتواند با تزریق یک تگ <base>، همهٔ مسیرهای نسبی صفحه را به دامنهٔ خودش بفرستد و توکن CSRF را از فرم شما بدزدد. این یکی از آن مواردی است که در چکلیستها جا میماند.
تریدآف واقعی: چرا نمیتوان همهجا سختگیر بود
CSP سختگیرانه با 'unsafe-inline' صفر، روی سایتهای قدیمی که دهها اسکریپت inline و افزونهٔ ثالث دارند عملاً یعنی بازنویسی فرانتاند. اگر پروژه در حال مهاجرت است، انتخاب من این است: اول escape کردن خروجی و توکن CSRF را کامل کنید، CSP را در حالت Report-Only بگذارید و بهتدریج سخت کنید. اگر سایت تازهساز است، از روز اول nonce بگذارید؛ بعداً اضافه کردنش چند برابر هزینه دارد.
یک محدودیت دیگر هم هست که کمتر گفته میشود: توکن CSRF روی APIهای stateless که با Bearer token کار میکنند معنا ندارد. آنجا اگر توکن را در localStorage نگه دارید، یک XSS کافی است تا همهچیز از دست برود. برای همین در APIها کوکی HttpOnly بههمراه توکن CSRF انتخاب بهتری است، حتی اگر مدیریتش سختتر باشد.
چکلیست عملی برای امشب
- همهٔ کوکیهای نشست را با
HttpOnly; Secure; SameSite=Laxبفرستید. - هر endpoint تغییردهندهٔ وضعیت را با توکن CSRF محافظت کنید، از جمله logout.
- خروجیها را در قالب HTML escape کنید؛ برای جاوااسکریپت از
textContentاستفاده کنید. - هدر CSP را در حالت Report-Only بالا بیاورید و لاگها را ببینید.
- لاگ ممیزی را جدا و دستنخورده نگه دارید تا بتوانید بعد از حادثه بازسازی کنید؛ لاگ ممیزی چیست و چگونه آن را دستنخورده نگه داریم؟ دقیقاً همین را توضیح میدهد.
اگر روی وردپرس کار میکنید، بخش بزرگی از این حفرهها از افزونهها و REST API میآید. دو نوشتهٔ امنیت وردپرس؛ از wp-config تا افزونههایی که خودشان حفرهاند و امنسازی REST API وردپرس را قبل از هر چیز بخوانید. برای لایهٔ شبکه هم اگر سرویستان زیر بار حملات حجمی است، محافظت در برابر DDoS مکمل این کارهاست، ولی جای escape کردن خروجی را نمیگیرد. اگر تیمتان بزرگ است و رمزها را در چت رد و بدل میکنید، مدیریت رمز عبور در تیمها را هم ببینید؛ نشست دزدیدهشده اغلب از همانجا شروع میشود.
اگر میخواهید این لایهها را بهجای ساختن از صفر، روی زیرساختی بگذارید که خودش بخشی از بار را بردارد، خدمات امنیت سرورنت نقطهٔ شروع معقولی است. ولی یادتان باشد: هیچ سرویسی جای textContent نوشتن در کد شما را نمیگیرد.
پرسشهای پرتکرار
آیا SameSite=Lax جای توکن CSRF را میگیرد؟
نه بهطور کامل. SameSite=Lax جلوی اکثر درخواستهای cross-site را میگیرد، اما درخواستهای top-level GET را رد نمیکند و روی مرورگرهای قدیمی قابل اتکا نیست. اگر endpoint شما با GET وضعیت را تغییر میدهد، SameSite هیچ کمکی نمیکند. توکن CSRF را نگه دارید و SameSite را لایهٔ دوم بدانید.
چرا با وجود توکن CSRF، حملهٔ XSS همچنان موفق میشود؟
چون اسکریپت مهاجم داخل origin شما اجرا میشود و به همان DOM دسترسی دارد که کاربر واقعی دارد. میتواند توکن را از فرم بخواند و درخواست معتبر بفرستد. دفاع درست در این حالت escape کردن خروجی و CSP است، نه توکن.
کوکی HttpOnly جلوی کدام حمله را میگیرد؟
جلوی خواندن کوکی نشست توسط جاوااسکریپت را میگیرد، پس دزدی مستقیم نشست با XSS را سخت میکند. اما جلوی CSRF را نمیگیرد، چون مرورگر همچنان کوکی را بهطور خودکار به درخواست ضمیمه میکند.
حداقل کاری که همین امروز میتوانم انجام دهم چیست؟
سه کار: کوکی نشست را HttpOnly; Secure; SameSite=Lax کنید، هر endpoint تغییردهندهٔ وضعیت را با توکن CSRF ببندید، و همهٔ خروجیهای کاربر را escape کنید. بعد از این سه، سراغ CSP بروید. ترتیب مهم است؛ CSP روی کدی که هنوز escape نمیکند فقط سایت را میشکند.
دیدگاهها ۰
هنوز دیدگاهی ثبت نشده؛ اولین نفر باشید!