guest@websec ~ % curl -I https://app.example.com
11 · HTTP Security Header
header ไม่กี่บรรทัดบอกเบราว์เซอร์ว่าต้องเข้มงวดกับหน้าเราแค่ไหน ไม่มีต้นทุน แต่กันการโจมตีได้ทั้งหมวด
11.1 header พื้นฐาน
Rails ส่งส่วนใหญ่ให้ฟรี ลองเปิดปิดแต่ละตัวเพื่อดูว่ามันกันอะไร:
response
| การโจมตี | ผลลัพธ์ |
|---|
curl -I เพราะ proxy อาจไปยุ่งกับมันX-Frame-Options
กันไม่ให้เว็บอื่นใส่หน้าเราใน iframe ที่มองไม่เห็น แล้วหลอกให้ผู้ใช้กดปุ่มของเรา (clickjacking) ค่าเริ่มต้น SAMEORIGIN ของ Rails ใช้ได้
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 ให้เว็บอื่น
การเปลี่ยนค่าเริ่มต้น
ค่าเริ่มต้นเหล่านี้อยู่ใน 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 หลุดเข้ามา เบราว์เซอร์ก็ยังไม่ยอมรันสคริปต์ที่ถูกแทรก
| สิ่งที่หน้าเว็บพยายามโหลด | directive | ผลลัพธ์ |
|---|
การรายงานการละเมิด
เริ่มที่โหมด 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)
ปิดฟีเจอร์ของเบราว์เซอร์ที่ไม่ได้ใช้ (กล้อง ไมค์ ตำแหน่ง) โค้ดที่ถูกแทรกจะได้ขอใช้ไม่ได้
11.5 Cross-Origin Resource Sharing (CORS)
โดยค่าเริ่มต้น JS บนเว็บหนึ่งอ่าน response ของอีกเว็บไม่ได้ CORS คือวิธีที่ API บอกว่า "เว็บนี้อ่านฉันได้" ถ้าไม่ใช่ request แบบง่าย เบราว์เซอร์จะถามก่อน (preflight)
CORS ผ่อนความปลอดภัยลง จึงต้องระบุ origin ให้ตรง อย่าสะท้อน origin อะไรก็ได้กลับไปพร้อมอนุญาตคุกกี้ เพราะทุกเว็บจะอ่านข้อมูลผู้ใช้ได้
rack-cors ให้ระบุรายการให้ตรงการตั้งค่า 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