رفتن به محتوا
LoopX

امنیت و هدرهای امنیتی

توی این درس یاد می‌گیری با چند خط تنظیم در 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'; ...
هدرهای امنیتی چطور کار می‌کنند: Nginx آن‌ها را به پاسخ اضافه می‌کند و مرورگر، نه سرور، آن‌ها را اجرا می‌کند. بدون هدر، مرورگر رفتار پیش‌فرض و سهل‌گیرانه دارد؛ با هدر، همان صفحه در همان مرورگر محدودیت‌های سخت‌تری می‌گیرد.

این درس روی ماشین آزمایشی server (Ubuntu 24.04، Nginx 1.24) با کاربر root اجرا شده؛ آزمایش CSP در یک مرورگر واقعی (Chromium بدون رابط گرافیکی) و یک Nginx داخل داکر انجام شده. برای HTTPS یک گواهی خودامضا (درس قبل) کافی است.

مثال ۱: نقطه‌ی شروع، و curl -I

Section titled “مثال ۱: نقطه‌ی شروع، و curl -I”

سایت sec.test با HTTPS (گواهی خودامضا؛ curl با --cacert به آن اعتماد می‌کند) و یک ریدایرکت از HTTP:

/etc/nginx/sites-available/sec.test
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;
}
Terminal window
ln -s /etc/nginx/sites-available/sec.test /etc/nginx/sites-enabled/
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
alias c='curl -s --cacert /etc/nginx/ssl/sec.crt'
shopt -s expand_aliases
c -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 successful
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html
=== صفحه‌ی خطا:
<center>nginx/1.24.0 (Ubuntu)</center>

curl -I (فقط هدرها) ابزار اصلی این درس است. فعلاً دو مشکل: هدر Server و حتی پایین صفحه‌ی خطای ۴۰۴ نسخه‌ی دقیق (nginx/1.24.0 (Ubuntu)) را لو می‌دهند، و هیچ هدر امنیتی‌ای نیست.

این تنظیم را یک بار در nginx.conf (context http) می‌گذاریم تا برای همه‌ی سایت‌ها اعمال شود. در فایل Ubuntu از قبل به‌صورت کامنت هست:

Terminal window
grep -n 'server_tokens' /etc/nginx/nginx.conf
sed -i 's/^\(\s*\)# server_tokens off;/\1server_tokens off;/' /etc/nginx/nginx.conf
grep -n 'server_tokens' /etc/nginx/nginx.conf
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
c -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 successful
Server: nginx
<center>nginx</center>

حالا فقط Server: nginx، بدون نسخه، در هدر و صفحه‌ی خطا. اسکنرهای خودکار نسخه را مستقیم با فهرست آسیب‌پذیری‌ها مقایسه می‌کنند؛ پنهان کردنش جلوی حمله‌ی هدفمند را نمی‌گیرد ولی کار اسکنرهای تنبل را سخت‌تر می‌کند. (مهم‌تر از پنهان کردن نسخه، به‌روز نگه داشتن آن است: sudo apt upgrade.) حذف کامل هدر Server در Nginx معمولی ممکن نیست (ماژول اضافه لازم دارد).

مثال ۳: هدرهای امنیتی در یک snippet

Section titled “مثال ۳: هدرهای امنیتی در یک snippet”

هدرها را یک بار در یک snippet می‌نویسیم (درس ساختار تنظیمات) تا هر جا لازم شد include کنیم:

/etc/nginx/snippets/security-headers.conf
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;
Terminal window
sed -i 's|^ index index.html;| index index.html;\n include snippets/security-headers.conf;|' /etc/nginx/sites-available/sec.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
c -I https://sec.test/ | grep -iE '^(strict|x-content|x-frame|referrer|permissions)'
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-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 فقط روی پاسخ‌های موفق (۲۰۰، ۲۰۱، ۲۰۴، ۲۰۶، ۳۰۱، ۳۰۲، ۳۰۳، ۳۰۴، ۳۰۷، ۳۰۸) گذاشته می‌شود؛ صفحه‌های خطا (۴۰۴، ۵۰۰) بدون هدر می‌مانند:

Terminal window
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"; }
}
EOF
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for p in /ok /missing; do
echo "== $p"
curl -s -I "http://localhost:8085$p" | grep -iE '^(HTTP|x-with)'
done
rm /etc/nginx/conf.d/lx-always.conf
systemctl reload nginx
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
== /ok
HTTP/1.1 200 OK
X-Without-Always: yes
X-With-Always: yes
== /missing
HTTP/1.1 404 Not Found
X-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) باز می‌کنیم:

csp/default.conf
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;
}
}
csp/page.html
<!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>
csp/browser.js
// browser.js URL: open a page in headless Chromium and print console messages
const { 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();
})();
Terminal window
cd csp
mkdir -p html/open html/strict
for d in open strict; do
cp page.html html/$d/index.html
echo 'console.log("app.js (same site) ran")' > html/$d/app.js
done
docker rm -f lx-csp > /dev/null 2>&1
docker 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/null
sleep 1
echo "=== /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 است که دقیقاً همان هدر را می‌فرستد:

Terminal window
mkdir -p /srv/node-app
cat > /srv/node-app/app.js <<'EOF'
// pretends to be an Express app: adds the X-Powered-By header
require("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 1
sed -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.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== مستقیم از اپ:"
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; includeSubDomains
X-Frame-Options: SAMEORIGIN

X-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) ساخته می‌شود:

Terminal window
htpasswd -bc /etc/nginx/.htpasswd admin 'lab-pass-only' 2>&1
chown root:www-data /etc/nginx/.htpasswd && chmod 640 /etc/nginx/.htpasswd
sed -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.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for m in GET POST DELETE TRACE; do
printf '%-7s / -> ' "$m"; c -o /dev/null -w '%{http_code}\n' -X "$m" https://sec.test/
done
for m in POST DELETE; do
printf '%-7s /api/ -> ' "$m"; c -o /dev/null -w '%{http_code}\n' -X "$m" https://sec.test/api/
done
echo "=== /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 admin
nginx: configuration file /etc/nginx/nginx.conf test is successful
GET / -> 200
POST / -> 405
DELETE / -> 405
TRACE / -> 405
POST /api/ -> 200
DELETE /api/ -> 405
=== /admin/:
from 127.0.0.1, no password: 401
from 127.0.0.1, with password: <h1>admin panel</h1>
from 127.0.0.66, with password: 403
  • DELETE و 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 نمی‌گذارد:

Terminal window
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 Permanently
Location: https://sec.test/
=== HTTPS:
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains

روی HTTP فقط ریدایرکت است (هدرهای امنیتی را در server ۸۰ نگذاشتیم) و HSTS روی HTTPS آمد. اولین بازدید با HTTP ریدایرکت می‌شود، پاسخ HTTPS هدر HSTS را می‌آورد، و از آن به بعد مرورگر دیگر HTTP را امتحان نمی‌کند.

تنظیم پیشنهادی (snippet):

snippets/security-headers.conf
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-Policy
add_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' بدون افزونه

مثال ۴: صفحه‌های خطا بدون هدر امنیتی. راه‌حل: همیشه 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 و بررسی کنسول مرورگر.

رمز در هر درخواست با 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.

دیدن جواب
Terminal window
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"; fi
done
خروجی
== 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 است، و توضیح بده چرا این حالت را اول می‌گذاری.

دیدن جواب
Terminal window
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.test
grep -c 'Report-Only' /etc/nginx/sites-available/sec.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
c -I https://sec.test/ | grep -iE '^content-security'
خروجی
1
nginx: configuration file /etc/nginx/nginx.conf test is successful
Content-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 (با رمز) ثابت کن.

دیدن جواب
Terminal window
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;
EOF
sed -i '/location \/admin\/ {/,/}/ s|include snippets/security-headers.conf;|include snippets/lx-admin-headers.conf;|' /etc/nginx/sites-available/sec.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
c -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 successful
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Cache-Control: no-store
Content-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 تنها منبع است و تکراری پیش نمی‌آید.

⚡ بررسی سریع

چرا هدرهای امنیتی را با پارامتر always می‌نویسیم؟

؟ آزمونک
  1. server_tokens off دقیقاً چه می‌کند؟

  2. HSTS را روی server پورت ۸۰ (HTTP) گذاشته‌ای. اثرش؟

  3. کدام هدر جلوی قرار گرفتن صفحه‌ی ورود در iframe یک سایت دیگر (clickjacking) را می‌گیرد؟

  4. با CSP script-src 'self'، یک <script> درون‌خطی که مهاجم تزریق کرده چه می‌شود؟

  5. قبل از فعال کردن CSP روی سایت واقعی چه می‌کنی؟

  6. در location /admin/ با allow 127.0.0.1; deny all; و auth_basic، درخواست از IP دیگر با رمز درست چه کدی می‌گیرد؟

  • پنهان کن: 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دیدن هدرهای پاسخ