Danh sách bài viết

Bài 83: Request Validation + Input Sanitize — Hardening Layer

Bài 83 của series Rust RESTful API — bài thứ 8 Group 8 Middleware Sâu (B83/B85), hardening layer cho Shop API qua 3 layer defense: DTO validate B41 với validator crate + ORDER BY whitelist B59 chống SQL injection + extractor middleware B83 mới; recap 4 vector input attack — SQL injection (B59 lock QueryBuilder.push_bind + whitelist) + XSS (server JSON skip vì serde_json tự escape, chuyển trách nhiệm client) + command injection (avoid Command::new("sh") shell exec với user input) + header injection (axum HeaderValue::from_str reject control char ngầm); 2 custom extractor mới ValidatedQuery<T> compose Query<T> + validate() giống ValidatedJson B41 cho query string DTO + ValidatedPath<T> cho path param với regex validate MANDATORY trước khi pass tới DB; 3 sanitize helper escape_html + truncate_for_log + sanitize_display_name dùng cho log + email G16 + admin dashboard G14 (KHÔNG dùng JSON response vì serde_json serializer auto-escape); strict-mode #[serde(deny_unknown_fields)] MANDATORY mọi Create/Update DTO chống typo field silent ignore (EXCEPT webhook DTO Stripe forward compat + extra metadata HashMap pattern B44 flatten); 4 cross-cutting validation rule MANDATORY checklist PR — length cap String + numeric range + Vec length + deny_unknown_fields — review reject nếu thiếu 1 trong 4; workspace dep mới html-escape = "0.2"; file path lock NEW extractors/validated_query.rs + NEW extractors/validated_path.rs + NEW shop-common/src/sanitize.rs; AppError variant 22 KHÔNG đổi B82; foundation cho B84 fallback handler + G14 admin escape + G16 email escape.

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

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

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

  • Hiểu 4 vector input attack: SQL injection, XSS, command injection, header injection.
  • Recap 3 layer defense Shop API (B41 validator + B59 ORDER BY whitelist + B83 mới).
  • Implement ValidatedQuery<T> custom extractor cho query string (giống ValidatedJson B41).
  • Implement ValidatedPath<T> cho path param với regex.
  • Hiểu HTML escape pattern cho field user-controlled (display_name, description).
  • Pattern strict-mode validation: reject unknown field qua #[serde(deny_unknown_fields)].
  • 4 cross-cutting validation rule MANDATORY checklist PR.
  • File path lock NEW 3 file (2 extractor + 1 sanitize), workspace dep html-escape v0.2.
2

4 Vector Input Attack

Trước khi đi vào code, bạn cần nhận diện 4 vector input attack điển hình trong REST API hiện đại. Mỗi vector có pattern tấn công riêng và giải pháp đặc thù — gộp chung dễ dẫn tới defense thiếu hoặc thừa.

SQL injection — đã lock ở B59 qua QueryBuilder + ORDER BY whitelist:

  • Pattern tấn công: handler nhúng thẳng user input vào câu SQL qua format!, ví dụ format!("SELECT * FROM products WHERE name = '{}'", user_input) — attacker gửi x'; DROP TABLE products;-- sẽ thực thi câu phụ.
  • Giải pháp: parameter binding qua sqlx::query! / sqlx::query_as! macro hoặc QueryBuilder.push_bind(); mọi giá trị động đi qua bind, không nhúng chuỗi.
  • Trường hợp đặc biệt cột ORDER BY (không thể bind giá trị động): whitelist match enum sang &'static str (B59 lock).

XSS (Cross-Site Scripting) — client gửi payload chứa thẻ script:

  • Pattern tấn công: client gửi display_name = "<script>alert('xss')</script>", server lưu nguyên văn, sau đó UI render HTML không escape → script chạy trong session người dùng khác.
  • Giải pháp truyền thống: escape <, >, &, ", ' khi render HTML phía server.
  • Note Shop API: API JSON-only (lock B5), server KHÔNG render HTML → trách nhiệm escape chuyển qua client (React/Vue tự escape khi bind text vào DOM). Tuy vậy server vẫn cần escape ở 2 nơi: log file (xem step 6 truncate_for_log) và email body (G16 deploy).
  • JSON response: serde_json tự escape </script> sequence khi serialize chuỗi → bạn KHÔNG phải tự escape trong handler.

Command injection — chạy shell với user input:

  • Pattern tấn công: Command::new("sh").arg("-c").arg(format!("convert {}", filename)) với filename đến từ client — attacker gửi a.png; rm -rf /.
  • Giải pháp: tránh tuyệt đối shell exec với user input. Nếu phải gọi external binary, dùng Command::new("convert").arg(filename) exec trực tiếp + arg list — không qua sh -c.
  • Shop API chưa có endpoint nào gọi shell; G15 (image resize) sẽ dùng crate image pure-Rust thay vì ImageMagick exec.

Header injection (HTTP Response Splitting) — nhúng \r\n vào header:

  • Pattern tấn công: Location: /redirect?url={user_input} với user gửi foo\r\nSet-Cookie: admin=1 → response bị tách thành 2 message, attacker inject cookie.
  • Giải pháp: dùng HeaderValue::from_str validate khi build response — axum + hyper reject ngầm mọi byte \r/\n/\0 trong header value (trả error InvalidHeaderValue).
  • Shop API safe by default: mọi header set qua HeaderMap::insert(name, HeaderValue::from_str(value)?); KHÔNG tự ghép chuỗi vào header rồi cast.

Bốn vector trên là baseline OWASP Top 10 phần Injection — bài này focus xây defense in depth cho 2 vector đầu (SQL + XSS) qua extractor middleware + sanitize helper.

3

Recap 3 Layer Defense (B41 + B59 + B83 Mới)

Shop API hardening 3 layer độc lập nhau. Tấn công đi xuyên từ HTTP boundary tới DB phải vượt cả 3.

Layer 1 — DTO validation (B41):

  • Crate validator = "0.18" đã lock workspace dep từ B41.
  • DTO derive Validate, field attribute length / regex / range / email / url / custom.
  • Apply trong ValidatedJson<T> extractor — gọi value.validate()? sau khi parse JSON, fail trả 422 với envelope ValidationFailed.
  • Áp dụng cho mọi POST/PUT/PATCH body từ B41 onward.

Layer 2 — Query SQL whitelist (B59):

  • Helper sort_to_sql(&ProductSort) -> &'static str match enum → constant string an toàn.
  • ORDER BY column từ user input PHẢI match whitelist enum trước khi nhúng vào câu SQL.
  • Mọi giá trị động khác đi qua query_as! macro bind tự động.
  • Combine với QueryBuilder.push_bind() cho dynamic WHERE clause.

Layer 3 — B83 mới:

  • ValidatedQuery<T> extractor cho query string (chưa có ở B41, vì B41 chỉ wrap JSON body).
  • ValidatedPath<T> cho path param với regex validate MANDATORY (chống path traversal + invalid format trước khi pass DB).
  • Strict-mode #[serde(deny_unknown_fields)] mọi Create/Update DTO chống typo field silent ignore.
  • HTML escape helper cho user-controlled string (log + email + admin dashboard).

Lock pattern Shop API: 3 layer defense validate ở mọi entry point — JSON body, query string, path param. Thiếu 1 layer = vector tấn công có lối vào.

4

ValidatedQuery<T> Custom Extractor

Pattern ValidatedJson<T> ở B41 chỉ wrap body JSON. Endpoint GET /api/v1/products?page=1&size=20&sort=name:asc nhận query string qua Query<T> nhưng KHÔNG gọi validate() — user gửi size=10000 sẽ pass thẳng tới DB layer.

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

// File: crates/shop-api/src/extractors/validated_query.rs
use axum::{
    extract::{FromRequestParts, Query},
    http::request::Parts,
};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;
use validator::Validate;

#[derive(Debug, Clone)]
pub struct ValidatedQuery<T>(pub T);

impl<T, S> FromRequestParts<S> for ValidatedQuery<T>
where
    T: DeserializeOwned + Validate,
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        // 1. Extract Query<T> qua axum built-in (parse query string → T)
        let Query(value): Query<T> = Query::from_request_parts(parts, state)
            .await
            .map_err(|e| AppError::BadRequest(format!("invalid query: {}", e)))?;

        // 2. Validate qua validator crate (length, range, regex, ...)
        value.validate()?;

        Ok(ValidatedQuery(value))
    }
}

Pattern hoàn toàn giống ValidatedJson<T> B41 — chỉ khác là wrap Query thay Json. Trả error 2 nhánh:

  • Parse fail (vd ?page=abc không phải số): BadRequest(400) — format error.
  • Validate fail (vd ?size=10000 vượt cap 100): ValidationFailed(422) — semantic error.

Apply vào ProductSearchQuery đã lock B59:

// File: crates/shop-api/src/routes/products.rs (sửa list_products)
use crate::extractors::ValidatedQuery;
use shop_common::dto::product::ProductSearchQuery;

pub async fn list_products(
    State(state): State<AppState>,
    ValidatedQuery(query): ValidatedQuery<ProductSearchQuery>,
) -> Result<Json<ProductListResponse>, AppError> {
    // validate đã chạy tự động — query.page/size/sort constraints OK
    let result = shop_db::products::list_products(&state.db, &query).await?;
    Ok(Json(result))
}

Update crates/shop-api/src/extractors/mod.rs re-export:

// File: crates/shop-api/src/extractors/mod.rs
pub mod json;
pub mod validated_json;
pub mod validated_query;   // NEW B83
pub mod validated_path;    // NEW B83

pub use json::AppJson;
pub use validated_json::ValidatedJson;
pub use validated_query::ValidatedQuery;   // NEW B83
pub use validated_path::ValidatedPath;     // NEW B83

Lock pattern: mọi handler nhận query string DTO MUST dùng ValidatedQuery<T> thay Query<T> trần — code review reject nếu thấy Query<T> trần xuất hiện.

5

ValidatedPath<T> Cho Path Param

Path param thường là ID (i64) hoặc slug (chuỗi). Slug chưa validate sẽ gây 2 rủi ro: (a) attacker gửi ký tự đặc biệt làm SQL match lung tung; (b) cache key bị poisoning vì slug có dấu cách hoặc unicode lạ.

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

// File: crates/shop-api/src/extractors/validated_path.rs
use axum::{
    extract::{FromRequestParts, Path},
    http::request::Parts,
};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;
use validator::Validate;

#[derive(Debug, Clone)]
pub struct ValidatedPath<T>(pub T);

impl<T, S> FromRequestParts<S> for ValidatedPath<T>
where
    T: DeserializeOwned + Validate + Send,
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        // Parse path param qua axum built-in
        let Path(value): Path<T> = Path::from_request_parts(parts, state)
            .await
            .map_err(|e| AppError::BadRequest(format!("invalid path: {}", e)))?;

        // Validate regex / length / range
        value.validate()?;

        Ok(ValidatedPath(value))
    }
}

DTO cho path param chứa slug — regex validate MANDATORY:

// File: crates/shop-common/src/dto/product.rs (extend)
use crate::dto::SLUG_REGEX;  // đã có từ B41
use serde::Deserialize;
use validator::Validate;

#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct ProductSlugPath {
    #[validate(
        length(min = 1, max = 100),
        regex(path = *SLUG_REGEX),
    )]
    pub slug: String,
}

Apply vào handler get_product:

// File: crates/shop-api/src/routes/products.rs (sửa get_product)
use crate::extractors::ValidatedPath;
use shop_common::dto::product::ProductSlugPath;

pub async fn get_product(
    State(state): State<AppState>,
    ValidatedPath(ProductSlugPath { slug }): ValidatedPath<ProductSlugPath>,
) -> Result<Json<ProductResponseDto>, AppError> {
    // slug đã match SLUG_REGEX ^[a-z0-9]+(-[a-z0-9]+)*$ → safe pass DB
    let row = shop_db::products::find_by_slug(&state.db, &slug)
        .await?
        .ok_or(AppError::NotFound)?;
    Ok(Json(row.into()))
}

Test bằng curl:

# slug hợp lệ — 200 OK
curl http://localhost:3000/api/v1/products/iphone-15-pro

# slug có ký tự lạ — 422 ValidationFailed
curl http://localhost:3000/api/v1/products/iphone%2015%20pro
# → {"error":"validation failed","code":"VALIDATION_FAILED",
#    "detail":{"fields":{"slug":["regex"]}}}

# slug quá dài — 422 ValidationFailed
curl "http://localhost:3000/api/v1/products/$(python -c 'print("a"*200)')"

Lock pattern: mọi path param có format constraint (slug, IDish string format vd order_id UUID, code) MUST validate qua ValidatedPath<T>. Path param thuần i64 không có format constraint vẫn dùng Path<i64> trần được vì serde tự reject input không phải số (trả 400 BadRequest format error theo B43 lock).

6

HTML Escape Helper Cho User-Controlled String

Server JSON API không render HTML nên XSS chuyển trách nhiệm sang client. Tuy vậy Shop API có 3 chỗ vẫn cần escape phía server: log file (admin xem qua Loki UI render HTML), email body (G16 deploy gửi mã xác nhận có chứa display_name), admin dashboard SSR (G14).

Thêm workspace dep mới — sửa shop/Cargo.toml:

# File: shop/Cargo.toml (workspace root)
[workspace.dependencies]
# ... existing deps ...
html-escape = "0.2"   # NEW B83

Add vào crates/shop-common/Cargo.toml:

# File: crates/shop-common/Cargo.toml
[dependencies]
# ... existing deps ...
html-escape = { workspace = true }   # NEW B83

Tạo file mới crates/shop-common/src/sanitize.rs:

// File: crates/shop-common/src/sanitize.rs
//! Sanitize helper cho user-controlled string.
//!
//! Dùng cho 3 boundary: log file, email body, admin dashboard SSR.
//! KHÔNG dùng cho JSON response (serde_json tự escape).

/// Escape HTML special chars: & < > " '
///
/// Ví dụ: `<script>` → `&lt;script&gt;`.
pub fn escape_html(input: &str) -> String {
    html_escape::encode_text(input).to_string()
}

/// Truncate string khi log, đính kèm độ dài gốc.
///
/// Tránh log dump body 1MB của attacker fuzz endpoint.
pub fn truncate_for_log(input: &str, max_len: usize) -> String {
    if input.len() <= max_len {
        input.to_string()
    } else {
        // chú ý: cắt theo byte có thể vào giữa codepoint UTF-8;
        // production version dùng char_indices() truncate safe boundary.
        format!("{}...({} bytes)", &input[..max_len], input.len())
    }
}

/// Sanitize display_name input — strip control char + cap length.
///
/// Áp dụng trước khi save user.display_name xuống DB.
pub fn sanitize_display_name(input: &str) -> String {
    input
        .chars()
        .filter(|c| !c.is_control())
        .take(100)
        .collect()
}

Update crates/shop-common/src/lib.rs expose module:

// File: crates/shop-common/src/lib.rs
pub mod config;
pub mod dto;
pub mod error;
pub mod headers;
pub mod retry;
pub mod sanitize;   // NEW B83
pub mod telemetry;

pub use error::{AppError, AppResult};
pub use retry::with_retry;
pub use sanitize::{escape_html, truncate_for_log, sanitize_display_name};   // NEW B83

Lock pattern Shop API dùng helper ở 4 boundary:

  • JSON response: KHÔNG escape — serde_json::to_vec tự escape </>/"/\ theo RFC 8259.
  • Log: dùng truncate_for_log(value, 200) trước khi nhúng vào tracing::info!(name = ?...); nếu log có thể render HTML qua Loki UI thì wrap thêm escape_html.
  • Email body (G16 deploy): MANDATORY escape_html(display_name) trước khi nhúng vào HTML template; askama auto-escape cho template HTML nhưng helper này dùng cho text/plain version + format string thủ công.
  • Display name input: sanitize_display_name strip control char (\0, \r, \n ngang giữa name) + cap 100 char trước khi save DB.
7

Strict-Mode Validation — deny_unknown_fields

Default serde behavior: field client gửi mà DTO không khai báo sẽ bị bỏ qua âm thầm. Pitfall: client gõ sai field name ("stocks": 10 thay vì "stock": 10) — server vẫn 201 OK với stock default 0; user nghĩ đã set 10 nhưng DB lưu 0. Bug khó debug.

Pattern strict-mode — apply cho mọi Create/Update DTO:

// File: crates/shop-common/src/dto/product.rs (sửa CreateProductDto)
use serde::Deserialize;
use validator::Validate;
use std::collections::BTreeMap;
use serde_json::Value;
use crate::dto::types::Money;

#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]   // NEW B83 — strict mode MANDATORY
pub struct CreateProductDto {
    #[validate(length(min = 1, max = 200))]
    pub name: String,

    #[validate(length(min = 1, max = 100), regex(path = *crate::dto::SLUG_REGEX))]
    pub slug: String,

    #[validate(custom(function = "validate_money_positive"))]
    pub price: Money,

    #[validate(range(min = 0, max = 1_000_000))]
    pub stock: u32,

    #[serde(default)]
    #[validate(length(max = 500))]
    pub description: Option<String>,

    #[serde(default)]
    pub metadata: BTreeMap<String, Value>,
}

Test bằng curl:

# Typo field — 400 Bad Request (B43 lock parse fail = format error)
curl -X POST http://localhost:3000/api/v1/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "iPhone 15",
    "slug": "iphone-15",
    "price": "29990000",
    "stock": 10,
    "stocks": 99,
    "typo_field": "oops"
  }'

# Response:
# {
#   "error": "json data mismatch",
#   "code": "JSON_DATA_MISMATCH",
#   "detail": {
#     "path": "",
#     "message": "unknown field `stocks`, expected one of `name`, `slug`, ..."
#   }
# }

Lock decision Shop API:

  • deny_unknown_fields MANDATORY cho mọi Create/Update DTO (CreateProductDto, UpdateProductDto, CreateOrderDto, RegisterDto, LoginDto, UpdateProfileDto, ...).
  • EXCEPT webhook DTO: Stripe có thể thêm field mới vào event payload trong tương lai (forward compatibility); webhook handler chỉ đọc field cần, bỏ qua field mới — deny_unknown_fields ở đây sẽ làm break webhook khi Stripe upgrade.
  • EXCEPT extra metadata HashMap pattern (B44 flatten): field metadata: BTreeMap<String, Value> đã hứng tất cả custom key, không xung đột với strict-mode top-level.
8

Cross-Cutting Validation Rules — 4 Global Lock

Mỗi DTO Shop API phải pass 4 rule sau. Thiếu 1 rule = vector tấn công có lối vào. Code review PR reject nếu thấy DTO mới thiếu 1 trong 4.

Rule 1 — Length cap MANDATORY cho String field:

  • Mọi field String MUST có #[validate(length(max = N))].
  • Default cap Shop API: 200 char (name), 500 (description), 100 (slug), 320 (email theo RFC 5321), 50 (display_name), 1000 (note).
  • Lý do: chống DoS qua large payload (attacker gửi name = "A".repeat(10_000_000) ngốn RAM + log + DB).

Rule 2 — Numeric range MANDATORY cho integer field:

  • Mọi u32/i32/u64/i64 MUST có #[validate(range(min, max))].
  • Lý do: chống overflow + out-of-bound business value (stock = u32::MAX không có nghĩa nghiệp vụ).
  • Ví dụ: stock range 0..=1_000_000, quantity 1..=999, page 1..=10_000.

Rule 3 — Vec/Array length MANDATORY:

  • Mọi Vec<T> MUST có #[validate(length(min, max))].
  • Default cap Shop API: 100 items (CreateOrderDto items), 20 (category_ids), 30 (tags), 10 (image_urls).
  • Lý do: chống DoS qua array khổng lồ (attacker gửi items: [{...}].repeat(1_000_000) ngốn RAM khi deserialize).

Rule 4 — Strict-mode deny_unknown_fields (đã chi tiết bước 7).

Code review checklist text-mode để paste vào PR template:

Validation checklist (B83 lock):
[ ] String field có #[validate(length(max = N))] ?
[ ] Numeric field có #[validate(range(min, max))] ?
[ ] Vec field có #[validate(length(max = N))] ?
[ ] Struct có #[serde(deny_unknown_fields)] (KHÔNG phải webhook DTO) ?

Reject PR nếu thiếu 1 trong 4 rule.

Ví dụ DTO compliance đầy đủ:

// File: crates/shop-common/src/dto/order.rs (sample)
use serde::Deserialize;
use validator::Validate;
use crate::dto::types::ProductId;

#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]                       // Rule 4 — strict mode
pub struct CreateOrderItemDto {
    pub product_id: ProductId,                      // newtype, validate internal
    #[validate(range(min = 1, max = 999))]         // Rule 2 — numeric range
    pub quantity: u32,
}

#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]                       // Rule 4
pub struct CreateOrderDto {
    #[validate(length(min = 1, max = 100), nested)]  // Rule 3 — Vec length
    pub items: Vec<CreateOrderItemDto>,

    #[validate(length(max = 1000))]                 // Rule 1 — String length
    pub note: Option<String>,
}

Test oversize payload — server reject 422 không phải 500:

# Vec items quá dài — 422 ValidationFailed
curl -X POST http://localhost:3000/api/v1/orders \
  -H "Content-Type: application/json" \
  -d "{\"items\":$(python -c 'import json;print(json.dumps([{"product_id":1,"quantity":1}]*200))'),\"note\":null}"

# Response: 422 ValidationFailed
# detail.fields.items = ["length"]

4 rule trên là baseline production-grade. Mỗi PR thêm DTO mới sẽ chạy qua checklist 4 bước. Lint custom (preview G18) có thể detect tự động: AST visit struct derive Deserialize, kiểm có deny_unknown_fields + mỗi Stringlength(max).

9

Tổng Kết

  • 4 vector input attack: SQL injection (B59 lock), XSS (server JSON skip vì serde_json tự escape), command injection (avoid shell exec với user input), header injection (axum HeaderValue::from_str handle ngầm).
  • 3 layer defense: DTO validate B41 + SQL whitelist B59 + extractor middleware B83.
  • ValidatedQuery<T> extractor lock cho query string DTO (compose Query<T> + validate()).
  • ValidatedPath<T> extractor lock cho path param (regex MANDATORY cho slug / UUID format).
  • HTML escape helpers: escape_html, truncate_for_log, sanitize_display_name.
  • html-escape crate v0.2 workspace dep mới.
  • #[serde(deny_unknown_fields)] MANDATORY cho mọi Create/Update DTO.
  • EXCEPT webhook DTO (Stripe forward compat) + extra metadata HashMap pattern (B44 flatten).
  • 4 cross-cutting rule: length max String + numeric range + Vec length max + strict-mode.
  • Code review checklist — reject PR thiếu 1 trong 4 rule.
  • Test pattern: send unknown field → 400; send oversize → 422 (envelope ValidationFailed).
  • File path lock: NEW extractors/validated_query.rs + NEW extractors/validated_path.rs + NEW shop-common/src/sanitize.rs.
  • Updated extractors/mod.rs + shop-common/src/lib.rs + DTO files thêm deny_unknown_fields.
  • AppError variant 22 KHÔNG đổi B82 (Timeout vẫn variant 22 — B83 chỉ reuse ValidationFailed + BadRequest đã có).
  • Foundation cho B84 (fallback handler 404 custom), G14 admin dashboard SSR escape, G16 email template escape.
10

Bài Tập Củng Cố

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

  1. 4 vector input attack — phân tích pros/cons giải pháp mỗi vector. Tại sao XSS phía server-side API JSON có thể skip mà vẫn an toàn? Khi nào server vẫn phải escape mặc dù API JSON-only?
  2. ValidatedQuery<T> vs ValidatedJson<T> — phân biệt scope mỗi extractor (query string vs body JSON). Khi nào dùng cái nào? Cho ví dụ Shop API endpoint cụ thể.
  3. #[serde(deny_unknown_fields)] — pitfall typo field silent ignore. Cho ví dụ scenario cụ thể trong Shop API và behavior trước/sau khi apply strict-mode.
  4. 4 cross-cutting validation rule — checklist review PR. Cho ví dụ scenario DTO thiếu 1 trong 4 rule và hậu quả production có thể xảy ra.
  5. HTML escape khi nào MANDATORY khi nào skip — phân biệt 3 boundary: JSON API response (skip), email body (MANDATORY), admin dashboard SSR (MANDATORY). Tại sao serde_json tự escape mà template HTML thì không?
Đáp án
  1. 4 vector input attack pros/cons: (a) SQL injection — solution parameter binding qua sqlx::query! macro hoặc QueryBuilder.push_bind; pros: compile-time check (macro), an toàn tuyệt đối khi giá trị động đi qua bind, ORDER BY column dùng whitelist match enum sang &'static str (B59 lock); cons: ORDER BY/dynamic column phải tự whitelist không thể auto, người mới dễ quên dùng format! nhúng chuỗi — phải code review chặt. (b) XSS — solution escape HTML khi render; pros với template engine (askama/maud) auto-escape mặc định an toàn, serde_json auto-escape khi serialize response; cons: nếu trộn raw HTML vào template (vd {{ html_str | safe }}) hoặc dùng template không auto-escape (vd Tera default config) sẽ tạo lỗ hổng. (c) Command injection — solution tránh tuyệt đối sh -c với user input; pros: dùng Command::new("convert").arg(filename) exec trực tiếp + arg list pass nguyên giá trị qua argv không qua shell parser; cons: vẫn có thể bị nếu binary đích tự gọi shell internally (vd ImageMagick với certain delegate), nên ưu tiên thư viện pure-Rust (image crate) khi có thể. (d) Header injection — solution dùng HeaderValue::from_str validate; pros: axum + hyper reject ngầm mọi byte \r/\n/\0 trong header value → trả error InvalidHeaderValue trước khi gửi wire; cons: nếu code tự ghép chuỗi rồi cast qua HeaderValue::from_bytes unchecked sẽ bypass — code review chặn. XSS server-side API JSON skip an toàn vì: (i) serde_json::to_vec tự escape </>/"/\ theo RFC 8259 + escape unicode control char + </script> sequence — không thể nhúng raw HTML vào JSON value; (ii) Content-Type: application/json bảo browser KHÔNG parse response như HTML, không execute script tag; (iii) client (React/Vue) bind text vào DOM qua textContent/v-text mặc định auto-escape — chỉ dangerouslySetInnerHTML/v-html mới render raw HTML và đó là trách nhiệm client đảm bảo không dùng với user input. Khi nào server vẫn phải escape mặc dù API JSON-only: (a) Log file — admin xem qua Loki/Grafana UI render HTML, attacker fuzz endpoint với payload chứa <script> sẽ bị render khi admin view log → escape trước khi tracing::info!; (b) Email body (G16 deploy) — gửi user via SMTP, email client render HTML, server nhúng display_name vào template HTML → escape MANDATORY; (c) Admin dashboard SSR (G14) — server render HTML qua askama/maud, có auto-escape nhưng vẫn phải verify mỗi {{ value }} dùng escape filter mặc định; (d) CSV/XLSX export — formula injection nếu user input bắt đầu bằng =/+/-/@ sẽ thực thi formula khi mở Excel, prefix ' hoặc strip ký tự đó.
  2. ValidatedQuery<T> vs ValidatedJson<T> phân biệt scope: ValidatedJson<T> wrap body JSON của POST/PUT/PATCH request, consume body bytes (impl FromRequest chứ không phải FromRequestParts), KHÔNG dùng được cho GET/DELETE (không có body); ValidatedQuery<T> wrap query string sau dấu ? của URL, không touch body (impl FromRequestParts), dùng được cho mọi method bao gồm GET/DELETE. Khi nào dùng cái nào: ValidatedJson<T> cho POST/PUT/PATCH với DTO phức tạp nhiều field nested (CreateOrderDto với items: Vec<...>, CreateProductDto với metadata: BTreeMap<...>), payload size lớn hơn URL cho phép (URL giới hạn ~2KB browser, ~8KB server); ValidatedQuery<T> cho GET với filter/pagination/sort DTO đơn giản chỉ field flat (ProductSearchQuery { page, size, sort, status, min_price, max_price }). Ví dụ Shop API cụ thể: (a) GET /api/v1/products?page=1&size=20&sort=name:asc&status=activeValidatedQuery<ProductSearchQuery>; (b) POST /api/v1/products với body {name, slug, price, stock, ...}ValidatedJson<CreateProductDto>; (c) PATCH /api/v1/products/iphone-15 với body partial → ValidatedPath<ProductSlugPath> + ValidatedJson<UpdateProductDto> cùng handler (path + body cả 2 validate); (d) GET /api/v1/orders?status=paid&from_date=2026-01-01ValidatedQuery<OrderSearchQuery>. Lock pattern: handler nhận query string MUST dùng ValidatedQuery thay Query trần — code review reject.
  3. deny_unknown_fields pitfall typo field silent ignore: default serde behavior bỏ qua field client gửi mà DTO không khai báo → user lỗi typo không nhận feedback, server lưu giá trị default → DB sai âm thầm. Scenario Shop API cụ thể: admin gọi POST /api/v1/admin/products tạo iPhone 15 mới với body {"name":"iPhone 15","slug":"iphone-15","price":"29990000","stocks":50,"price_currency":"VND"} — admin gõ "stocks" thay "stock" và thêm "price_currency" không có trong DTO (Money newtype đã embed currency). Behavior trước khi apply deny_unknown_fields: serde bỏ qua 2 field stocks + price_currency, parse OK với stock = 0 (default từ #[serde(default)] hoặc Option::None), validate pass (stock 0 trong range 0..=1_000_000), DB INSERT row với stock = 0; response 201 OK với {stock: 0}; admin nghĩ đã set 50 (vì response có status 201 dấu hiệu thành công, không đọc kỹ field stock), tiến hành publish sản phẩm; user mua → 422 InsufficientStock (B54 lock); customer service nhận complaint, debug 30 phút mới phát hiện admin gõ sai field 4 tuần trước. Behavior sau khi apply: serde reject parse với error unknown field 'stocks', expected one of 'name', 'slug', 'price', 'stock', 'description', 'metadata' → AppError::JsonDataMismatch trả 400 BadRequest với detail.message rõ ràng → admin nhận lỗi ngay UI, fix typo gửi lại; total time fix < 30 giây thay 30 phút debug + 4 tuần lost sales. Lock Shop API: MANDATORY mọi Create/Update DTO; EXCEPT (a) webhook DTO Stripe có thể thêm field mới forward compat (skip strict cho StripeEventPayload), (b) extra metadata: BTreeMap<String, Value> đã hứng tất cả custom key nên không xung đột top-level strict.
  4. 4 cross-cutting rule + scenario thiếu rule: Rule 1 — Length cap String. Scenario thiếu: CreateProductDto.description: String không có length(max) → attacker fuzz endpoint với description = "A".repeat(50_000_000) (50MB string trong JSON body) → server deserialize tiêu hết RAM của container 512MB → OOM killed → các request khác đang xử lý bị mất. Mitigate phụ: DefaultBodyLimit B47 (default 2MB) giới hạn body size — vẫn không thay được length cap per-field vì 1 string 1.9MB pass body limit nhưng vẫn chạm OOM khi nhân với 100 concurrent request. Rule 2 — Numeric range. Scenario thiếu: CreateOrderItemDto.quantity: u32 không có range → user gửi quantity = u32::MAX (~4.2 tỷ) → handler insert_order_item bind quantity = 4_200_000_000 vào DB → constraint CHECK (quantity > 0) pass nhưng unit_price * quantity overflow Decimal128 hoặc tràn báo cáo doanh thu năm → audit log số tiền âm. Rule 3 — Vec length. Scenario thiếu: CreateOrderDto.items: Vec<CreateOrderItemDto> không có length(max) → attacker gửi 1 triệu item trong 1 request → handler loop validate stock từng item → 1 transaction lock cả bảng products 30s → các order khác chờ → cascade timeout B82 → 504 storm. Mitigate phụ: DefaultBodyLimit ngăn payload >2MB; nhưng vector 100K item (~1.5MB) vẫn pass body limit. Rule 4 — strict-mode. Scenario thiếu: như câu 3 — typo field silent ignore, admin set stock sai mất doanh số. Code review checklist: paste vào PR template, 4 checkbox, reject PR thiếu 1 trong 4. Lint custom G18 preview: AST visit struct derive Deserialize, kiểm deny_unknown_fields + mỗi field Stringlength(max) + numeric có range + Veclength.
  5. HTML escape boundary phân biệt: (a) JSON API response — skip: serde_json::to_vec tự escape </>/"/\/\b/\f/\n/\r/\t/control char theo RFC 8259 + escape </script> sequence (avoid early script tag close khi response embedded vào <script> tag) + escape unicode > 0x7E qua \uXXXX nếu cấu hình ascii_only. Content-Type: application/json báo browser KHÔNG parse như HTML → script tag không execute. (b) Email body — MANDATORY: email client (Gmail / Outlook / Apple Mail) render HTML body → nếu nhúng raw display_name = "<script>alert('xss')</script>" vào template <p>Xin chào {{display_name}}</p> thì client sẽ render script tag (mặc dù phần lớn email client strip <script> vì security, nhưng <img onerror>, <a href="javascript:"> vẫn execute) → escape MANDATORY trước khi nhúng. askama auto-escape mặc định cho file .html template; helper escape_html dùng cho text/plain version email + format string thủ công không qua template. (c) Admin dashboard SSR — MANDATORY: server render HTML, admin user là người nội bộ nhưng vẫn xem data của customer end-user; nếu customer gõ display_name = "<script>steal_admin_session()</script>" và admin view list user → script chạy trong context admin → leak admin session token → attacker chiếm quyền admin. askama/maud auto-escape mặc định nhưng vẫn phải verify mỗi {{ value }} dùng filter mặc định, KHÔNG dùng {{ value | safe }} với user input. Tại sao serde_json tự escape mà template HTML thì không (mặc định): (i) serde_json ràng buộc strict bởi RFC 8259 — chỉ có 1 cách serialize chuỗi đúng spec, không có "raw mode" → escape là chuyện đương nhiên không có choice; (ii) template HTML có legitimate use case render raw HTML (vd admin viết bài blog rich text qua TinyMCE/Quill, output HTML đã sanitize phía client, server render trực tiếp) → engine cho phép opt-out auto-escape qua filter | safe/{% raw %}; bài học: opt-out là dao 2 lưỡi, dev phải biết rõ khi nào dùng, default an toàn nhất là auto-escape mọi {{ var }} + chỉ | safe cho data đã sanitize qua thư viện như ammonia (Rust port của Bleach).
11

Bài Tiếp Theo

— custom 404 fallback cho route không match (B19 lock continued), pattern 405 Method Not Allowed, OPTIONS handler default, error envelope consistency cho non-handler error (panic, internal Tokio error), áp Shop API uniform error response.