توی این درس یاد میگیری با چند خط تنظیم در Nginx، یک لایهی امنیتی مهم به سایتت اضافه کنی. اول اطلاعات اضافه را پنهان میکنی (server_tokens off که نسخهی Nginx را لو نمیدهد، و proxy_hide_header برای هدرهای اپ). بعد هدرهای امنیتی را یکییکی میگذاری و میفهمی هر کدام جلوی چه حملهای را میگیرد: HSTS (همیشه HTTPS)، X-Content-Type-Options (جلوگیری از حدس نوع فایل)، X-Frame-Options (جلوگیری از clickjacking)، Referrer-Policy، Permissions-Policy و مبانی Content-Security-Policy (CSP) که آن را در یک مرورگر واقعی آزمایش میکنی. پارامتر always را میشناسی، متدهای غیرلازم را میبندی، و بخش مدیریت را با IP و رمز محافظت میکنی. در پایان همه را با curl -I بررسی میکنی.
مسئله: سایت امن نیست، حتی با HTTPS
Section titled “مسئله: سایت امن نیست، حتی با HTTPS”HTTPS (درس قبل) مسیر را رمزنگاری میکند، ولی خیلی از حملهها داخل مرورگر کاربر اتفاق میافتند: یک سایت دیگر صفحهی تو را داخل یک iframe نامرئی بار میکند و کاربر را فریب میدهد روی دکمهی «پرداخت» بزند (clickjacking)؛ یک اسکریپت تزریقشده در یک کامنت، کوکیهای کاربر را میدزدد (XSS)؛ کاربر یک بار با HTTP وارد میشود و در همان لحظه در وایفای عمومی شنود میشود. مرورگرها برای جلوگیری از همهی اینها سازوکار دارند، ولی فقط وقتی سایت با هدرهای پاسخ به آنها بگوید سختگیر باشند. Nginx بهترین جا برای گذاشتن این هدرهاست: یک بار، برای همهی صفحهها.
تشبیه: تابلوهای راهنمای یک ساختمان
Section titled “تشبیه: تابلوهای راهنمای یک ساختمان”هدرهای امنیتی مثل تابلوها و قوانینی است که ساختمان به نگهبانها (مرورگرها) میدهد: «این ساختمان را فقط از در امن وارد شوید» (HSTS)، «بستهها را باز نکنید که حدس بزنید چیست؛ برچسب رویشان را باور کنید» (nosniff)، «اجازه ندهید کسی از ساختمان ما عکس بگیرد و در ویترین خودش بگذارد» (X-Frame-Options)، «فقط مهمانهایی با این کارتها اجازهی کار دارند» (CSP). و server_tokens off یعنی روی در ورودی ننویس «قفلهای ما مدل فلان سال ۱۴۰۲ هستند».
هدرهای امنیتی در یک نگاه
Section titled “هدرهای امنیتی در یک نگاه”| هدر | جلوی چه چیزی را میگیرد | مقدار رایج |
|---|---|---|
Strict-Transport-Security (HSTS) |
دسترسی با HTTP و حملهی SSL stripping | max-age=31536000; includeSubDomains |
X-Content-Type-Options |
حدس زدن نوع فایل (MIME sniffing) و اجرای فایل آپلودی بهعنوان اسکریپت | nosniff |
X-Frame-Options |
قرار گرفتن صفحه در iframe سایت دیگر (clickjacking) | SAMEORIGIN یا DENY |
Referrer-Policy |
لو رفتن آدرس کامل صفحه (و توکنهای داخلش) به سایتهای دیگر | strict-origin-when-cross-origin |
Permissions-Policy |
دسترسی صفحه (یا iframe ها) به دوربین، میکروفون، موقعیت | camera=(), microphone=(), geolocation=() |
Content-Security-Policy (CSP) |
اجرای اسکریپت تزریقشده (XSS) و بار شدن منابع از جاهای ناشناخته | default-src 'self'; ... |
مثالهای عملی
Section titled “مثالهای عملی”این درس روی ماشین آزمایشی server (Ubuntu 24.04، Nginx 1.24) با کاربر root اجرا شده؛ آزمایش CSP در یک مرورگر واقعی (Chromium بدون رابط گرافیکی) و یک Nginx داخل داکر انجام شده. برای HTTPS یک گواهی خودامضا (درس قبل) کافی است.
مثال ۱: نقطهی شروع، و curl -I
Section titled “مثال ۱: نقطهی شروع، و curl -I”سایت sec.test با HTTPS (گواهی خودامضا؛ curl با --cacert به آن اعتماد میکند) و یک ریدایرکت از HTTP:
server { listen 80; server_name sec.test; return 301 https://sec.test$request_uri;}
server { listen 443 ssl; server_name sec.test; ssl_certificate /etc/nginx/ssl/sec.crt; ssl_certificate_key /etc/nginx/ssl/sec.key;
root /var/www/sec.test; index index.html;}ln -s /etc/nginx/sites-available/sec.test /etc/nginx/sites-enabled/nginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1alias c='curl -s --cacert /etc/nginx/ssl/sec.crt'shopt -s expand_aliasesc -I https://sec.test/ | grep -vE '^(Date|Content-Length|Last-Modified|ETag|Accept-Ranges|Connection):'echo "=== صفحهی خطا:"c https://sec.test/nothing | grep -o '<center>nginx.*</center>'nginx: configuration file /etc/nginx/nginx.conf test is successfulHTTP/1.1 200 OKServer: nginx/1.24.0 (Ubuntu)Content-Type: text/html
=== صفحهی خطا:<center>nginx/1.24.0 (Ubuntu)</center>curl -I (فقط هدرها) ابزار اصلی این درس است. فعلاً دو مشکل: هدر Server و حتی پایین صفحهی خطای ۴۰۴ نسخهی دقیق (nginx/1.24.0 (Ubuntu)) را لو میدهند، و هیچ هدر امنیتیای نیست.
مثال ۲: server_tokens off
Section titled “مثال ۲: server_tokens off”این تنظیم را یک بار در nginx.conf (context http) میگذاریم تا برای همهی سایتها اعمال شود. در فایل Ubuntu از قبل بهصورت کامنت هست:
grep -n 'server_tokens' /etc/nginx/nginx.confsed -i 's/^\(\s*\)# server_tokens off;/\1server_tokens off;/' /etc/nginx/nginx.confgrep -n 'server_tokens' /etc/nginx/nginx.confnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1c -I https://sec.test/ | grep -i '^server'c https://sec.test/nothing | grep -o '<center>nginx.*</center>'21: # server_tokens off;21: server_tokens off;nginx: configuration file /etc/nginx/nginx.conf test is successfulServer: nginx<center>nginx</center>حالا فقط Server: nginx، بدون نسخه، در هدر و صفحهی خطا. اسکنرهای خودکار نسخه را مستقیم با فهرست آسیبپذیریها مقایسه میکنند؛ پنهان کردنش جلوی حملهی هدفمند را نمیگیرد ولی کار اسکنرهای تنبل را سختتر میکند. (مهمتر از پنهان کردن نسخه، بهروز نگه داشتن آن است: sudo apt upgrade.) حذف کامل هدر Server در Nginx معمولی ممکن نیست (ماژول اضافه لازم دارد).
مثال ۳: هدرهای امنیتی در یک snippet
Section titled “مثال ۳: هدرهای امنیتی در یک snippet”هدرها را یک بار در یک snippet مینویسیم (درس ساختار تنظیمات) تا هر جا لازم شد include کنیم:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;add_header X-Content-Type-Options "nosniff" always;add_header X-Frame-Options "SAMEORIGIN" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;sed -i 's|^ index index.html;| index index.html;\n include snippets/security-headers.conf;|' /etc/nginx/sites-available/sec.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1c -I https://sec.test/ | grep -iE '^(strict|x-content|x-frame|referrer|permissions)'nginx: configuration file /etc/nginx/nginx.conf test is successfulStrict-Transport-Security: max-age=31536000; includeSubDomainsX-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()هر هدر، به زبان ساده:
- HSTS: «تا یک سال (
31536000ثانیه) این دامنه و زیردامنههایش را فقط با HTTPS باز کن». بعد از اولین بازدید HTTPS، مرورگر حتی اگر کاربرhttp://تایپ کند یا روی لینک HTTP بزند، خودش (بدون رفتن به شبکه) آن راhttps://میکند؛ پس کسی در وایفای عمومی نمیتواند اتصال را به HTTP «پایین بکشد». nosniff: مرورگر نوع فایل را ازContent-Typeباور کند و حدس نزند؛ پس فایل متنی آپلودشده، حتی اگر شبیه جاوااسکریپت باشد، اسکریپت اجرا نمیشود.X-Frame-Options: SAMEORIGIN: فقط صفحههای همین سایت میتوانند این صفحه را در iframe بگذارند (جلوی clickjacking).DENYیعنی هیچکس.Referrer-Policy: وقتی کاربر از سایت تو به سایت دیگری میرود، فقط دامنهی تو (نه آدرس کامل صفحه با پارامترهایش) به آن فرستاده شود.Permissions-Policy: این سایت (و iframe های داخلش) به دوربین، میکروفون و موقعیت مکانی نیازی ندارد؛ اگر اسکریپتی خواست، مرورگر رد کند.
مثال ۴: always و هدرها روی صفحههای خطا
Section titled “مثال ۴: always و هدرها روی صفحههای خطا”بدون پارامتر always، add_header فقط روی پاسخهای موفق (۲۰۰، ۲۰۱، ۲۰۴، ۲۰۶، ۳۰۱، ۳۰۲، ۳۰۳، ۳۰۴، ۳۰۷، ۳۰۸) گذاشته میشود؛ صفحههای خطا (۴۰۴، ۵۰۰) بدون هدر میمانند:
cat > /etc/nginx/conf.d/lx-always.conf <<'EOF'server { listen 8085; add_header X-Without-Always "yes"; add_header X-With-Always "yes" always; location = /ok { return 200 "ok\n"; }}EOFnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for p in /ok /missing; do echo "== $p" curl -s -I "http://localhost:8085$p" | grep -iE '^(HTTP|x-with)'donerm /etc/nginx/conf.d/lx-always.confsystemctl reload nginxnginx: configuration file /etc/nginx/nginx.conf test is successful== /okHTTP/1.1 200 OKX-Without-Always: yesX-With-Always: yes== /missingHTTP/1.1 404 Not FoundX-With-Always: yesروی /missing (۴۰۴) فقط هدر دارای always آمد. صفحههای خطا هم صفحهاند و مرورگر رویشان همان سیاستها را لازم دارد؛ پس هدرهای امنیتی را همیشه با always بنویس.
مثال ۵: Content-Security-Policy در یک مرورگر واقعی
Section titled “مثال ۵: Content-Security-Policy در یک مرورگر واقعی”CSP قویترین و پیچیدهترین هدر است: یک فهرست سفید از جاهایی که صفحه اجازه دارد از آنها اسکریپت، استایل، عکس و … بار کند. اگر مهاجم موفق شود یک <script> در صفحه تزریق کند (مثلاً در یک کامنت)، CSP با script-src 'self' اجرایش را رد میکند. CSP را مرورگر اجرا میکند، پس برای دیدن اثرش یک مرورگر لازم است. یک صفحه با یک اسکریپت درونخطی (inline) (شبیه کد تزریقشده) و یک فایل اسکریپت از خود سایت میسازیم، با Nginx (داخل داکر) سرو میکنیم و در Chromium بدون رابط گرافیکی (headless) باز میکنیم:
server { listen 80; root /usr/share/nginx/html;
location /open/ { } location /strict/ { add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'" always; }}<!doctype html><title>CSP test</title><p id="status">nothing ran yet</p><script>document.getElementById("status").textContent = "INLINE script ran (an attacker could run code here)";</script><script src="app.js"></script>// browser.js URL: open a page in headless Chromium and print console messagesconst { chromium } = require("/opt/node22/lib/node_modules/playwright");
(async () => { const browser = await chromium.launch({ executablePath: "/opt/pw-browsers/chromium-1194/chrome-linux/chrome" }); const page = await browser.newPage(); page.on("console", (m) => console.log(` console.${m.type()}: ${m.text().split(". ")[0]}`)); await page.goto(process.argv[2]); console.log(" #status =", await page.textContent("#status")); await browser.close();})();cd cspmkdir -p html/open html/strictfor d in open strict; do cp page.html html/$d/index.html echo 'console.log("app.js (same site) ran")' > html/$d/app.jsdonedocker rm -f lx-csp > /dev/null 2>&1docker run -d --name lx-csp -p 8094:80 -v "$PWD/default.conf:/etc/nginx/conf.d/default.conf:ro" \ -v "$PWD/html:/usr/share/nginx/html:ro" nginx:alpine > /dev/nullsleep 1echo "=== /open/ (بدون CSP):"node browser.js http://localhost:8094/open/ 2>&1 | grep -v 'Failed to load resource'echo "=== /strict/ (با CSP):"curl -s -I http://localhost:8094/strict/ | grep -i '^content-security-policy'node browser.js http://localhost:8094/strict/ 2>&1 | grep -v 'Failed to load resource'docker rm -f lx-csp > /dev/null=== /open/ (بدون CSP): console.log: app.js (same site) ran #status = INLINE script ran (an attacker could run code here)=== /strict/ (با CSP):Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self' console.error: Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'" console.log: app.js (same site) ran #status = nothing ran yetبدون CSP، اسکریپت درونخطی اجرا شد و متن صفحه را عوض کرد. با CSP، Chromium آن را رد کرد (Refused to execute inline script because it violates the following Content Security Policy directive) و متن صفحه دستنخورده ماند؛ ولی app.js که از خود سایت ('self') بود، اجرا شد. همین قانون جلوی اسکریپت تزریقشدهی مهاجم را میگیرد.
بخشهای این CSP: default-src 'self' (هر نوع منبعی فقط از همین دامنه)، script-src 'self' (اسکریپت فقط فایلهای همین سایت؛ نه درونخطی، نه سایتهای دیگر)، object-src 'none' (بدون Flash و افزونه)، base-uri 'self' (جلوگیری از عوض کردن آدرس پایهی لینکها).
مثال ۶: پنهان کردن هدرهای اپ
Section titled “مثال ۶: پنهان کردن هدرهای اپ”اپها هم چیزهایی لو میدهند. Express (فریمورک Node) بهصورت پیشفرض X-Powered-By: Express میفرستد. با proxy_hide_header آن را قبل از رسیدن به کاربر حذف میکنیم. اپ آزمایشی یک سرور کوچک Node است که دقیقاً همان هدر را میفرستد:
mkdir -p /srv/node-appcat > /srv/node-app/app.js <<'EOF'// pretends to be an Express app: adds the X-Powered-By headerrequire("http").createServer((req, res) => { res.setHeader("X-Powered-By", "Express"); res.end("api ok\n");}).listen(3000, "127.0.0.1");EOF(exec node /srv/node-app/app.js) > /dev/null 2>&1 &sleep 1sed -i 's|^ include snippets/security-headers.conf;| include snippets/security-headers.conf;\n\n location /api/ {\n proxy_pass http://127.0.0.1:3000;\n proxy_hide_header X-Powered-By;\n }|' /etc/nginx/sites-available/sec.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1echo "=== مستقیم از اپ:"curl -s -I http://127.0.0.1:3000/ | grep -i '^x-powered'echo "=== از میان Nginx:"c -I https://sec.test/api/ | grep -iE '^(x-powered|strict|x-frame)'nginx: configuration file /etc/nginx/nginx.conf test is successful=== مستقیم از اپ:X-Powered-By: Express=== از میان Nginx:Strict-Transport-Security: max-age=31536000; includeSubDomainsX-Frame-Options: SAMEORIGINX-Powered-By به کاربر نرسید، و هدرهای امنیتی سطح server روی پاسخ اپ (که از proxy آمده) هم گذاشته شدند؛ چون location /api/ خودش add_header ندارد و از server ارث میبرد. proxy_hide_header را برای هر هدر دیگری هم که اپ نباید لو بدهد (مثل X-AspNet-Version یا Server خود اپ) میتوانی به کار ببری.
مثال ۷: محدود کردن متدها و دسترسی به /admin
Section titled “مثال ۷: محدود کردن متدها و دسترسی به /admin”متدهایی که سایت لازم ندارد را ببند، و بخش مدیریت را هم با IP (allow/deny) و هم با رمز (auth_basic) محافظت کن. فایل رمز با htpasswd (از بستهی apache2-utils) ساخته میشود:
htpasswd -bc /etc/nginx/.htpasswd admin 'lab-pass-only' 2>&1chown root:www-data /etc/nginx/.htpasswd && chmod 640 /etc/nginx/.htpasswdsed -i 's|^ location /api/ {| if ($request_method !~ ^(GET\|HEAD\|POST)$) {\n return 405;\n }\n\n location /admin/ {\n allow 127.0.0.1;\n deny all;\n auth_basic "Admin area";\n auth_basic_user_file /etc/nginx/.htpasswd;\n include snippets/security-headers.conf;\n }\n\n location /api/ {|' /etc/nginx/sites-available/sec.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for m in GET POST DELETE TRACE; do printf '%-7s / -> ' "$m"; c -o /dev/null -w '%{http_code}\n' -X "$m" https://sec.test/donefor m in POST DELETE; do printf '%-7s /api/ -> ' "$m"; c -o /dev/null -w '%{http_code}\n' -X "$m" https://sec.test/api/doneecho "=== /admin/:"printf 'from 127.0.0.1, no password: '; c -o /dev/null -w '%{http_code}\n' https://sec.test/admin/printf 'from 127.0.0.1, with password: '; c -u 'admin:lab-pass-only' https://sec.test/admin/printf 'from 127.0.0.66, with password: '; c --interface 127.0.0.66 -o /dev/null -w '%{http_code}\n' -u 'admin:lab-pass-only' https://sec.test/admin/Adding password for user adminnginx: configuration file /etc/nginx/nginx.conf test is successfulGET / -> 200POST / -> 405DELETE / -> 405TRACE / -> 405POST /api/ -> 200DELETE /api/ -> 405=== /admin/:from 127.0.0.1, no password: 401from 127.0.0.1, with password: <h1>admin panel</h1>from 127.0.0.66, with password: 403DELETEوTRACEبا ۴۰۵ (Method Not Allowed) رد شدند؛ifبا regex روی$request_method.POSTبه/هم ۴۰۵ گرفت ولی به دلیل دیگری: Nginx فایل استاتیک را باPOSTنمیفرستد. روی/api/که به اپ میرود،POSTرد نشد و به اپ رسید (۲۰۰)، ولیDELETEهمانجا هم با قانون ما ۴۰۵ گرفت؛ یعنی قانون قبل از رسیدن به اپ اعمال شد./admin/از IP مجاز بدون رمز ۴۰۱ (رمز لازم است) گرفت و با رمز درست باز شد؛ از IP دیگر حتی با رمز درست ۴۰۳.allowوdenyبه ترتیب بررسی میشوند و اولین جور برنده است.- (رمز
lab-pass-onlyفقط رمز آزمایشی این ماشین است.auth_basicرمز را فقط base64 میکند، نه رمزنگاری؛ فقط روی HTTPS استفادهاش کن. فایل.htpasswdباbcryptیاapr1هش شده و نباید داخلrootسایت باشد.)
پشت پرده: HSTS در مرورگر چه میکند؟
Section titled “پشت پرده: HSTS در مرورگر چه میکند؟”HSTS یکی از هدرهایی است که اثرش بعد از پاسخ هم میماند. مرورگر وقتی هدر را روی یک پاسخ HTTPS ببیند، دامنه را با تاریخ انقضا (max-age) در فهرست داخلیاش ذخیره میکند. تا آن تاریخ، هر آدرس http:// به آن دامنه داخل خود مرورگر به https:// تبدیل میشود (در ابزار توسعهدهندهی Chrome بهصورت «307 Internal Redirect» دیده میشود) و هیچ درخواست HTTP به شبکه نمیرود. سه نکتهی مهم:
۱. HSTS روی پاسخ HTTP نادیده گرفته میشود (وگرنه یک مهاجم میانی میتوانست آن را جعل کند). برای همین ما فقط در server ۴۴۳ گذاشتیمش.
۲. اولین بازدید (قبل از دیدن هدر) هنوز محافظت نشده است؛ فهرست preload مرورگرها (با preload در هدر و ثبت در hstspreload.org) این را هم میپوشاند، ولی برگشت از آن ماهها طول میکشد.
۳. includeSubDomains یعنی همهی زیردامنهها (حتی intranet.example.com که شاید HTTPS نداشته باشد) فقط با HTTPS باز میشوند. قبل از گذاشتنش مطمئن شو همهی زیردامنهها HTTPS دارند.
ببینیم خود Nginx این هدر را روی پاسخ HTTP نمیگذارد:
echo "=== HTTP (پورت ۸۰):"curl -s -I http://sec.test/ | grep -iE '^(HTTP|location|strict)'echo "=== HTTPS:"c -I https://sec.test/ | grep -iE '^(HTTP|strict)'=== HTTP (پورت ۸۰):HTTP/1.1 301 Moved PermanentlyLocation: https://sec.test/=== HTTPS:HTTP/1.1 200 OKStrict-Transport-Security: max-age=31536000; includeSubDomainsروی HTTP فقط ریدایرکت است (هدرهای امنیتی را در server ۸۰ نگذاشتیم) و HSTS روی HTTPS آمد. اولین بازدید با HTTP ریدایرکت میشود، پاسخ HTTPS هدر HSTS را میآورد، و از آن به بعد مرورگر دیگر HTTP را امتحان نمیکند.
جدولهای مرجع
Section titled “جدولهای مرجع”تنظیم پیشنهادی (snippet):
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;add_header X-Content-Type-Options "nosniff" always;add_header X-Frame-Options "SAMEORIGIN" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;# start in report-only mode, then switch to Content-Security-Policyadd_header Content-Security-Policy-Report-Only "default-src 'self'" always;directive های امنیتی دیگر:
| directive | کار |
|---|---|
server_tokens off; |
پنهان کردن نسخه (در http) |
proxy_hide_header X-Powered-By; |
حذف هدر اپ |
allow IP; deny all; |
محدودیت بر اساس IP (به ترتیب) |
auth_basic "Area"; auth_basic_user_file /etc/nginx/.htpasswd; |
رمز ساده (فقط روی HTTPS) |
if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; } |
بستن متدهای غیرلازم |
client_max_body_size 10m; |
سقف اندازهی درخواست (درس reverse proxy) |
location ~ /\.(?!well-known) { deny all; } |
بستن فایلهای مخفی (درس سایت استاتیک) |
limit_req |
محدودسازی تعداد درخواست (درس rate limiting) |
بخشهای رایج CSP:
| بخش | معنی |
|---|---|
default-src 'self' |
پیشفرض همهی منابع: فقط همین دامنه |
script-src 'self' https://cdn.example |
اسکریپت از اینجاها |
style-src 'self' 'unsafe-inline' |
استایل (درونخطی هم مجاز؛ ضعیفتر) |
img-src 'self' data: |
عکس، بهعلاوهی data: URI |
frame-ancestors 'self' |
جانشین مدرن X-Frame-Options |
object-src 'none' |
بدون افزونه |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) فراموش کردن always
Section titled “۱) فراموش کردن always”مثال ۴: صفحههای خطا بدون هدر امنیتی. راهحل: همیشه always.
۲) گم شدن هدرها با add_header در location
Section titled “۲) گم شدن هدرها با add_header در location”درس ساختار تنظیمات: یک add_header در location همهی هدرهای سطح بالاتر را حذف میکند. راهحل: snippet را در هر location ای که add_header خودش را دارد هم include کن (همان کاری که برای /admin/ کردیم).
۳) HSTS طولانی و includeSubDomains قبل از آماده بودن
Section titled “۳) HSTS طولانی و includeSubDomains قبل از آماده بودن”با max-age یکساله، اگر روزی HTTPS را برداری یا زیردامنهای HTTPS نداشته باشد، کاربرانی که هدر را دیدهاند تا یک سال نمیتوانند به آن برسند. راهحل: اول با max-age=300 (پنج دقیقه) شروع کن، همهچیز را بسنج و بعد بزرگش کن؛ preload را آخر از همه و آگاهانه.
۴) CSP سختگیر بدون آزمایش
Section titled “۴) CSP سختگیر بدون آزمایش”اسکریپتهای درونخطی، فونت و آمار از دامنههای دیگر بیصدا مسدود میشوند. راهحل: Content-Security-Policy-Report-Only و بررسی کنسول مرورگر.
۵) auth_basic روی HTTP
Section titled “۵) auth_basic روی HTTP”رمز در هر درخواست با base64 فرستاده میشود؛ روی HTTP هر کسی در مسیر آن را میخواند. راهحل: فقط روی HTTPS، و برای پنلهای جدی، محدودیت IP یا VPN هم کنارش.
تمرین اصلی درس (بخش اول): برای sec.test با یک حلقهی curl -I، وجود این شش هدر را روی صفحهی اصلی و روی یک صفحهی ۴۰۴ بررسی کن و برای هر کدام OK یا MISSING چاپ کن: Strict-Transport-Security، X-Content-Type-Options، X-Frame-Options، Referrer-Policy، Permissions-Policy، و نبودن نسخه در Server.
دیدن جواب
for path in / /no-such-page; do echo "== https://sec.test$path" headers=$(c -I "https://sec.test$path") for h in Strict-Transport-Security X-Content-Type-Options X-Frame-Options Referrer-Policy Permissions-Policy; do if grep -qi "^$h:" <<< "$headers"; then echo " OK $h"; else echo " MISSING $h"; fi done if grep -qiE '^server: nginx/[0-9]' <<< "$headers"; then echo " MISSING server_tokens off"; else echo " OK Server without version"; fidone== https://sec.test/ OK Strict-Transport-Security OK X-Content-Type-Options OK X-Frame-Options OK Referrer-Policy OK Permissions-Policy OK Server without version== https://sec.test/no-such-page OK Strict-Transport-Security OK X-Content-Type-Options OK X-Frame-Options OK Referrer-Policy OK Permissions-Policy OK Server without version<<< (here-string) هدرها را از متغیر به grep میدهد تا برای هر هدر دوباره درخواست نفرستیم. چون همهی هدرها always دارند، روی ۴۰۴ هم هستند.
تمرین اصلی درس (بخش دوم): یک CSP در حالت Report-Only به صفحهی اصلی sec.test اضافه کن که فقط اسکریپت و استایل از خود سایت را مجاز بداند. ثابت کن هدر میآید و نوعش Report-Only است، و توضیح بده چرا این حالت را اول میگذاری.
دیدن جواب
sed -i "s|^ include snippets/security-headers.conf;\$| include snippets/security-headers.conf;\n add_header Content-Security-Policy-Report-Only \"default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'\" always;|" /etc/nginx/sites-available/sec.testgrep -c 'Report-Only' /etc/nginx/sites-available/sec.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1c -I https://sec.test/ | grep -iE '^content-security'1nginx: configuration file /etc/nginx/nginx.conf test is successfulContent-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'sed فقط اولین include (که دقیقاً خط آخرش ; است، در سطح server) را عوض کرد و خط CSP را بعدش گذاشت. در حالت Report-Only مرورگر تخلفها را (مثل همان «Refused to execute inline script» مثال ۵، ولی با «[Report Only]») در کنسول نشان میدهد و هیچ چیز را مسدود نمیکند؛ پس میتوانی چند روز روی سایت واقعی ببینی چه چیزهایی میشکند، فهرست را کامل کنی و بعد به Content-Security-Policy تغییر دهی.
برای /admin/ سیاست سختگیرانهتری بساز: همهی هدرهای امنیتی عمومی، بهعلاوهی X-Frame-Options: DENY (بهجای SAMEORIGIN)، Cache-Control: no-store (تا صفحههای مدیریت در کش مرورگر نمانند) و یک CSP واقعی (نه Report-Only) با frame-ancestors 'none'. مراقب باش هدر X-Frame-Options دو بار نیاید. با curl -I (با رمز) ثابت کن.
دیدن جواب
cat > /etc/nginx/snippets/lx-admin-headers.conf <<'EOF'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;add_header X-Content-Type-Options "nosniff" always;add_header X-Frame-Options "DENY" always;add_header Referrer-Policy "no-referrer" always;add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;add_header Cache-Control "no-store" always;add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'; object-src 'none'" always;EOFsed -i '/location \/admin\/ {/,/}/ s|include snippets/security-headers.conf;|include snippets/lx-admin-headers.conf;|' /etc/nginx/sites-available/sec.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1c -I -u 'admin:lab-pass-only' https://sec.test/admin/ | grep -iE '^(x-frame|cache-control|content-security|referrer)'echo "X-Frame-Options count: $(c -I -u 'admin:lab-pass-only' https://sec.test/admin/ | grep -ci '^x-frame')"nginx: configuration file /etc/nginx/nginx.conf test is successfulX-Frame-Options: DENYReferrer-Policy: no-referrerCache-Control: no-storeContent-Security-Policy: default-src 'self'; frame-ancestors 'none'; object-src 'none'X-Frame-Options count: 1بهجای include دو snippet (که X-Frame-Options را دو بار میفرستاد و مرورگر با مقدارهای متناقض ممکن است هر دو را نادیده بگیرد)، یک snippet جدا برای admin ساختیم که همهی هدرها را دارد. چون location /admin/ add_header خودش را دارد، هیچ هدری از سطح server ارث نمیبرد؛ پس نسخهی خاص admin تنها منبع است و تکراری پیش نمیآید.
آزمونک
Section titled “آزمونک”چرا هدرهای امنیتی را با پارامتر always مینویسیم؟
بدون always، add_header فقط روی کدهای موفق و ریدایرکت اعمال میشود.
server_tokens off دقیقاً چه میکند؟
بهروز نگه داشتن Nginx مهمتر از پنهان کردن نسخه است.
HSTS را روی server پورت ۸۰ (HTTP) گذاشتهای. اثرش؟
وگرنه مهاجم میانی میتوانست آن را جعل کند.
کدام هدر جلوی قرار گرفتن صفحهی ورود در iframe یک سایت دیگر (clickjacking) را میگیرد؟
SAMEORIGIN یا DENY.
با CSP script-src 'self'، یک <script> درونخطی که مهاجم تزریق کرده چه میشود؟
CSP را مرورگر اجرا میکند، نه Nginx.
قبل از فعال کردن CSP روی سایت واقعی چه میکنی؟
Report-Only فقط گزارش میدهد و چیزی را مسدود نمیکند.
در location /admin/ با allow 127.0.0.1; deny all; و auth_basic، درخواست از IP دیگر با رمز درست چه کدی میگیرد؟
محدودیت IP قبل از رمز رد میکند؛ از IP مجاز بدون رمز 401 است.
جمعبندی
Section titled “جمعبندی”- پنهان کن:
server_tokens off;(نسخه)،proxy_hide_header X-Powered-By;(هدر اپ)؛ و مهمتر، Nginx را بهروز نگه دار. - هدرهای امنیتی را در یک snippet با
alwaysبنویس وincludeکن: HSTS (فقط روی HTTPS)،X-Content-Type-Options: nosniff،X-Frame-Options: SAMEORIGIN،Referrer-Policy،Permissions-Policy. - CSP فهرست سفید منابع است و مرورگر اجرایش میکند؛ اسکریپت درونخطی تزریقشده را رد میکند. اول
Report-Only، بعد اجباری. - ارثبری
add_header: location ای کهadd_headerخودش را دارد، هیچ هدری از بالا نمیگیرد؛ snippet را آنجا همincludeکن (یا نسخهی خاص بساز). - دسترسی:
allow/denyبرای IP،auth_basic(فقط روی HTTPS) برای رمز،return 405برای متدهای غیرلازم. - بررسی:
curl -Iروی صفحهی موفق و صفحهی خطا؛ و وقتی نتیجه عجیب است، خروجی خام وnginx -T.
| دستور | کاری که میکند |
|---|---|
server_tokens off; | پنهان کردن نسخه (در http) |
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; | HSTS (روی HTTPS) |
add_header X-Content-Type-Options "nosniff" always; | جلوگیری از حدس نوع فایل |
add_header X-Frame-Options "SAMEORIGIN" always; | جلوگیری از clickjacking |
add_header Referrer-Policy "strict-origin-when-cross-origin" always; | محدود کردن Referer |
add_header Content-Security-Policy-Report-Only "default-src 'self'" always; | آزمایش CSP بدون مسدود کردن |
include snippets/security-headers.conf; | یک بار نوشتن، همهجا استفاده |
proxy_hide_header X-Powered-By; | حذف هدر اپ |
allow 127.0.0.1; deny all; | فقط از IP های مشخص |
htpasswd -c /etc/nginx/.htpasswd admin | ساخت فایل رمز |
auth_basic "Admin"; auth_basic_user_file /etc/nginx/.htpasswd; | رمز برای یک location |
curl -sI https://example.com | دیدن هدرهای پاسخ |