Danh sách bài viết

Bài 77: CORS + Security Headers — Hardening Production

Bài 77 của series Rust RESTful API — bài thứ 2 Group 8 Middleware Sâu (B77/B85), implement đầy đủ CORS (Cross-Origin Resource Sharing) qua tower-http::CorsLayer với 4 cấu hình chính (allow_origin, allow_methods, allow_headers, max_age, expose_headers, allow_credentials), phân biệt simple request (GET/POST/HEAD + Content-Type: text/plain) browser gửi thẳng vs preflight request (PATCH/DELETE hoặc Content-Type: application/json) browser gửi OPTIONS trước check permission, lock multi-env CORS strategy Shop API — Local AllowOrigin::any() permissive cho dev/Postman vs Staging/Production AllowOrigin::list(env_origins) strict + fail-fast nếu ALLOWED_ORIGINS empty production, allow_credentials(false) MANDATORY Shop API stateless JWT bearer (B11 continued — KHÔNG cookie), max_age 86400 (24h) cache preflight giảm OPTIONS subsequent request, 4 method allowed (GET, POST, PATCH, DELETE + OPTIONS + HEAD) explicit list (KHÔNG Any attack surface), 5 header allowed (Content-Type, Authorization, Idempotency-Key, X-Request-Id, If-None-Match) + 4 header expose (X-Request-Id, ETag, Location, X-Total-Count); implement custom security_headers_middleware qua from_fn pattern (B76 90/10 rule continued) inject 6 OWASP standard security header chuẩn 2024: X-Frame-Options DENY (chống clickjacking iframe wrap) + X-Content-Type-Options nosniff (chống MIME sniffing attack) + Strict-Transport-Security max-age 1 năm + includeSubDomains (HSTS force HTTPS) + Content-Security-Policy strict (default-src 'self'; frame-ancestors 'none'; base-uri 'none' chống XSS injection) + Referrer-Policy strict-origin-when-cross-origin (KHÔNG leak path Referer khi navigate cross-origin) + Permissions-Policy (disable geolocation/microphone/camera/payment); AppConfig extend field allowed_origins: Vec<String> parse ALLOWED_ORIGINS env CSV; NEW 2 file middleware (crates/shop-api/src/middleware/cors.rs cors_layer helper + crates/shop-api/src/middleware/security_headers.rs security_headers_middleware) + updated router.rs wire 2 layer mới (security_headers INNER hơn cors — cors check trước inject security sau, cors INNER hơn compression — ordering bottom-up B76 lock continued), stack giờ 6 layer (4 cũ B50 + 2 mới B77); SameSite cookie SKIP Shop API stateless JWT KHÔNG cookie (B34 strategy chỉ áp dụng nếu future thêm session cookie admin dashboard); 5 verify test curl preflight OPTIONS + cross-origin POST origin allowed + origin KHÔNG allowed production + security header inspection grep + SameSite scenario note.

16/06/2026
13 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu CORS (Cross-Origin Resource Sharing) — phân biệt preflight vs simple request.
  • Áp dụng tower-http::CorsLayer với 4 config chính: allow_origin, allow_methods, allow_headers, max_age.
  • Phân biệt CORS dev permissive vs production strict theo môi trường.
  • Implement 6 OWASP security headers: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, Permissions-Policy, Strict-Transport-Security.
  • Hiểu pattern SameSite cookie Strict vs Lax vs None và lý do Shop API skip.
  • Áp Shop API multi-env config (Local / Staging / Production) qua ALLOWED_ORIGINS env.
2

CORS Là Gì + Preflight Request

Browser thực thi Same-Origin Policy (RFC 6454): JavaScript chỉ gọi được API cùng origin (scheme + host + port). Khi frontend https://shop.blogcode.vn muốn fetch API https://api.blogcode.vn, browser block mặc định để chống cross-site request forgery.

CORS (Cross-Origin Resource Sharing) là cơ chế server cấp phép cross-origin qua response header Access-Control-Allow-*. Browser kiểm tra header này trước khi expose response cho JavaScript đọc.

Browser chia request thành 2 loại theo Fetch spec:

Simple request — browser gửi thẳng (không preflight). Điều kiện: method ∈ {GET, POST, HEAD} + Content-Type ∈ {text/plain, application/x-www-form-urlencoded, multipart/form-data} + chỉ header CORS-safelisted. Browser check Access-Control-Allow-Origin trên response — nếu match origin client thì expose body, ngược lại JavaScript nhận lỗi CORS.

Preflight request — browser gửi OPTIONS request trước, chờ server cấp phép, sau đó mới gửi request thật. Trigger preflight khi: method ∈ {PATCH, PUT, DELETE}, hoặc Content-Type application/json, hoặc có custom header (vd Authorization, Idempotency-Key).

Ví dụ frontend gọi PATCH /api/v1/products:

--- Browser gửi preflight OPTIONS ---
OPTIONS /api/v1/products HTTP/1.1
Origin: https://shop.blogcode.vn
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type, Authorization

--- Server trả allow ---
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.blogcode.vn
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, Idempotency-Key
Access-Control-Max-Age: 86400

--- Browser cache preflight 24h rồi gửi request thật ---
PATCH /api/v1/products HTTP/1.1
Origin: https://shop.blogcode.vn
Content-Type: application/json
Authorization: Bearer eyJ...
{"name": "iPhone 17"}

Browser cache preflight theo Access-Control-Max-Age (24h là phổ biến) → các request kế tiếp cùng origin + method + header skip OPTIONS, giảm overhead.

Hai pitfall thường gặp:

  • Không cấu hình CORS → frontend nhận "CORS error: No 'Access-Control-Allow-Origin' header", không gọi được API.
  • Access-Control-Allow-Origin: * kèm cookie → RFC 6454 cấm: server phải echo origin cụ thể khi allow_credentials = true, không được wildcard.
3

tower-http CorsLayer Cấu Hình Production

Crate tower-http đã có sẵn từ workspace root (B10 lock + B50 mở rộng feature compression-full + decompression-full). Feature cors đã enable mặc định khi dùng full; nếu chưa, xác nhận trong shop/Cargo.toml:

# File: shop/Cargo.toml — workspace root
[workspace.dependencies]
tower-http = { version = "0.6", features = [
    "compression-full",
    "decompression-full",
    "cors",            # B77 — CorsLayer
] }

Tạo file mới crates/shop-api/src/middleware/cors.rs chứa helper factory:

// File: crates/shop-api/src/middleware/cors.rs
use std::time::Duration;

use axum::http::{header, HeaderName, HeaderValue, Method};
use tower_http::cors::{AllowOrigin, CorsLayer};

use crate::config::{AppConfig, Environment};

/// Build CorsLayer với cấu hình theo Environment.
///
/// Local: AllowOrigin::any() — permissive cho dev/Postman/curl.
/// Staging/Production: AllowOrigin::list(env_origins) — strict.
/// Panic fail-fast nếu Staging/Production mà ALLOWED_ORIGINS empty.
pub fn cors_layer(config: &AppConfig) -> CorsLayer {
    let origins: Vec<HeaderValue> = config
        .allowed_origins
        .iter()
        .filter_map(|s| HeaderValue::from_str(s).ok())
        .collect();

    let allow_origin = match config.environment {
        Environment::Local => AllowOrigin::any(),
        Environment::Staging | Environment::Production => {
            assert!(
                !origins.is_empty(),
                "ALLOWED_ORIGINS env MANDATORY trong {:?} — list rỗng \
                 sẽ block toàn bộ frontend",
                config.environment
            );
            AllowOrigin::list(origins)
        }
    };

    CorsLayer::new()
        .allow_origin(allow_origin)
        .allow_methods([
            Method::GET,
            Method::POST,
            Method::PATCH,
            Method::DELETE,
            Method::OPTIONS,
            Method::HEAD,
        ])
        .allow_headers([
            header::CONTENT_TYPE,
            header::AUTHORIZATION,
            HeaderName::from_static("idempotency-key"),
            HeaderName::from_static("x-request-id"),
            header::IF_NONE_MATCH,
        ])
        .expose_headers([
            HeaderName::from_static("x-request-id"),
            header::ETAG,
            header::LOCATION,
            HeaderName::from_static("x-total-count"),
        ])
        .max_age(Duration::from_secs(86_400))
        .allow_credentials(false)
}

Bảng lock vĩnh viễn các quyết định CORS Shop API:

  • Local dùng AllowOrigin::any() — dev/Postman/curl không cần khai báo origin cụ thể.
  • Staging / Production dùng AllowOrigin::list(env_origins) — strict; assert! fail-fast crash startup nếu list rỗng, tránh deploy production mà miss config.
  • allow_credentials(false) MANDATORY — Shop API stateless JWT Bearer (B11 lock continued); không gửi cookie cross-origin nên không cần credentials.
  • Methods explicit list 6 method (GET, POST, PATCH, DELETE, OPTIONS, HEAD); KHÔNG dùng Any để giảm attack surface.
  • Headers allowed: Content-Type (JSON body), Authorization (Bearer JWT), Idempotency-Key (B66 lock), X-Request-Id (B39 lock), If-None-Match (ETag conditional GET B62 lock).
  • Headers expose: X-Request-Id (frontend log correlation), ETag (cache validator), Location (POST 201 redirect target), X-Total-Count (pagination total — lock B4).
  • max_age 86400 (24h) — cache preflight 1 ngày, giảm OPTIONS subsequent request.
4

AppConfig Thêm allowed_origins Env

AppConfig đã có 8 field từ B56 (database_url, pool tuning, server_bind, environment). Bài này thêm 1 field allowed_origins: Vec<String> parse từ env ALLOWED_ORIGINS CSV format.

// File: crates/shop-api/src/config.rs (snippet — chỉ phần thêm B77)
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct AppConfig {
    pub database_url: String,
    pub pool_max_connections: u32,
    pub pool_min_connections: u32,
    pub pool_acquire_timeout: Duration,
    pub pool_idle_timeout: Duration,
    pub pool_max_lifetime: Option<Duration>,
    pub pool_statement_cache: usize,
    pub server_bind: String,
    pub environment: Environment,

    // B77 — CORS allowed origins (CSV env)
    pub allowed_origins: Vec<String>,
}

impl AppConfig {
    pub fn from_env() -> anyhow::Result<Self> {
        // ... existing 8 field parse (B56)

        // B77 — parse CSV "https://a.com,https://b.com" → Vec<String>
        let allowed_origins = std::env::var("ALLOWED_ORIGINS")
            .unwrap_or_default()
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect::<Vec<String>>();

        Ok(Self {
            // ... existing field
            allowed_origins,
        })
    }
}

Cập nhật .env.example commit kèm template cho onboarding dev mới:

# File: shop/.env.example (snippet thêm B77)

# CORS — danh sách origin cho phép (production CSV).
# Local dev: để trống → CorsLayer dùng AllowOrigin::any() permissive.
# Staging/Production: MANDATORY, panic startup nếu rỗng.
ALLOWED_ORIGINS=https://shop.blogcode.vn,https://admin.blogcode.vn

Pattern parse CSV graceful: split(',') + trim() + filter(!empty) đảm bảo trailing comma hoặc whitespace dư không gây entry rỗng. Production set qua orchestrator (Docker secret, K8s ConfigMap, fly.io secret) — KHÔNG copy .env lên prod.

5

6 Security Headers — OWASP Standard

OWASP HTTP Headers Cheat Sheet 2024 liệt kê 6 header phòng thủ mỗi response API/web đều nên inject. Shop API lock cả 6:

  • X-Frame-Options: DENY — cấm browser nhúng response vào <iframe>. Chống clickjacking: kẻ tấn công nhúng admin dashboard vào iframe trong suốt rồi lừa user click. DENY tuyệt đối; SAMEORIGIN chỉ cho cùng domain (dùng khi cần preview nội bộ).
  • X-Content-Type-Options: nosniff — cấm browser đoán MIME type khác với Content-Type server gửi. Chống MIME sniffing attack: file upload .png chứa JS payload, browser cũ đoán là HTML execute.
  • Strict-Transport-Security: max-age=31536000; includeSubDomains (HSTS) — buộc browser dùng HTTPS suốt 1 năm cho domain + subdomain. Chống SSL stripping attack man-in-the-middle ép HTTP. Lưu ý: chỉ inject khi serve qua HTTPS thật; localhost HTTP browser sẽ ignore.
  • Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'none' — chống XSS injection. default-src 'self' chỉ load resource cùng origin; frame-ancestors 'none' như X-Frame-Options DENY hiện đại hơn; base-uri 'none' cấm <base> tag relocate URL. Shop API là API-only nên policy strict tối đa; nếu serve HTML admin dashboard sẽ cần script-src 'self' 'nonce-xxx' riêng.
  • Referrer-Policy: strict-origin-when-cross-origin — gửi đầy đủ Referer (origin + path) cho same-origin; chỉ gửi origin (không path) cho cross-origin HTTPS; bỏ trống khi HTTPS → HTTP. Cân bằng analytics + privacy, KHÔNG leak path nhạy cảm (vd /orders/12345) sang third-party.
  • Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=() — disable hẳn 4 browser API; nếu kẻ tấn công XSS hoặc iframe nhúng cũng không request được. Shop API API-only KHÔNG dùng feature này → disable hết là an toàn nhất.

Cả 6 header lock vĩnh viễn cho mọi response Shop API (kể cả error response 4xx/5xx). Áp qua middleware global thay vì inject thủ công mỗi handler.

6

Custom SecurityHeadersLayer (from_fn)

Theo lock B76 (90/10 rule), middleware đơn giản không stateful dùng axum::middleware::from_fn thay vì viết custom Layer struct. Security headers chỉ inject 6 header static post-response → fit pattern from_fn hoàn hảo.

Tạo file mới crates/shop-api/src/middleware/security_headers.rs:

// File: crates/shop-api/src/middleware/security_headers.rs
use axum::{
    extract::Request,
    http::HeaderValue,
    middleware::Next,
    response::Response,
};

/// Inject 6 OWASP security header vào mọi response.
///
/// Áp dụng post-process (sau khi handler chạy xong) qua axum::middleware::from_fn.
/// Pattern B76 90/10 rule lock — stateless + static value → from_fn.
pub async fn security_headers_middleware(req: Request, next: Next) -> Response {
    let mut response = next.run(req).await;
    let headers = response.headers_mut();

    headers.insert(
        "X-Frame-Options",
        HeaderValue::from_static("DENY"),
    );
    headers.insert(
        "X-Content-Type-Options",
        HeaderValue::from_static("nosniff"),
    );
    headers.insert(
        "Strict-Transport-Security",
        HeaderValue::from_static("max-age=31536000; includeSubDomains"),
    );
    headers.insert(
        "Content-Security-Policy",
        HeaderValue::from_static(
            "default-src 'self'; frame-ancestors 'none'; base-uri 'none'",
        ),
    );
    headers.insert(
        "Referrer-Policy",
        HeaderValue::from_static("strict-origin-when-cross-origin"),
    );
    headers.insert(
        "Permissions-Policy",
        HeaderValue::from_static(
            "geolocation=(), microphone=(), camera=(), payment=()",
        ),
    );

    response
}

Dùng HeaderValue::from_static cho 6 giá trị literal — không alloc heap, không có error path runtime (compiler check static byte string hợp lệ ASCII). Toàn bộ middleware là 1 hàm 30 dòng đơn giản, không struct, không bound generic phức tạp.

Cập nhật crates/shop-api/src/middleware/mod.rs re-export 2 file mới:

// File: crates/shop-api/src/middleware/mod.rs
pub mod cors;
pub mod error_enrich;
pub mod request_id;
pub mod security_headers;

pub use cors::cors_layer;
pub use error_enrich::enrich_error_response;
pub use request_id::request_id_middleware;
pub use security_headers::security_headers_middleware;
7

Wire Stack — Thêm 2 Middleware Mới

Cập nhật crates/shop-api/src/router.rs wire 2 layer mới. Lưu ý ordering bottom-up đã lock B76 — đọc TỪ DƯỚI LÊN để hiểu execution order:

// File: crates/shop-api/src/router.rs (snippet — phần middleware stack B77)
use axum::{middleware, routing::get, Router};
use tower_http::{
    compression::{CompressionLayer, CompressionLevel},
    decompression::DecompressionLayer,
};

use crate::{
    middleware::{
        cors_layer, enrich_error_response, request_id_middleware,
        security_headers_middleware,
    },
    routes,
    state::AppState,
};

pub fn build_router(state: AppState) -> Router {
    let api_v1 = Router::new()
        .merge(routes::products::routes())
        .merge(routes::orders::routes())
        .merge(routes::cart::routes())
        .merge(routes::users::routes())
        .merge(routes::brands::routes())
        .merge(routes::categories::routes());

    Router::new()
        .merge(routes::health::routes())
        .route("/metrics", get(routes::metrics::metrics))
        .nest("/api/v1", api_v1)
        .merge(routes::webhooks::routes())
        //
        // Middleware stack — đọc TỪ DƯỚI LÊN để hiểu execution order:
        .layer(middleware::from_fn(enrich_error_response))        // INNER 6 — wrap error envelope
        .layer(middleware::from_fn(request_id_middleware))        // INNER 5 — inject Extension<RequestId>
        .layer(middleware::from_fn(security_headers_middleware))  // INNER 4 — B77 inject 6 OWASP header
        .layer(cors_layer(&state.config))                         // INNER 3 — B77 CORS preflight + origin check
        .layer(DecompressionLayer::new().gzip(true).br(true))     // INNER 2 — decompress body
        .layer(                                                   // OUTERMOST 1 — compress response
            CompressionLayer::new()
                .gzip(true)
                .br(true)
                .quality(CompressionLevel::Default),
        )
        .with_state(state)
}

Ordering rationale:

  • CORS INNER hơn compression — CORS xử lý preflight OPTIONS sớm, không cần compress 204 No Content rỗng body.
  • security_headers INNER hơn CORS — CORS check origin pass trước, security headers inject sau khi response đã build (kể cả khi CORS reject vẫn inject security header để defense in depth).
  • security_headers OUTER hơn request_id — security header không phụ thuộc RequestId, có thể inject độc lập.
  • enrich_error INNER nhất — wrap envelope error gần handler nhất, đảm bảo lấy đúng RequestId từ Extension.

Stack giờ 6 layer global (4 cũ B50 + 2 mới B77) + 1 per-route Idempotency B66. Mỗi .layer() comment INNER/OUTERMOST rõ ràng theo lock B76 — team đọc code không phải suy luận.

8

Verify End-To-End

Khởi động Shop API local (mặc định APP_ENV=localAllowOrigin::any()):

cargo run -p shop-api
# > shop-api listening addr=0.0.0.0:3000 environment=Local

Test 1 — Preflight OPTIONS request:

curl -i -X OPTIONS http://localhost:3000/api/v1/products \
  -H 'Origin: https://shop.blogcode.vn' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: Content-Type, Authorization'

# Expected response:
# HTTP/1.1 200 OK
# access-control-allow-origin: *
# access-control-allow-methods: GET,POST,PATCH,DELETE,OPTIONS,HEAD
# access-control-allow-headers: content-type,authorization,idempotency-key,...
# access-control-max-age: 86400
# x-frame-options: DENY
# x-content-type-options: nosniff
# ...

Test 2 — Cross-origin POST với origin allowed (Local any):

curl -i -X POST http://localhost:3000/api/v1/products \
  -H 'Origin: https://shop.blogcode.vn' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 11111111-1111-1111-1111-111111111111' \
  -d '{"name":"iPhone 17","slug":"iphone-17","price":"30000000.00","stock":5,"metadata":{}}'

# Expected:
# HTTP/1.1 201 Created
# location: /api/v1/products/iphone-17
# access-control-allow-origin: *
# access-control-expose-headers: x-request-id,etag,location,x-total-count

Test 3 — Production strict (set APP_ENV=production + ALLOWED_ORIGINS):

APP_ENV=production \
ALLOWED_ORIGINS=https://shop.blogcode.vn \
cargo run -p shop-api

# Cùng request từ origin KHÔNG nằm trong list:
curl -i -X POST http://localhost:3000/api/v1/products \
  -H 'Origin: https://malicious.com' \
  -H 'Content-Type: application/json' -d '{}'

# Response từ server vẫn 4xx (request thật vẫn chạy):
# HTTP/1.1 422 Unprocessable Entity
# (KHÔNG có access-control-allow-origin header)
# → Browser thực thi Same-Origin Policy block JavaScript đọc response,
#   curl không enforce nên vẫn thấy body. Đây là behavior đúng.

Test 4 — Verify 6 security header inject mọi response:

curl -is http://localhost:3000/api/v1/products | \
  grep -iE "x-frame|x-content-type|strict-transport|content-security|referrer-policy|permissions-policy"

# Expected 6 dòng:
# x-frame-options: DENY
# x-content-type-options: nosniff
# strict-transport-security: max-age=31536000; includeSubDomains
# content-security-policy: default-src 'self'; frame-ancestors 'none'; base-uri 'none'
# referrer-policy: strict-origin-when-cross-origin
# permissions-policy: geolocation=(), microphone=(), camera=(), payment=()

Test 5 — SameSite cookie scenario (Shop API skip):

SameSite chỉ áp dụng khi server SET cookie qua header Set-Cookie. Shop API stateless JWT Bearer (B11 lock) KHÔNG set cookie → SameSite không liên quan. Nếu future thêm admin dashboard session cookie (preview G11 B106), cookie sẽ wire 3 attribute MANDATORY: HttpOnly (cấm JS đọc), Secure (chỉ HTTPS), SameSite=Strict (chỉ gửi cookie khi navigate từ cùng origin — chống CSRF triệt để).

9

Tổng Kết

  • CORS preflight (OPTIONS) vs simple request — browser trigger preflight khi method PATCH/DELETE hoặc Content-Type: application/json hoặc có custom header.
  • tower-http::CorsLayer lock cho Shop API CORS handling — KHÔNG viết tay.
  • Multi-env CORS lock: Local AllowOrigin::any(), Staging/Production AllowOrigin::list(env) với assert! fail-fast.
  • allow_credentials(false) MANDATORY — Shop API stateless JWT Bearer (B11 continued).
  • max_age 86400 cache preflight 24h, giảm OPTIONS subsequent request.
  • 4 method allowed: GET, POST, PATCH, DELETE + OPTIONS + HEAD — explicit list không Any.
  • 5 header allowed: Content-Type, Authorization, Idempotency-Key, X-Request-Id, If-None-Match.
  • 4 header expose: X-Request-Id, ETag, Location, X-Total-Count.
  • 6 OWASP security header: X-Frame-Options DENY, X-Content-Type-Options nosniff, HSTS 1 năm, CSP strict, Referrer-Policy strict-origin, Permissions-Policy disable browser features.
  • security_headers_middleware từ from_fn pattern (B76 lock continued — stateless static value).
  • 6 layer stack sau B77 (4 cũ + 2 mới): compression > decompression > cors > security_headers > request_id > enrich_error > handler.
  • SameSite cookie SKIP Shop API stateless JWT KHÔNG cookie — pattern chỉ áp khi future thêm session cookie (G11).
  • File path lock: NEW middleware/cors.rs + middleware/security_headers.rs; updated middleware/mod.rs + router.rs + config.rs + .env.example.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. CORS preflight vs simple request — khi nào browser gửi OPTIONS? 3 điều kiện trigger preflight.
  2. AllowOrigin::any() vs AllowOrigin::list(...) — tại sao production KHÔNG dùng any? Cho ví dụ attack scenario.
  3. allow_credentials(true) vs false — Shop API stateless JWT chọn false. Trade-off cookie vs token.
  4. 6 security header OWASP — phân biệt vai trò mỗi header. Cho ví dụ CSP strict vs permissive scenario.
  5. SameSite cookie Strict vs Lax vs None — khi nào dùng nào? Shop API skip vì lý do gì?
Đáp án
  1. Simple request browser gửi thẳng không preflight, server trả response có header Access-Control-Allow-Origin rồi browser quyết định expose body hay không cho JavaScript. Điều kiện simple request (CORS-safelisted theo Fetch spec): (a) method ∈ {GET, POST, HEAD}; (b) Content-Type ∈ {text/plain, application/x-www-form-urlencoded, multipart/form-data}; (c) chỉ có CORS-safelisted header (Accept, Accept-Language, Content-Language, Content-Type ở 3 value trên, Range cho byte fetch). Preflight request browser gửi OPTIONS trước với 2 header Access-Control-Request-Method + Access-Control-Request-Headers, chờ server trả 204 hoặc 200 với Access-Control-Allow-*, sau đó mới gửi request thật. 3 điều kiện trigger preflight: (a) method ∈ {PATCH, PUT, DELETE, CONNECT, OPTIONS, TRACE} — vd Shop API PATCH product, DELETE cart item; (b) Content-Type: application/json (hoặc khác 3 value safelisted) — vd POST tạo product với JSON body; (c) có custom header non-safelisted — vd Authorization: Bearer, Idempotency-Key, X-Request-Id. Shop API gần như 100% endpoint trigger preflight vì dùng JSON + Bearer auth → cấu hình CorsLayer đầy đủ là MANDATORY. Browser cache preflight theo Access-Control-Max-Age (Shop API lock 86400 = 24h) → request kế cùng tuple (origin, method, header set) skip OPTIONS, giảm overhead xuống ~0.
  2. Tại sao production KHÔNG dùng AllowOrigin::any(): (a) any() trả header Access-Control-Allow-Origin: * cho mọi origin — bất kỳ website nào trên Internet đều fetch được API thành công, JavaScript đọc được response; (b) kết hợp với cookie session (nếu app dùng) sẽ vi phạm RFC 6454 (CORS spec cấm wildcard + credentials); (c) attack scenario CSRF-like — kẻ tấn công host site https://attacker.com, dụ user (đã login Shop API) truy cập, JavaScript của attacker fetch https://api.shop.com/me/orders với CORS pass → đọc được data nhạy cảm user; (d) attack scenario data scraping — competitor scrape catalog products qua JavaScript browser (không qua server-side để né rate limit IP-based), CORS open = không có raincoat. Pattern AllowOrigin::list([...]) chỉ echo origin cụ thể (vd Access-Control-Allow-Origin: https://shop.blogcode.vn) khi request Origin match list — origin khác không có header → browser block JavaScript đọc response. Lock Shop API: Local dùng any() vì dev gọi từ Postman/curl/localhost:5173 SPA dev server đa dạng port, không có user nhạy cảm thật; Staging/Production dùng list(env_origins) + assert! fail-fast nếu empty (tránh deploy quên config → block toàn bộ frontend). Pattern decision tree: list env → MUST production, any() → CHỈ local dev.
  3. allow_credentials(true) cho phép browser gửi cookie + Authorization header + client TLS cert cross-origin. Server PHẢI echo origin cụ thể (KHÔNG được *) — RFC 6454 cấm wildcard + credentials đồng thời (tránh security leak). Frontend phải set fetch(url, {credentials: 'include'}). Use case: server-rendered web app dùng session cookie (vd admin dashboard truyền thống Rails/Django), SPA cùng tenant dùng cookie. allow_credentials(false) browser KHÔNG gửi cookie cross-origin; gửi Authorization header bình thường (header này không bị credentials flag chặn từ CORS spec, chỉ cookie/client cert bị chặn). Shop API lock false MANDATORY vì: (a) stateless JWT Bearer (B11 lock continued) — auth qua header Authorization: Bearer eyJ..., KHÔNG cookie; (b) simplify CORS config — không cần lock origin cụ thể với credentials, có thể wildcard nếu cần (Local any() không vi phạm spec); (c) scale dễ — không có server-side session store, mọi instance Shop API stateless thuần. Trade-off cookie vs token: cookie pros — auto attach mọi request không code, browser quản lý expiry, HttpOnly + SameSite=Strict + Secure chống XSS + CSRF triệt để; cons — CSRF risk nếu thiếu SameSite, scale stateful (Redis session store), cross-domain phức tạp. Token pros — stateless scale tốt, cross-domain dễ (mobile app, partner API), revoke tinh tế qua jti blacklist; cons — phải code attach header mỗi request (interceptor axios/fetch wrapper), XSS đọc localStorage = leak token (nên dùng HttpOnly refresh token cookie + memory access token hybrid pattern G12). Shop API quyết định: API thuần token vì target client đa platform (web SPA + mobile + partner integration); admin dashboard tương lai (B106) sẽ dùng session cookie + SameSite=Strict riêng (KHÔNG share auth scheme với public API).
  4. 6 OWASP security header phân biệt vai trò: (a) X-Frame-Options: DENY chống clickjacking — kẻ tấn công nhúng app admin vào <iframe> trong suốt, lừa user click vào button "Xác nhận" tưởng click vào game/promo; DENY cấm browser render trong frame bất kỳ, SAMEORIGIN chỉ cho frame cùng domain; CSP frame-ancestors 'none' là phiên bản hiện đại thay thế. (b) X-Content-Type-Options: nosniff chống MIME sniffing — browser cũ đoán type từ nội dung byte đầu, vd file upload .png chứa <script> tag browser cũ render HTML execute JS; nosniff ép browser tuân thủ Content-Type server. (c) Strict-Transport-Security: max-age=31536000; includeSubDomains chống SSL stripping — MITM ép user xuống HTTP, browser cache HSTS 1 năm → tự upgrade HTTP request thành HTTPS, không gửi clear-text bao giờ; includeSubDomains apply cho mọi *.blogcode.vn (cẩn thận: nếu subdomain nào không có HTTPS sẽ bị block); chỉ inject khi serve HTTPS thật, localhost HTTP browser ignore. (d) Content-Security-Policy chống XSS injection — limit nguồn resource browser load; Shop API API-only strict tối đa default-src 'self' + frame-ancestors 'none' + base-uri 'none'; strict scenario: API-only, không serve HTML — strict triệt để như Shop API là an toàn nhất, kẻ XSS không inject được external script; permissive scenario: web app cần CDN script (Tailwind, Google Fonts, Google Analytics) — phải mở script-src 'self' https://cdn.tailwindcss.com https://www.googletagmanager.com 'nonce-xxx', mỗi request server generate nonce ngẫu nhiên inject vào <script nonce="xxx"> inline tránh XSS inject script không có nonce. (e) Referrer-Policy: strict-origin-when-cross-origin chống information leak — same-origin gửi full URL Referer, cross-origin HTTPS chỉ gửi origin (không path), HTTPS → HTTP bỏ trống; chuẩn 2024 balance analytics + privacy, KHÔNG leak path nhạy cảm như /orders/12345/payment-status cho third-party (vd link click sang social media). (f) Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=() chống iframe escalation — nếu kẻ tấn công nhúng app vào iframe (dù X-Frame-Options đã block) hoặc XSS thành công, Permissions-Policy disable hẳn 4 API ở browser level → request không trigger được prompt user. Shop API API-only KHÔNG dùng feature này → disable tất cả là default tốt nhất.
  5. SameSite cookie 3 mode: (a) SameSite=Strict — cookie CHỈ gửi khi navigate từ cùng origin (cùng scheme + host + port); link từ Google search click vào Shop API sẽ KHÔNG gửi cookie → user phải login lại nếu vào qua external link; bảo vệ CSRF triệt để vì kẻ tấn công host site khác không trigger gửi cookie được. (b) SameSite=Lax (default 2024+ browser modern) — gửi cookie khi navigate top-level GET (link click, type URL, redirect), KHÔNG gửi khi POST/PATCH/DELETE từ form/JavaScript cross-origin hoặc iframe/img/script subresource; balance bảo mật + UX (user click link từ Google vẫn được login). (c) SameSite=None + Secure (MANDATORY) — gửi cookie cross-origin mọi context kể cả iframe + JS fetch; use case duy nhất: 3rd-party widget (Stripe Checkout, OAuth popup, embed video) cần state cross-domain; nguy hiểm: CSRF protection mất gần như hoàn toàn, phải bù bằng CSRF token double-submit. Decision tree: app standalone không có 3rd-party embed → Strict (admin dashboard internal); app có deep link Google/social cần UX trơn tru → Lax (default modern); app embed 3rd-party widget bắt buộc → None + Secure + CSRF token. Shop API skip SameSite vì 4 lý do lock vĩnh viễn: (a) stateless JWT Bearer (B11 + B112) — auth qua header KHÔNG cookie, SameSite không có chỗ áp dụng; (b) API-only không serve HTML — không có top-level navigation form submit cần cookie; (c) scale stateless không có server session store Redis cross-instance share; (d) client đa dạng (web + mobile + partner integration) — mobile native không có cookie jar browser, partner backend-to-backend không có browser context → token là chuẩn cross-platform duy nhất. Future admin dashboard B106 nếu thêm sẽ wire session cookie riêng: HttpOnly + Secure + SameSite=Strict + CSRF token double-submit (B107) — KHÔNG share auth scheme với public API.
11

Bài Tiếp Theo

— implement rate limiting với governor crate, in-memory token bucket, áp Shop API per-IP cho /auth + per-user cho /orders, Redis-backed cho production (preview G15), response 429 Too Many Requests + header Retry-After + X-RateLimit-Remaining.