guest@websec: ~/websec/11-http-security-headers/th — zsh — 100×40 เขียนโดย SaKKo ENTH โค้ด:

guest@websec ~ % curl -I https://app.example.com

11 · HTTP Security Header

header ไม่กี่บรรทัดบอกเบราว์เซอร์ว่าต้องเข้มงวดกับหน้าเราแค่ไหน ไม่มีต้นทุน แต่กันการโจมตีได้ทั้งหมวด

11.1 header พื้นฐาน

Rails ส่งส่วนใหญ่ให้ฟรี ลองเปิดปิดแต่ละตัวเพื่อดูว่ามันกันอะไร:

sim: ตัวตรวจ response headerเปิด/ปิด header แล้วอ่านผลที่ตามมา

response

การโจมตีผลลัพธ์
เราได้ X-Frame-Options, nosniff, Referrer-Policy และอื่นๆ ในทุก response อยู่แล้ว ลองเช็กของจริงด้วย curl -I เพราะ proxy อาจไปยุ่งกับมัน

X-Frame-Options

กันไม่ให้เว็บอื่นใส่หน้าเราใน iframe ที่มองไม่เห็น แล้วหลอกให้ผู้ใช้กดปุ่มของเรา (clickjacking) ค่าเริ่มต้น SAMEORIGIN ของ Rails ใช้ได้

sim: clickjackingเผย frame ที่ซ่อนอยู่
0%
FREE-PRIZE.TEST · You won a new phone!
(attacker's page)
bank.test/settings · Close account and send balance to linked account?
CONFIRM CLOSE
(your page, framed invisibly)
Click "CLAIM PRIZE".

X-XSS-Protection

ฟีเจอร์เบราว์เซอร์เก่าที่สร้างปัญหามากกว่าช่วย Rails ปิดไว้ (0) ให้ใช้ CSP แทน

X-Content-Type-Options

บอกเบราว์เซอร์ไม่ให้เดาชนิดไฟล์ "รูป" ที่ข้างในเป็น HTML จึงรันเป็นหน้าเว็บไม่ได้

X-Permitted-Cross-Domain-Policies

กันผลิตภัณฑ์ของ Adobe (Flash รุ่นเก่า, Acrobat) ไม่ให้โหลดไฟล์ policy ข้ามโดเมนจากเว็บเรา ส่วนใหญ่เป็นของเก่าแล้ว แต่ไม่มีต้นทุนและไม่มีผลเสีย เปิดไว้

Referrer-Policy

เมื่อผู้ใช้กดลิงก์ออกไป เบราว์เซอร์อาจส่ง URL เต็มของเราไปให้เว็บถัดไป รวมถึง token ค่าเริ่มต้นจะส่งแค่ https://app.test ให้เว็บอื่น

sim: อะไรรั่วใน header Refererจาก https://app.test/reset?token=8f2c…&email=ana%40x.test

การเปลี่ยนค่าเริ่มต้น

ค่าเริ่มต้นเหล่านี้อยู่ใน config.action_dispatch.default_headers ปกติแทบไม่ต้องแตะ หัวข้อสุดท้ายของหน้านี้แสดงวิธีปรับให้เข้มขึ้น

11.2 Strict-Transport-Security (HSTS)

การ redirect ไป HTTPS ยังทิ้ง request แรกสุดไว้บน HTTP ธรรมดา ซึ่งถูกดักได้ HSTS บอกเบราว์เซอร์ว่า "ใช้ HTTPS กับเว็บนี้เสมอ" ไปหนึ่งปี

force_ssl เพิ่ม HSTS ให้ เปิด preload เมื่อทุก subdomain ใช้ HTTPS แล้วเท่านั้น

11.3 Content-Security-Policy (CSP)

CSP คือรายการว่าสคริปต์มาจากที่ไหนได้บ้าง ถ้า XSS หลุดเข้ามา เบราว์เซอร์ก็ยังไม่ยอมรันสคริปต์ที่ถูกแทรก

sim: ตัวประเมิน CSPแก้ policy แล้วแต่ละการทดสอบจะถูกตรวจทันที
presets:
สิ่งที่หน้าเว็บพยายามโหลดdirectiveผลลัพธ์
Rails สร้างไฟล์ CSP ให้ แต่คอมเมนต์ไว้ทั้งหมด ไม่มี CSP จนกว่าเราจะเปิด

การรายงานการละเมิด

เริ่มที่โหมด Report-Only: ไม่มีอะไรพัง แต่ได้รายงานว่าอะไร จะ ถูกบล็อก แก้ส่วนนั้นแล้วค่อยบังคับใช้

ใช้ nonce แทน 'unsafe-inline'

'unsafe-inline' ทำให้สคริปต์ที่ถูกแทรกรันได้ด้วย จึงแทบทำลาย CSP ให้ใส่ nonce สุ่มที่เปลี่ยนทุก response ในแท็ก script ของเราแทน ผู้โจมตีเดาไม่ได้

# config/initializers/content_security_policy.rb (generated commented out: enable it)
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src  :self
    policy.object_src  :none
    policy.base_uri    :none
    policy.frame_ancestors :none
    policy.form_action :self
    policy.report_uri  "/csp-violation-report"
  end
  config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
  config.content_security_policy_nonce_directives = %w[script-src]
  # config.content_security_policy_report_only = true     # start here
end
<%= javascript_tag nonce: true do %> init() <% end %>
import crypto from "node:crypto";
import helmet from "helmet";
app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString("base64"); next(); });
app.use(helmet.contentSecurityPolicy({ directives: {
  defaultSrc: ["'self'"],
  scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"],
  objectSrc: ["'none'"], baseUri: ["'none'"], frameAncestors: ["'none'"],
  reportUri: ["/csp-report"],
}, reportOnly: false }));
// template: <script nonce="<%= nonce %>">…</script>
# Django 6.0+ has built-in CSP (SECURE_CSP setting); this is the django-csp 4.x form
# pip install django-csp   (MIDDLEWARE += ["csp.middleware.CSPMiddleware"])
from csp.constants import NONCE, NONE, SELF, STRICT_DYNAMIC
CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": [SELF],
        "script-src": [SELF, NONCE, STRICT_DYNAMIC],   # NONCE: fresh nonce per response
        "object-src": [NONE],
        "base-uri": [NONE],
        "frame-ancestors": [NONE],
        "report-uri": "/csp-report",
    },
}
# template: <script nonce="{{ request.csp_nonce }}">…</script>

11.4 Permissions-Policy (เดิมชื่อ Feature-Policy)

ปิดฟีเจอร์ของเบราว์เซอร์ที่ไม่ได้ใช้ (กล้อง ไมค์ ตำแหน่ง) โค้ดที่ถูกแทรกจะได้ขอใช้ไม่ได้

sim: ตัวสร้าง Permissions-Policyอนุญาตเฉพาะที่ใช้
สร้างมาแบบคอมเมนต์ไว้เหมือนกัน ปิดอยู่จนกว่าเราจะเปิด

11.5 Cross-Origin Resource Sharing (CORS)

โดยค่าเริ่มต้น JS บนเว็บหนึ่งอ่าน response ของอีกเว็บไม่ได้ CORS คือวิธีที่ API บอกว่า "เว็บนี้อ่านฉันได้" ถ้าไม่ใช่ request แบบง่าย เบราว์เซอร์จะถามก่อน (preflight)

CORS ผ่อนความปลอดภัยลง จึงต้องระบุ origin ให้ตรง อย่าสะท้อน origin อะไรก็ได้กลับไปพร้อมอนุญาตคุกกี้ เพราะทุกเว็บจะอ่านข้อมูลผู้ใช้ได้

ค่าเริ่มต้นไม่มี CORS ซึ่งปลอดภัย ถ้าเพิ่ม rack-cors ให้ระบุรายการให้ตรง
sim: การตัดสินของ CORSตั้งค่า API แล้วส่ง request

การตั้งค่า CORS ของ api.example.com



request จากเบราว์เซอร์


# Gemfile: gem "rack-cors"    # config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "https://app.example.com"      # exact list; never "*" with credentials
    resource "/api/*", headers: %w[Content-Type X-CSRF-Token],
             methods: %i[get post], credentials: true, max_age: 600
  end
end
import cors from "cors";
app.use("/api", cors({
  origin: ["https://app.example.com"],   // exact list; never `origin: true` with credentials
  methods: ["GET", "POST"],
  allowedHeaders: ["Content-Type", "X-CSRF-Token"],
  credentials: true,
  maxAge: 600,
}));
# Django: pip install django-cors-headers
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]   # exact list
CORS_ALLOW_METHODS = ["GET", "POST"]
CORS_ALLOW_CREDENTIALS = True
# Flask: CORS(app, origins=["https://app.example.com"], supports_credentials=True)

11.6 การตั้งค่าใน stack ของคุณ

# defaults already sent (config.action_dispatch.default_headers);
# tighten one:
config.action_dispatch.default_headers["X-Frame-Options"] = "DENY"

config.force_ssl = true                     # + Strict-Transport-Security
config.ssl_options = { hsts: { expires: 2.years, subdomains: true } }

# config/initializers/permissions_policy.rb (generated commented out)
Rails.application.config.permissions_policy do |p|
  p.camera :none
  p.microphone :none
  p.geolocation :none
  p.payment :self
end
// npm install helmet: sets a sensible baseline in one line
import helmet from "helmet";
app.use(helmet({
  frameguard: { action: "sameorigin" },               // X-Frame-Options
  referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  hsts: { maxAge: 31536000, includeSubDomains: true },
  // X-XSS-Protection: 0 is sent by default (switches the old filter off)
  // noSniff, permittedCrossDomainPolicies, CSP: on by default
}));
app.use((req, res, next) => {
  res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  next();
});
# Django settings.py (SecurityMiddleware + XFrameOptionsMiddleware)
X_FRAME_OPTIONS = "SAMEORIGIN"
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
# Permissions-Policy: django-permissions-policy, or a small middleware

# Flask: pip install flask-talisman
Talisman(app, frame_options="SAMEORIGIN", strict_transport_security=True,
         referrer_policy="strict-origin-when-cross-origin",
         content_security_policy={"default-src": "'self'"},
         permissions_policy={"camera": "()", "microphone": "()"})

จากนั้นตรวจสอบ: นำเว็บไปผ่าน HTTP Observatory ของ Mozilla หรือ curl -I ใน production