Mục lục
- Mục Tiêu Bài Học
- 4 Vector Input Attack
- Recap 3 Layer Defense (B41 + B59 + B83 Mới)
- ValidatedQuery<T> Custom Extractor
- ValidatedPath<T> Cho Path Param
- HTML Escape Helper Cho User-Controlled String
- Strict-Mode Validation — deny_unknown_fields
- Cross-Cutting Validation Rules — 4 Global Lock
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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ốngValidatedJsonB41). - 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-escapev0.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ửix'; DROP TABLE products;--sẽ thực thi câu phụ. - Giải pháp: parameter binding qua
sqlx::query!/sqlx::query_as!macro hoặcQueryBuilder.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_jsontự 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ớifilenameđến từ client — attacker gửia.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 quash -c. - Shop API chưa có endpoint nào gọi shell; G15 (image resize) sẽ dùng crate
imagepure-Rust thay vìImageMagickexec.
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ửifoo\r\nSet-Cookie: admin=1→ response bị tách thành 2 message, attacker inject cookie. - Giải pháp: dùng
HeaderValue::from_strvalidate khi build response — axum + hyper reject ngầm mọi byte\r/\n/\0trong header value (trả errorInvalidHeaderValue). - 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.
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 attributelength/regex/range/email/url/custom. - Apply trong
ValidatedJson<T>extractor — gọivalue.validate()?sau khi parse JSON, fail trả 422 với envelopeValidationFailed. - Áp dụng cho mọi
POST/PUT/PATCHbody từ B41 onward.
Layer 2 — Query SQL whitelist (B59):
- Helper
sort_to_sql(&ProductSort) -> &'static strmatch 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 dynamicWHEREclause.
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.
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=abckhông phải số):BadRequest(400)— format error. - Validate fail (vd
?size=10000vượ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.
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).
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>` → `<script>`.
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_vectự escape</>/"/\theo RFC 8259. - Log: dùng
truncate_for_log(value, 200)trước khi nhúng vàotracing::info!(name = ?...); nếu log có thể render HTML qua Loki UI thì wrap thêmescape_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_namestrip control char (\0,\r,\nngang giữa name) + cap 100 char trước khi save DB.
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_fieldsMANDATORY 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
metadataHashMap pattern (B44 flatten): fieldmetadata: BTreeMap<String, Value>đã hứng tất cả custom key, không xung đột với strict-mode top-level.
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
StringMUST 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/i64MUST có#[validate(range(min, max))]. - Lý do: chống overflow + out-of-bound business value (
stock = u32::MAXkhông có nghĩa nghiệp vụ). - Ví dụ:
stockrange 0..=1_000_000,quantity1..=999,page1..=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 String có length(max).
Tổng Kết
- 4 vector input attack: SQL injection (B59 lock), XSS (server JSON skip vì
serde_jsontự escape), command injection (avoid shell exec với user input), header injection (axumHeaderValue::from_strhandle ngầm). - 3 layer defense: DTO validate B41 + SQL whitelist B59 + extractor middleware B83.
ValidatedQuery<T>extractor lock cho query string DTO (composeQuery<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-escapecrate v0.2 workspace dep mới.#[serde(deny_unknown_fields)]MANDATORY cho mọi Create/Update DTO.- EXCEPT webhook DTO (Stripe forward compat) + extra
metadataHashMap 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+ NEWextractors/validated_path.rs+ NEWshop-common/src/sanitize.rs. - Updated
extractors/mod.rs+shop-common/src/lib.rs+ DTO files thêmdeny_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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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?
ValidatedQuery<T>vsValidatedJson<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ể.#[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 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.
- 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_jsontự escape mà template HTML thì không?
Đáp án
- 4 vector input attack pros/cons: (a) SQL injection — solution parameter binding qua
sqlx::query!macro hoặcQueryBuilder.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ùngformat!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_jsonauto-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 đốish -cvới user input; pros: dùngCommand::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 (vdImageMagickvới certain delegate), nên ưu tiên thư viện pure-Rust (imagecrate) khi có thể. (d) Header injection — solution dùngHeaderValue::from_strvalidate; pros: axum + hyper reject ngầm mọi byte\r/\n/\0trong header value → trả errorInvalidHeaderValuetrước khi gửi wire; cons: nếu code tự ghép chuỗi rồi cast quaHeaderValue::from_bytesunchecked sẽ bypass — code review chặn. XSS server-side API JSON skip an toàn vì: (i)serde_json::to_vectự 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/jsonbảo browser KHÔNG parse response như HTML, không execute script tag; (iii) client (React/Vue) bind text vào DOM quatextContent/v-textmặc định auto-escape — chỉdangerouslySetInnerHTML/v-htmlmớ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 khitracing::info!; (b) Email body (G16 deploy) — gửi user via SMTP, email client render HTML, server nhúngdisplay_namevà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ự đó. ValidatedQuery<T>vsValidatedJson<T>phân biệt scope:ValidatedJson<T>wrap body JSON củaPOST/PUT/PATCHrequest, consume body bytes (implFromRequestchứ không phảiFromRequestParts), KHÔNG dùng được choGET/DELETE(không có body);ValidatedQuery<T>wrap query string sau dấu?của URL, không touch body (implFromRequestParts), dùng được cho mọi method bao gồmGET/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 (CreateOrderDtovớiitems: Vec<...>,CreateProductDtovớimetadata: 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=active→ValidatedQuery<ProductSearchQuery>; (b)POST /api/v1/productsvới body{name, slug, price, stock, ...}→ValidatedJson<CreateProductDto>; (c)PATCH /api/v1/products/iphone-15vớ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-01→ValidatedQuery<OrderSearchQuery>. Lock pattern: handler nhận query string MUST dùngValidatedQuerythayQuerytrần — code review reject.deny_unknown_fieldspitfall 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ọiPOST /api/v1/admin/productstạ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 applydeny_unknown_fields: serde bỏ qua 2 fieldstocks+price_currency, parse OK vớistock = 0(default từ#[serde(default)]hoặcOption::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 errorunknown 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 choStripeEventPayload), (b) extrametadata: BTreeMap<String, Value>đã hứng tất cả custom key nên không xung đột top-level strict.- 4 cross-cutting rule + scenario thiếu rule: Rule 1 — Length cap String. Scenario thiếu:
CreateProductDto.description: Stringkhông cólength(max)→ attacker fuzz endpoint vớidescription = "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ụ:DefaultBodyLimitB47 (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: u32không có range → user gửiquantity = u32::MAX(~4.2 tỷ) → handlerinsert_order_itembindquantity = 4_200_000_000vào DB → constraintCHECK (quantity > 0)pass nhưngunit_price * quantityoverflow 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ụ:DefaultBodyLimitngă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 deriveDeserialize, kiểmdeny_unknown_fields+ mỗi fieldStringcólength(max)+ numeric córange+Veccólength. - HTML escape boundary phân biệt: (a) JSON API response — skip:
serde_json::to_vectự 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\uXXXXnếu cấu hình ascii_only.Content-Type: application/jsonbá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 rawdisplay_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.htmltemplate; helperescape_htmldù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 saoserde_jsontự escape mà template HTML thì không (mặc định): (i)serde_jsonrà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ỉ| safecho data đã sanitize qua thư viện nhưammonia(Rust port của Bleach).
Bài Tiếp Theo
Bài 84: Fallback Handler — 404 Customization — 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.
