cross-site scripting چیست
Cross-site scripting (XSS) آسیبپذیریای است که در آن مهاجم باعث میشود صفحهای روی سایت شما JavaScriptی را که او نوشته شامل شود. مرورگر نمیتواند آن اسکریپت را از اسکریپت خودتان تشخیص دهد: از origin شما میرسد، پس با هر چیزی که origin شما اجازهٔ انجامش را دارد اجرا میشود، از جمله sessionهای کاربرانتان.
علت ریشهای همیشه یکسان است. متنی که کس دیگری کنترلش را دارد، مثل یک نظر، یک عبارت جستوجو، یک نام نمایشی یا یک fragment از URL، به شکلی در یک صفحه درج میشود که به مرورگر اجازه میدهد آن را بهجای متن ساده، بهعنوان markup یا کد بخواند. نظری که باید بهشکل کاراکترهای واقعی <script> ظاهر شود، بهجایش یک عنصر script میشود.
این نام تاریخی است و کمی گمراهکننده: حملهٔ کلاسیک سایت دومی را شامل میشد، اما امروز اکثر XSS صرفاً دادهٔ غیرقابلاعتماد است که درون صفحهٔ خودتان اجرا میشود. OWASP آن را زیر دستهٔ Injection در OWASP Top 10 پرونده میکند، کنار پسرعموی سمتسرورش، تزریق SQL. تفاوت در interpreter است. تزریق SQL دیتابیس شما را گول میزند؛ XSS مرورگر کاربرانتان را گول میزند.
XSS باگی در اپلیکیشن است، نه در مرورگر یا شبکه. HTTPS آن را جلو نمیگیرد، یک فایروال هم کاملاً جلویش را نمیگیرد، و فقط کدی که صفحه را میسازد میتواند حذفش کند.
سه نوع: stored، reflected و DOM-based
Stored XSS (که persistent هم نامیده میشود) مخربترین نوع است. ورودی مخرب توسط سرور ذخیره میشود، در یک نظر، یک فیلد پروفایل، یک نقد محصول یا یک تیکت پشتیبانی، و بعد به هر بازدیدکنندهای که آن صفحه را باز کند سرویس داده میشود، اغلب از جمله مدیرانی که back office را میخوانند. هیچکس مجبور نیست روی یک لینک خاص کلیک کند.
Reflected XSS ذخیره نمیشود. سرور چیزی از درخواست، معمولاً یک پارامتر query، میگیرد و مستقیم آن را در پاسخ مینویسد: صفحهٔ جستوجویی که «نتایج برای ...» چاپ میکند، یا صفحهٔ خطایی که مقدار بد را echo میکند. مهاجم باید قربانی را مجاب کند یک لینک ساختهشده را، از طریق ایمیل، چت یا سایت دیگر، باز کند.
DOM-based XSS کاملاً در مرورگر اتفاق میافتد. JavaScript خود شما مقداری را که مهاجم کنترلش را دارد میخواند، مثل location.hash، location.search، دادهی postMessage یا document.referrer، و آن را داخل یک sink خطرناک مینویسد: innerHTML، document.write، eval، setTimeout با یک رشته، یا یک URL از نوع javascript: در یک href. سرور ممکن است هرگز payload را نبیند، چون هر چیزی بعد از # با درخواست فرستاده نمیشود.
سه مثال کتابی زیر شکل هر باگ را نشان میدهند. در هر حالت راهحل همان ایده است: مقدار را بهعنوان متن درنظر بگیرید.
الگوهای مینیمال آسیبپذیر (اینها را ship نکنید)
<!-- Stored: a saved comment printed without escaping (PHP) -->
<p><?= $comment->body ?></p>
<!-- body saved as: <script>alert(document.domain)</script> -->
<!-- Reflected: the search term echoed from the URL -->
<h1>Results for <?= $_GET['q'] ?></h1>
<!-- link: /search?q=<script>alert(document.domain)</script> -->
// DOM-based: client code writes the URL fragment as HTML
document.getElementById('greeting').innerHTML =
decodeURIComponent(location.hash.slice(1));
// link: /welcome#<img src=x onerror=alert(document.domain)>
مهاجم با آن واقعاً چه میتواند بکند
alert(1) چیزی است که تسترها با آن باگ را اثبات میکنند؛ چیزی نیست که مهاجمان اجرا میکنند. وقتی اسکریپتشان در صفحهٔ شما اجرا شود، بهعنوان کاربر لاگینشده، درون origin شما عمل میکند.
دزدی session. اگر کوکی session از JavaScript خوانا باشد، اسکریپت آن را برای مهاجم میفرستد، که بعد بهجای قربانی لاگین میکند. tokenهایی که در localStorage یا sessionStorage نگه داشته میشوند همیشه برای اسکریپت خوانا هستند، که دلیل اصلی مخالفت با نگهداشتن tokenهای طولانیمدت در آنجاست.
انجام اقدامها بهجای کاربر. حتی وقتی کوکی HttpOnly باشد، اسکریپت میتواند با fetch API شما را صدا بزند؛ مرورگر خودش کوکی را میچسباند. میتواند tokenهای CSRF را از صفحه بخواند، آدرس ایمیل حساب را عوض کند، یک کاربر مدیر بسازد، پیام بفرستد یا سفارش بگذارد. قواعد same-origin، CORS و کوکیهای SameSite این را متوقف نمیکنند، چون درخواست از سایت خودتان میآید.
خواندن چیزی که کاربر میبیند. دادهی شخصی، پیامها، فاکتورها، هر چیزی که روی صفحه یا از طریق API شما در دسترس است.
Phishing و keylogging روی دامنهٔ شما. اسکریپت میتواند صفحه را بهشکل یک فرم ورود دوباره بکشد یا آنچه کاربر در فرمهای واقعی تایپ میکند را ضبط کند. نوار آدرس دامنهٔ شما و یک گواهی معتبر را نشان میدهد، پس کاربر دلیلی برای شککردن ندارد.
تخریب و پخششدن. Stored XSS میتواند چیزی که هر بازدیدکننده میبیند را عوض کند، یا خودش را در پروفایل هر قربانی کپی کند. کرم Samy در سال 2005 روی MySpace اینطور در کمتر از یک روز به بیش از یک میلیون پروفایل پخش شد.
چقدر بد بودن یک XSS خاص به این بستگی دارد که چه کسی صفحه را میبیند. باگی در یک صفحهٔ مخصوص مدیر که از طریق ورودی stored، مثل موضوع یک تیکت پشتیبانی، به آن میرسد، اغلب بدتر از باگی در صفحهٔ اصلی عمومی است.
راهحل: encoding خروجی آگاه به context
دفاع اصلی این است که در لحظهای که دادهی غیرقابلاعتماد را در صفحه مینویسید، به شکلی که context اطراف لازم دارد، آن را encode کنید. validation ورودی کمک میکند (یک کد پستی باید شبیه کد پستی باشد)، اما نمیتواند کنترل اصلی باشد، چون همان مقدار ممکن است در یک context امن و در context دیگری خطرناک باشد.
بدنهٔ HTML: &، <، >، " و ' را به entity تبدیل کنید، پس <script> نمایش داده میشود نه پارس.
attribute HTML: همیشه مقادیر attribute را quote کنید، و همان کاراکترها را escape کنید. یک attribute بدون quote را میشود با یک فاصلهٔ تکی شکست.
URL در href یا src: encoding کافی نیست، چون javascript:alert(1) چیزی برای escapeکردن ندارد. URL را پارس کنید و فقط https: و http: را (و mailto: اگر لازم دارید) اجازه دهید؛ مقادیر query را با encodeURIComponent encode کنید.
درون یک بلوک <script>: رشتهها را داخل JavaScript concatenate نکنید. داده را با یک تابع که < را هم escape میکند بهعنوان JSON serialize کنید، یا آن را در یک attribute از نوع data- بگذارید و با element.dataset بخوانید.
استایلها: از گذاشتن دادهی کاربر در CSS کلاً پرهیز کنید.
سمت کلاینت: APIهای امن را ترجیح دهید. textContent، setAttribute روی attributeهای بیخطر و createElement هرگز HTML را پارس نمیکنند؛ innerHTML، outerHTML، insertAdjacentHTML و document.write این کار را میکنند.
نوشتن امن دادهی کاربر در مرورگر
// Text, never markup
el.textContent = userName;
// Links: allow only http(s)
function safeUrl(value) {
try {
const url = new URL(value, location.origin);
return ['https:', 'http:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
link.href = safeUrl(profile.website);
// Data for scripts: a data attribute, not string concatenation
// <div id="app" data-user="{{ user_json }}"> (escaped by the template)
const user = JSON.parse(document.getElementById('app').dataset.user);
auto-escaping فریمورکها، و escape hatchهایشان
فریمورکهای مدرن از قبل برای context HTML بهطور پیشفرض encode میکنند. {{ }} در Blade، Twig، Jinja، Django templates، Vue و Angular، <%= %> در Rails ERB و {value} در React JSX همگی چیزی که چاپ میکنند را escape میکنند. این دلیل اصلی این است که XSS نادرتر از زمان صفحات PHP دستساز شده، و دلیلی برای ادامهٔ استفاده از روش پیشفرض فریمورک برای چاپ مقادیر.
تقریباً هر XSS واقعی در یک codebase مدرن در یک escape hatch زندگی میکند، ویژگیای که عمداً escaping را خاموش میکند:
- dangerouslySetInnerHTML در React، v-html در Vue، {@html} در Svelte، bypassSecurityTrustHtml در Angular.
- {!! $value !!} در Laravel Blade، |safe و Markup() در Jinja، mark_safe و {% autoescape off %} در Django، raw و html_safe در Rails.
- نوشتن مستقیم روی DOM از کد component: ref.current.innerHTML = ...، .html() در jQuery، $(userInput).
شکافهای دیگر contextهایی هستند که auto-escaper نمیفهمد. یک template که HTML را escape میکند همچنان javascript: را در href={url} عبور میدهد، همچنان اجازه میدهد یک مقدار کاربر درون یک <script> inline یا event handler کد را بشکند، و هرگز رشتهای را که به eval میدهید محافظت نمیکند.
یک قاعدهٔ عملی: escape hatchها را نادر، قابلجستوجو و بازبینیشده نگه دارید. یک جستوجوی کد برای فهرست بالا اکثر exposure شما را پیدا میکند، و یک قاعدهٔ linter (مثلاً ESLint react/no-danger، یا یک قاعدهٔ Semgrep برای {!!) مواردی جدید را از لای انگشت رد نمیدهد.
وقتی مجبورید HTML بپذیرید: از یک sanitizer واقعی استفاده کنید
بعضی ویژگیها به HTML کاربر نیاز دارند: یک ویرایشگر rich-text، بدنهٔ یک CMS، یک پست فروم با قالببندی، پیشنمایش یک ایمیل. escaping قالببندی را خراب میکند، پس راهحل sanitizing است: پارسکردن HTML و نگهداشتن فقط یک allowlist از عنصرها و attributeهای امن.
از یک کتابخانهٔ نگهداریشده و ساختهشده برای همین کار استفاده کنید، هرگز یک عبارت باقاعده یا یک blocklist از tagهای «بد». مرورگرها HTML را بهشکلهای غافلگیرکننده پارس میکنند، و هر فیلتر دستساختهای که <script> را حذف میکند با یک attribute event-handler، یک عنصر SVG، تودرتویی عجیب یا یک ترفند encoding دور زده شده است. انتخابهای جاافتاده DOMPurify در مرورگر و در Node.js با jsdom، HTML Purifier برای PHP، nh3 (کتابخانهٔ Rust ammonia) برای Python، و OWASP Java HTML Sanitizer برای Java هستند.
سه جزئیات فرق را میسازند. با allowlistی بهکوچکی چیزی که ویژگی لازم دارد sanitize کنید. در خروجی sanitize کنید، یا دوباره در خروجی، تا تغییر بعدی در کتابخانه یا در allowlistتان دادهی قدیمی را هم محافظت کند. و بعد از sanitizeکردن HTML را تغییر نندهید: دوبارهپارسکردن، جایگزینی رشته یا درجکردنش در یک context دیگر میتواند یک نتیجهٔ امن را ناامن کند.
برای Markdown، HTML رندرشده به همان رفتار نیاز دارد: اکثر رندرکنندههای Markdown بهطور پیشفرض HTML خام را اجازه میدهند.
محدودکردن آسیب: HttpOnly، SameSite و Content-Security-Policy
فرض کنید بالاخره یک XSS از لای انگشتها رد میشود، و بیارزشش کنید.
کوکیها. کوکیهای session را HttpOnly علامت بزنید، پس document.cookie نمیتواند آنها را بخواند، بههمراه Secure و SameSite=Lax یا Strict. این سناریوی کوکیدزدیشده را متوقف میکند. اسکریپت را از عملکردن بهجای کاربر تا وقتی صفحه باز است متوقف نمیکند، پس این کنترل آسیب است، نه راهحل. به همین دلیل tokenهای طولانیمدت را از localStorage دور نگه دارید.
Content-Security-Policy. یک CSP به مرورگر میگوید کدام اسکریپتها اجازهٔ اجرا دارند. سیاستی که XSS را متوقف میکند یک سیاست سختگیرانه است: یک nonce تصادفی که برای هر پاسخ تازه تولید میشود، هم در هدر هم روی هر تگ <script> مشروع، بههمراه 'strict-dynamic'، object-src 'none' و base-uri 'none'. یک <script> تزریقشده هیچ nonce معتبری ندارد، و event handlerهای inline مثل onerror= مسدود میشوند، پس اکثر باگهای XSS به یک خطای کنسول و یک report تبدیل میشوند. سیاستی که فقط دامنهها را فهرست میکند و 'unsafe-inline' را نگه میدارد محافظت کمی در برابر XSS میدهد.
اول آن را بهشکل Content-Security-Policy-Report-Only منتشر کنید، چیزی که reportها نشان میدهند را رفع کنید، بعد enforce کنید. راهنمای هدرهای امنیتی HTTP انتشار report-only، هدرهای دیگری که کنارش میآیند، و اینکه چرا X-XSS-Protection دیگر نباید فرستاده شود را پوشش میدهد.
یک تلهٔ cache: یک nonce باید غیرقابلپیشبینی باشد. اگر یک CDN یا page cache همان HTML را به همه سرویس دهد، هر بازدیدکننده همان nonce را میگیرد، و مهاجم میتواند آن را از صفحه بخواند. برای صفحات cacheشده، بهجایش از hashهای اسکریپت ('sha256-...') استفاده کنید، یا HTMLای که nonce حمل میکند را از cache دور نگه دارید.
جایی که مرورگرها پشتیبانی میکنند، require-trusted-types-for 'script' یک قدم جلوتر میرود: sinkهای DOM مثل innerHTML رشتههای ساده را رد میکنند، پس DOM-based XSS باید از کدی که خودتان نوشتهاید عبور کند.
یک سیاست سختگیرانه و مبتنی بر nonce (یک nonce تازه برای هر پاسخ)
Content-Security-Policy: script-src 'nonce-R4nd0mPerResponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mPerResponse" src="/js/app.js"></script>
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
تست اپلیکیشن خودتان برای XSS
فقط سیستمهایی را تست کنید که مالکشان هستید یا اجازهٔ تستشان را دارید. برای اپ خودتان، یک marker بیخطر بدون هیچ کد حملهای اکثر باگها را پیدا میکند.
هر ورودی را تا هر خروجی ردیابی کنید. یک marker یکتا حاوی کاراکترهای معنادار-در-HTML، مثلاً xss7"'<b>bold</b>، در هر فیلد، پارامتر query، هدری که اپتان نمایش میدهد، و نام فایلی که میپذیرید بگذارید. بعد هر صفحهای که آن مقدار در آن ظاهر میشود را ببینید، از جمله صفحات مدیریتی، ایمیلها، exportها و اعلانها. اگر کلمه bold ظاهر شود، یا source صفحه <b> بدون escape یا یک quote که یک attribute را میبندد نشان دهد، خروجی encode نشده.
DOM را چک کنید، نه فقط source را. برای باگهای DOM-based، marker را در location.hash و query string بگذارید و DOM زنده را در developer tools بازرسی کنید: view-source فقط چیزی را نشان میدهد که سرور فرستاده.
کد را جستوجو کنید. بهدنبال escape hatchهای فهرستشده بالا و innerHTML، insertAdjacentHTML، document.write، eval و new Function grep کنید. هر برخورد باید یا حذف شود یا یک comment کوتاه داشته باشد که توضیح دهد چرا ورودی امن است.
از ابزارها استفاده کنید. OWASP ZAP و Burp Suite خودکار crawl میکنند و ورودیها را تست میکنند و موارد واضح reflected را پیدا میکنند؛ static analysis مثل Semgrep یا CodeQL جریانهای دادهای را دنبال میکند که یک crawler نمیبیند. هیچکدام جای ردیابی دستی برای stored XSS در صفحات back-office را نمیگیرند.
به reportهای CSP نگاه کنید. وقتی یک سیاست report-only در جای خودش باشد، نقضهای ناشی از اسکریپتهای inline غیرمنتظره یک هشدار زودهنگام رایگاناند.
grep -rnE 'dangerouslySetInnerHTML|v-html|\{@html|bypassSecurityTrust|innerHTML|insertAdjacentHTML|document\.write' src/
grep -rnE '\{!!|\|safe|mark_safe|html_safe|raw\(' resources/ templates/ app/
جایی که یک WAF مینشیند، و کاری که WAF CDN.com.tr میکند
یک فایروال اپلیکیشن وب درخواستها را بازرسی میکند و موردهایی را که شبیه حملهاند مسدود میکند. برای XSS این مفید است: تگهای script و event handlerها در یک query string یا بدنهٔ فرم قابلتشخیصاند، پس تلاشهای reflected و اسکنرهای خودکار پیش از رسیدن به اپلیکیشن شما متوقف میشوند، و یک payload stored که از یک فرم معمولی فرستاده میشود اغلب در راه ورودش رد میشود.
این یک لایه است، نه راهحل. یک WAF درخواستها را میبیند، نه صفحات شما را، پس نمیتواند بداند یک مقدار بعداً چطور استفاده خواهد شد. DOM-based XSS که در fragment URL زندگی میکند هرگز به آن نمیرسد، ورودیای که از طریق یک import، یک API که صدا میزنید یا کانالی که WAF بازرسی نمیکند میآید از لایش رد میشود، و مهاجمان payloadها را برای فرار از الگوها تنظیم میکنند. راهنمای OWASP Top 10 با جزئیات نقشه میکشد که یک فایروال چه چیزی را میتواند بگیرد و چه چیزی را نه. خروجی را encode کنید، HTML را sanitize کنید و یک CSP بگذارید، فارغ از اینکه یک WAF جلویش باشد یا نه.
روی CDN.com.tr، WAF ModSecurity با OWASP Core Rule Set است، که در edge اجرا میشود و برای حساب از صفحهٔ قوانین تحویل روشن میشود. خانوادهٔ 941 Core Rule Set قواعد cross-site scripting آن است. یک بازدیدکنندهٔ مسدودشده یک صفحهٔ 403 برندشده با یک Reference ID میگیرد، و صفحهٔ WAF Logs رویدادهای مسدودشده را با دستهی حمله، کشور، IP و قاعده فهرست میکند، قابلجستوجو با همان ID در 30 روز گذشته و قابلexport به CSV یا XLSX؛ cdnctl waf logs همان فهرست را از خط فرمان برمیگرداند.
false positive معمول برای قواعد XSS یک پست HTML مشروع است، مثل ویرایشگری که محتوای قالببندیشده را ذخیره میکند یا یک نمونهکد. استثنا برای مسیرهای تکی هنوز در پنل موجود نیست: اگر WAF یک درخواست مشروع را مسدود کرد، Reference IDاش را به پشتیبانی بفرستید نه خاموشکردن WAF.
هدرها هم میتوانند در edge اضافه شوند. هدرهای تکمقداری در هدرهای سفارشی یک قانون تحویل میروند، در هر سطر یکی، و روی پاسخهای 2xx و 3xx اعمال میشوند، از جمله cache hitها. یک Content-Security-Policy کامل نمیتواند آنجا برود، چون پنل و edge ; را در یک سطر هدر رد میکنند؛ یک directive تکی مثل frame-ancestors 'self' کار میکند، و یک nonce هرحال باید بهازای هر پاسخ تولید شود، پس سیاست کامل جای خودش در اپلیکیشن شماست.
cdnctl waf logs --account $ACCOUNT_UUID --range 1d
cdnctl waf show $REFERENCE_ID --account $ACCOUNT_UUID
پرسشهای پرتکرار دربارهٔ XSS
تفاوت XSS از نوع stored، reflected و DOM-based چیست؟
جایی که payload زندگی میکند. XSS از نوع stored روی سرور ذخیره میشود و به هر کسی که صفحه را باز کند سرویس داده میشود. XSS از نوع reflected از همان درخواست برمیگردد، پس قربانی باید یک لینک ساختهشده را باز کند. XSS از نوع DOM-based توسط JavaScript سمت کلاینت خود شما ساخته میشود که مقداری تحتکنترل مهاجم، اغلب از URL، را در صفحه مینویسد؛ سرور ممکن است هرگز آن را نبیند.
آیا HTTPS یا یک گواهی معتبر در برابر XSS محافظت میکند؟
نه. TLS از داده در حال انتقال محافظت میکند. یک payload XSS توسط سرور خودتان یا اسکریپت خودتان روی همان اتصال رمزگذاریشده تحویل داده میشود، و قفل سبز یک فرم ورود ساختگی روی دامنهٔ شما را معتبرتر نشان میدهد، نه کمتر.
آیا HttpOnly برای متوقفکردن XSS کافی است؟
نه. اسکریپت را از خواندن کوکی session متوقف میکند، که یک نتیجه را حذف میکند. اسکریپت همچنان میتواند بهجای کاربر درخواست بفرستد، چون مرورگر کوکی را برایش میچسباند، و همچنان میتواند صفحه را بخواند و عوض کند. HttpOnly کنترل آسیب است؛ encoding خروجی راهحل است.
آیا یک WAF میتواند cross-site scripting را متوقف کند؟
خیلی از تلاشهای reflected و خودکار را متوقف میکند، چون payloadهای اسکریپت در درخواستها قابلتشخیصاند. نمیتواند DOM-based XSS را در fragment URL ببیند، نمیداند اپ شما یک مقدار stored را چطور استفاده خواهد کرد، و میشود با یک payload تنظیمشده از آن فرار کرد. آن را یک لایه جلوی کد درست در نظر بگیرید.
آیا باید هنوز X-XSS-Protection بفرستم؟
نه. فیلتری که کنترلش میکرد از مرورگرهای فعلی حذف شده، و در مرورگرهای قدیمیتر میشد از آن سوءاستفاده کرد. هیچچیز نفرستید یا X-XSS-Protection: 0، و بهجایش یک Content-Security-Policy استفاده کنید.
آیا React یا Vue اپ من را در برابر XSS مصون میکنند؟
حالت معمول را با escapeکردن چیزی که چاپ میکنید امن میکنند. همچنان از طریق dangerouslySetInnerHTML و v-html، از طریق URLهای کاربر در href که با javascript: شروع میشوند، از طریق نوشتن مستقیم روی DOM، و از طریق HTML رندرشده سمت سرور که فریمورک کنترلش را ندارد، در معرض قرار دارید.
self-XSS چیست؟
اسکریپتی که قربانی فریب میخورد در کنسول developer مرورگر خودش paste کند. به هیچ باگی در سایت شما نیاز ندارد، که دلیل این است که مرورگرها وقتی داخل developer tools paste میکنید هشدار میدهند. این مهندسی اجتماعی است، نه آسیبپذیریای در کد شما.