Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu
multipart/form-dataformat và khác biệt vớiapplication/x-www-form-urlencodedở B35 (boundary delimiter tách parts vs flatkey=valuepercent-encoded). - Biết
Multipartextractor axum core stream từng field quamultipart.next_field().awaitkhông load toàn body memory. - Nắm cách extract text field qua
field.text().awaitvà file field quafield.bytes().await. - Áp dụng file size limit anti-DoS qua
DefaultBodyLimit::max(N)per route — Shop API 5MB product image, 2MB avatar, 20MB PDF. - Hiểu magic bytes verify MANDATORY — KHÔNG trust Content-Type/filename client gửi; dùng crate
inferdetect MIME từ bytes thật. - Save file safe — generate UUID v4 server-side cho filename tránh path traversal, extension từ magic bytes (KHÔNG từ client filename).
- Pattern Shop API admin upload product image (B62 implement): Multipart → size limit → magic bytes verify → UUID filename → S3 + CDN URL (CDN strategy lock B27).
multipart/form-data Format
Khi HTML form khai báo enctype="multipart/form-data" hoặc có <input type="file" />, browser tự đổi Content-Type submit từ application/x-www-form-urlencoded (B35) sang multipart/form-data; boundary=----abc123 — format hoàn toàn khác để truyền file binary hiệu quả mà không phải encode toàn bộ thành base64 (tốn 33% size) hay percent-encoding (tốn 200% với byte non-ASCII).
Cấu trúc body chia thành parts tách qua boundary marker ngẫu nhiên do browser sinh (đảm bảo không trùng nội dung file). Mỗi part có:
- Header
Content-Disposition: form-data; name="..."cho text field, hoặc thêm; filename="..."cho file field. - Header
Content-Type: image/jpegcho file field claim MIME (client gửi — KHÔNG trust cho security). - 1 empty line phân tách header và body.
- Body part: text UTF-8 hoặc binary bytes raw.
Wire format đầy đủ cho form admin upload product image (1 text field + 1 file field):
POST /admin/products/upload HTTP/1.1
Host: admin.shop.com
Content-Type: multipart/form-data; boundary=----abc123
Content-Length: 524
------abc123
Content-Disposition: form-data; name="title"
Phone Pro
------abc123
Content-Disposition: form-data; name="image"; filename="phone.jpg"
Content-Type: image/jpeg
<binary jpeg bytes ~500KB...>
------abc123--
Quan sát chi tiết:
- Boundary
----abc123ngẫu nhiên do browser sinh — bắt đầu mỗi part qua--{boundary}, kết thúc body qua--{boundary}--(suffix double dash). - Text field (
title) chỉ cóContent-Disposition: form-data; name="title", body là text UTF-8. - File field (
image) có thêmfilename="phone.jpg"trong Content-Disposition +Content-Type: image/jpegriêng + body binary bytes raw. - Streaming-friendly — server có thể parse từng part lần lượt không cần biết trước Content-Length tổng, phù hợp file lớn không load full memory.
So với x-www-form-urlencoded B35: urlencoded flat title=Phone+Pro&image=... chỉ chứa text, file binary phải base64 encode (tốn 33% size + complexity decode), không streaming-friendly. Multipart sinh ra chính cho use case file upload theo RFC 7578.
Multipart Extractor Cơ Bản
axum cung cấp axum::extract::Multipart extractor có sẵn trong axum core (KHÔNG cần feature riêng axum-extra). Pattern parse stream từng field qua .next_field().await trả Option<Field> — None khi hết parts:
// File: crates/shop-api/src/routes/admin/products.rs (B62 implement)
use axum::extract::Multipart;
use axum::response::Json;
use shop_common::error::AppResult;
#[derive(serde::Serialize)]
struct UploadResponse {
url: String,
title: String,
}
async fn upload(
mut multipart: Multipart,
) -> AppResult<Json<UploadResponse>> {
let mut title = String::new();
let mut image_bytes: Option<bytes::Bytes> = None;
while let Some(field) = multipart.next_field().await? {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"title" => {
title = field.text().await?;
tracing::info!(title = %title, "got title field");
}
"image" => {
let filename = field
.file_name()
.unwrap_or("unknown")
.to_string();
let content_type = field
.content_type()
.unwrap_or("")
.to_string();
tracing::info!(
filename = %filename,
content_type = %content_type,
"got image field"
);
image_bytes = Some(field.bytes().await?);
}
_ => continue,
}
}
// process bytes (B62 implement đầy đủ verify + save)
Ok(Json(UploadResponse {
url: "https://cdn.shop.com/products/...".to_string(),
title,
}))
}
Điểm chú ý:
mut multipart: Multipart— extractor consume body quaFromRequest(KHÔNGFromRequestParts), đặt CUỐI arg list theo lock B31 giốngForm<T>/Json<T>.multipart.next_field().awaittrảResult<Option<Field>, MultipartError>. Stream parse từng part lần lượt —Nonebáo hết parts (gặp boundary kết thúc--{boundary}--).field.name()trảOption<&str>tên field từContent-Disposition: form-data; name="...", dùng để route logic xử lý field nào.field.file_name()+field.content_type()trảOption<&str>filename và Content-Type client claim — chỉ log/audit, KHÔNG dùng cho security decision (chi tiết bước 5).field.text().awaitconsume field thànhStringUTF-8 — phù hợp text field nhỏ (title, alt_text, sort_order). Fail nếu bytes không phải UTF-8 hợp lệ.field.bytes().awaitconsume field thànhbytes::Bytes— phù hợp file binary. Cẩn thận memory — file 5MB tiêu thụ 5MB RAM cùng lúc, bound qua body size limit (bước 4).
Alternative cho stream-based read tiết kiệm memory hơn nữa: field.chunk().await trả chunk nhỏ ~8KB liên tục, xử lý streaming từng chunk (vd upload thẳng S3 multipart upload song song không hold full bytes RAM). Pattern phức tạp hơn, Shop API B62 dùng field.bytes() đơn giản vì file image <= 5MB acceptable hold memory ngắn.
File Size Limit — Anti-DoS
Pitfall nghiêm trọng: client gửi 10GB file → server load full vào Bytes → OOM crash. Attacker spam 100 request song song mỗi request 10GB → datacenter bandwidth + memory + disk hết → server down (DoS). Không limit thì 1 attacker đủ đánh sập production.
axum mặc định body limit 2MB per request qua DefaultBodyLimit bật sẵn ở extractor body (Json, Form, Multipart, Bytes). Vượt limit → reject 413 Payload Too Large body rỗng theo RFC 9110 mục 15.5.14.
Override per route qua .layer(DefaultBodyLimit::max(N)) trên Router:
// File: crates/shop-api/src/routes/admin/products.rs (B62 implement)
use axum::extract::DefaultBodyLimit;
use axum::routing::post;
use axum::Router;
pub fn admin_product_routes() -> Router<crate::AppState> {
Router::new()
.route(
"/admin/products/:slug/images",
post(upload_product_image),
)
.layer(DefaultBodyLimit::max(5 * 1024 * 1024)) // 5MB
.route(
"/admin/users/:id/avatar",
post(upload_avatar),
)
.layer(DefaultBodyLimit::max(2 * 1024 * 1024)) // 2MB
}
Pattern Shop API lock vĩnh viễn 3 ngưỡng theo loại upload:
┌─────────────────────┬──────────┬──────────────────────────────────────┐
│ Use case │ Limit │ Lý do │
├─────────────────────┼──────────┼──────────────────────────────────────┤
│ Product image │ 5 MB │ JPEG 4K compress quality 85 ~3-4MB │
│ User avatar │ 2 MB │ Portrait 1080p compress đủ │
│ PDF document │ 20 MB │ Invoice/contract đa trang (future) │
│ CSV import │ 50 MB │ Bulk import catalog (future) │
│ Default (no upload) │ 2 MB │ axum core default đủ cho JSON body │
└─────────────────────┴──────────┴──────────────────────────────────────┘
Quan sát:
- Reject 413 Payload Too Large tự động axum default khi body vượt limit — client thấy ngay lỗi không phải đợi upload xong rồi mới biết fail.
- Stream-based check incremental — axum count bytes khi đọc body, dừng ngay khi vượt limit không phải đợi load full → catch sớm tiết kiệm bandwidth + memory.
- Per route limit tránh dùng global limit lớn nhất chung — vd 50MB CSV import KHÔNG nên áp dụng cho mọi endpoint (endpoint khác bị tăng surface tấn công).
- Cấu hình ngoài hard-code ở B62 — load từ
AppConfigenv varSHOP_UPLOAD_MAX_IMAGE_BYTESđể dev test với limit nhỏ hơn không phải sửa code; default 5MB lock vĩnh viễn.
Combination với RequestBodyLimitLayer global (lock B29 G15) ở app root: global cap 50MB cho mọi route bảo vệ baseline, per-route override nhỏ hơn cho endpoint không cần upload lớn. Per-route limit không phá được global (axum check global trước).
Magic Bytes Verify — KHÔNG Trust Client
Anti-pattern nghiêm trọng: trust Content-Type header client gửi cho security decision. Attacker upload file shell.php đổi Content-Type thành image/jpeg qua tool như Burp Suite → server save với extension .jpg nhưng thực ra là PHP shell. Khi web server (Apache/Nginx) serve file kèm misconfig execute .jpg qua PHP handler (or attacker tìm bypass route) → Remote Code Execution. Tương tự KHÔNG trust filename extension .jpg vì client tự đặt tên gì cũng được.
Pattern fix MANDATORY: verify magic bytes (file signature ở N bytes đầu file) — chuẩn quốc tế File Signature Database. Mỗi định dạng file có signature cố định ở bytes đầu, không thể fake mà file vẫn hoạt động được:
┌──────────┬─────────────────────────────────┬──────────────────────────┐
│ Format │ Magic bytes (hex) │ Vị trí │
├──────────┼─────────────────────────────────┼──────────────────────────┤
│ JPEG │ FF D8 FF │ 3 bytes đầu │
│ PNG │ 89 50 4E 47 0D 0A 1A 0A │ 8 bytes đầu │
│ GIF │ 47 49 46 38 │ 4 bytes đầu (GIF8) │
│ WebP │ 52 49 46 46 ?? ?? ?? ?? 57 45 │ "RIFF...WEBP" offset 0+8 │
│ │ 42 50 │ │
│ PDF │ 25 50 44 46 │ 4 bytes đầu (%PDF) │
│ ZIP │ 50 4B 03 04 │ 4 bytes đầu (PK) │
│ MP4 │ 00 00 00 ?? 66 74 79 70 │ offset 4 (ftyp) │
└──────────┴─────────────────────────────────┴──────────────────────────┘
Crate infer 0.16+ detect MIME từ bytes tự động, hỗ trợ 100+ format. Pattern verify trong Shop API:
// File: crates/shop-api/src/upload/verify.rs (B62 tạo)
use shop_common::error::AppError;
/// Verify magic bytes match accepted image formats.
/// Returns extension string (jpg/png/webp) để build filename safe.
pub fn verify_image_magic_bytes(bytes: &[u8]) -> Result<&'static str, AppError> {
let kind = infer::get(bytes).ok_or_else(|| {
AppError::BadRequest("unknown file type".to_string())
})?;
match kind.mime_type() {
"image/jpeg" => Ok("jpg"),
"image/png" => Ok("png"),
"image/webp" => Ok("webp"),
other => Err(AppError::BadRequest(format!(
"unsupported file type: {}",
other
))),
}
}
Shop API accept list lock vĩnh viễn cho product image:
- JPEG (
image/jpeg) — phổ biến nhất, compress lossy hiệu quả cho ảnh photo. - PNG (
image/png) — lossless cho ảnh có transparency hoặc UI element. - WebP (
image/webp) — modern format compress tốt hơn JPEG 25-35%, support browser 95%+ 2026.
CẤM list lock vĩnh viễn:
- GIF — animation overhead + size lớn quá so với static image; nếu cần animation dùng video MP4 hoặc WebM optimized hơn nhiều.
- SVG — XSS risk nghiêm trọng vì SVG cho phép inline
<script>tag và event handleronclick; khi serve cùng origin (cdn.shop.com) hoặc inline render, attacker upload SVG có JS đánh cắp cookie / phishing UI. Nếu cần icon vector dùngicon setsprite preload từ designer team, KHÔNG accept SVG upload runtime. - Mọi format khác — exe, sh, php, html, zip, pdf — reject 400 Bad Request.
Workspace dependencies preview (B62 add khi implement):
# File: Cargo.toml workspace root (B62 add)
[workspace.dependencies]
infer = "0.16"
uuid = { version = "1", features = ["v4"] }
# aws-sdk-s3 hoặc object_store (TBD B62)
Path Traversal — Safe Filename Handling
Anti-pattern nghiêm trọng: dùng filename client gửi qua field.file_name() trực tiếp save filesystem. Attacker gửi filename="../../../etc/passwd" hoặc filename="C:\Windows\System32\evil.dll" — nếu code naive concat upload_dir.join(filename) thì path resolve thành /etc/passwd hoặc system folder, overwrite file hệ thống → RCE / privilege escalation.
Ngay cả filename "bình thường" như photo.jpg vẫn rủi ro: 2 user upload cùng tên → collision overwrite; filename chứa ký tự đặc biệt (; & |) có thể command injection nếu code shell-out xử lý sau.
Pattern fix MANDATORY 2 lớp:
- CẤM dùng
field.file_name()client trực tiếp cho filesystem path — chỉ log/audit value gốc để truy vết. - Generate UUID v4 server-side cho filename qua crate
uuid 1featurev4— entropy 122 bit collision negligible cross-instance. - Extension từ magic bytes verify (bước 5) — KHÔNG từ client filename extension.
// File: crates/shop-api/src/upload/save.rs (B62 tạo)
use std::path::PathBuf;
use uuid::Uuid;
use bytes::Bytes;
use shop_common::error::AppResult;
use crate::upload::verify::verify_image_magic_bytes;
/// Build safe filename + path. Verify magic bytes trước.
/// Return tuple (filename string, full safe path).
pub fn build_safe_path(
upload_dir: &PathBuf,
bytes: &Bytes,
) -> AppResult<(String, PathBuf)> {
let ext = verify_image_magic_bytes(bytes)?; // magic bytes, KHÔNG client
let filename = format!("{}.{}", Uuid::new_v4(), ext);
let safe_path = upload_dir.join(&filename);
// safe_path KHÔNG bao giờ chứa `..` hoặc path control character
// vì UUID v4 chỉ chứa hex + dash, ext là hardcoded whitelist (jpg/png/webp)
Ok((filename, safe_path))
}
Quan sát chi tiết:
- UUID v4 random 122 bit dạng
550e8400-e29b-41d4-a716-446655440000— chỉ chứa hex digit + dash, không có path separator (/,\) hay... - Extension whitelist hardcoded
jpg|png|webptrả từverify_image_magic_bytes— KHÔNG bao giờ là arbitrary string client. upload_dir.join(filename)safe vì cả 2 component đều known-good — RustPathBuf::joinkhông expand..nhưng filename không chứa..anyway.- Audit trail — vẫn lưu filename gốc client (
field.file_name()) vào columnoriginal_filenameDB cho truy vết, KHÔNG dùng cho filesystem path.
Workspace dependencies preview (B62 add):
[workspace.dependencies]
uuid = { version = "1", features = ["v4"] }
Tham khảo OWASP — Unrestricted File Upload nêu rõ 4 vector tấn công file upload: (1) magic bytes spoof Content-Type, (2) filename traversal, (3) double extension shell.php.jpg, (4) size DoS. 3 vector đầu mitigation qua bước 5 + 6 ở bài này, vector 4 qua bước 4 file size limit.
Save File: Disk Local Hoặc S3
Sau khi verify magic bytes + build safe path, save file vào đâu? 2 lựa chọn theo môi trường:
Disk local — chỉ dev/local test:
// Dev only — KHÔNG dùng production
use tokio::fs;
async fn save_to_disk(
safe_path: &std::path::Path,
bytes: &bytes::Bytes,
) -> shop_common::error::AppResult<()> {
fs::write(safe_path, bytes).await?;
Ok(())
}
Lý do KHÔNG dùng disk local production:
- Không scale horizontal — file save pod A, pod B serve request đọc không có file → 404 lung tung.
- Mất file khi restart/redeploy — container ephemeral, volume mount không phải standard practice cho user content.
- Không CDN coverage — user fetch image cross-region latency 200ms+ thay vì 30ms qua CDN edge.
- Bandwidth app server — mỗi GET image hit app server cạnh tranh resource với handler API business logic.
- Backup phức tạp — phải backup riêng filesystem ngoài database.
S3 / Cloudflare R2 / Bunny CDN — production lock B27 CDN strategy:
// File: crates/shop-api/src/upload/storage.rs (B62 implement)
// Pseudo-code preview — B62 implement đầy đủ với aws-sdk-s3 setup
use bytes::Bytes;
use shop_common::error::AppResult;
pub struct S3Storage {
client: aws_sdk_s3::Client,
bucket: String,
cdn_base_url: String,
}
impl S3Storage {
pub async fn upload(
&self,
filename: &str,
bytes: Bytes,
content_type: &str,
) -> AppResult<String> {
self.client
.put_object()
.bucket(&self.bucket)
.key(format!("products/{}", filename))
.body(bytes.into())
.content_type(content_type)
.send()
.await?;
Ok(format!("{}/products/{}", self.cdn_base_url, filename))
}
}
Pattern Shop API CDN strategy lock B27:
- Upload → S3 bucket theo env:
shop-uploads-dev,shop-uploads-staging,shop-uploads-prod. - CloudFront / Cloudflare CDN front bucket — phục vụ user qua URL
https://cdn.shop.com/products/{uuid}.jpg. - Database lưu URL CDN trong table
product_images(columnurl: TEXT), KHÔNG lưu bytes vào PostgreSQL (bytea query chậm + backup khổng lồ + transfer cost cao). - Cache header CDN:
Cache-Control: public, max-age=31536000, immutablevì filename UUID unique không bao giờ đổi nội dung (cache busting tự nhiên qua URL khác khi upload mới).
Crate lựa chọn (TBD lock B62):
aws-sdk-s3— official AWS SDK cho Rust, support full S3 feature (multipart upload, presigned URL, server-side encryption); dependency lớn ~30 crates transitive.object_store— multi-backend abstraction (S3/Azure Blob/GCS/local), interface đồng nhất swap backend không sửa code; dependency nhỏ hơn, lock Apache Arrow ecosystem.rusty-s3— minimal S3 client signed-only, không async runtime lock-in, lightweight cho microservice.
Shop API lean về object_store để swap S3 dev/MinIO ↔ Cloudflare R2 prod không sửa code (R2 S3-compatible nhưng có quirk), lock cuối ở B62 khi implement.
Pattern Shop API: Admin Upload Product Image
Ghép 4 bước (extract → size limit → magic bytes verify → UUID filename → S3) thành endpoint admin upload product image — preview pattern B62 implement:
Endpoint lock: POST /api/v1/admin/products/:slug/images với RequireRole<"admin"> (B135 implement role-based access control).
Flow 7 bước:
- Auth verify (B112) + role check admin (B135) qua extractor
RequireRole<"admin">. - Multipart extract:
imagefile field + optionalalt_texttext field. - Size limit check 5MB qua
DefaultBodyLimit::max(5 * 1024 * 1024)route layer. - Magic bytes verify jpeg/png/webp qua
infer::get. - Generate UUID v4 filename.
- Upload S3 qua
aws-sdk-s3/object_store. - INSERT vào table
product_images(B62 lock schema) + returnJson<ImageDto>201.
Code skeleton preview (B62 implement đầy đủ):
// File: crates/shop-api/src/routes/admin/products.rs (B62 implement)
use axum::extract::{Multipart, State};
use axum::Json;
use uuid::Uuid;
use crate::extractors::AppPath;
use crate::upload::{verify::verify_image_magic_bytes, storage::S3Storage};
use crate::AppState;
use shop_common::error::{AppError, AppResult};
#[derive(serde::Serialize)]
pub struct ImageDto {
pub id: Uuid,
pub url: String,
pub alt_text: Option<String>,
}
pub async fn upload_product_image(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
// _: RequireRole<"admin">, // B135 implement
mut multipart: Multipart,
) -> AppResult<Json<ImageDto>> {
let mut image_bytes: Option<bytes::Bytes> = None;
let mut alt_text: Option<String> = None;
while let Some(field) = multipart.next_field().await? {
match field.name().unwrap_or("") {
"image" => {
image_bytes = Some(field.bytes().await?);
}
"alt_text" => {
alt_text = Some(field.text().await?);
}
_ => continue,
}
}
let bytes = image_bytes.ok_or_else(|| {
AppError::BadRequest("image field missing".to_string())
})?;
// Step 4: magic bytes verify (KHÔNG trust Content-Type/filename client)
let ext = verify_image_magic_bytes(&bytes)?;
// Step 5: UUID v4 server-side filename (KHÔNG dùng filename client)
let filename = format!("{}.{}", Uuid::new_v4(), ext);
// Step 6: upload S3 + get CDN URL
let cdn_url = state.storage.upload(
&filename,
bytes,
&format!("image/{}", ext),
).await?;
// Step 7: INSERT product_images (B62 implement repo)
let image_id = state
.product_repo
.add_image(&slug, &cdn_url, alt_text.as_deref())
.await?;
Ok(Json(ImageDto {
id: image_id,
url: cdn_url,
alt_text,
}))
}
Schema preview product_images (B62 lock đầy đủ):
CREATE TABLE product_images (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL, -- URL CDN, không lưu bytes
alt_text TEXT, -- SEO + accessibility
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
Route mount với size limit + role check:
// File: crates/shop-api/src/routes/admin/mod.rs (B62 implement)
use axum::extract::DefaultBodyLimit;
use axum::routing::post;
use axum::Router;
use crate::AppState;
pub fn admin_routes() -> Router<AppState> {
Router::new()
.route(
"/admin/products/:slug/images",
post(products::upload_product_image),
)
.layer(DefaultBodyLimit::max(5 * 1024 * 1024))
// .route_layer(RequireRole::<"admin">::new()) // B135
}
Wrapper pattern AppMultipart tương tự AppForm B35 (file path lock crates/shop-api/src/extractors/multipart.rs B62 tạo) — map MultipartRejection sang AppError envelope chuẩn:
// File: crates/shop-api/src/extractors/multipart.rs (B62 tạo - preview)
// Pattern thinner hơn AppForm vì Multipart không destructure T,
// chỉ wrap để consistent error envelope.
// Chi tiết implementation B62.
Tổng Kết
multipart/form-datavới boundary delimiter tách body thành parts (text field + file field), khác hoàn toànapplication/x-www-form-urlencodedB35 (flat key=value percent-encoded); browser tự đổi Content-Type khi form có<input type="file" />.axum::extract::Multipartextractor axum core stream từng field quamultipart.next_field().awaittrảOption<Field>; đặt CUỐI arg list theo lock B31 (consume body).field.text().awaitcho text field UTF-8 →String;field.bytes().awaitcho file field →Bytes(cẩn thận memory, bound qua body size limit).- Body size limit anti-DoS MANDATORY:
DefaultBodyLimit::max(N)per route layer; Shop API lock vĩnh viễn 5MB product image, 2MB avatar, 20MB PDF (future), 50MB CSV import (future); reject 413 Payload Too Large axum default. - Magic bytes verify MANDATORY: KHÔNG trust Content-Type/filename client gửi (attacker upload
shell.phpđổi Content-Type → RCE); dùng crateinfer 0.16+detect MIME từ N bytes đầu file (JPEGFF D8 FF, PNG89 50 4E 47 0D 0A 1A 0A, WebPRIFF...WEBP). - Path traversal prevention: generate UUID v4 server-side cho filename (CẤM dùng
field.file_name()client tránh../../../etc/passwd); extension từ magic bytes verify whitelist hardcoded (jpg/png/webp). - Shop API accept lock vĩnh viễn cho product image:
image/jpeg,image/png,image/webp. CẤM GIF (animation overhead) + CẤM SVG (XSS risk inline JS). - Save destination: disk local
tokio::fs::writeCHỈ dev; production S3/Cloudflare R2/Bunny CDN theo CDN strategy lock B27 (REST API KHÔNG tự serve product image, database lưu URL CDN KHÔNG bytes). - Pattern admin upload product image endpoint
POST /api/v1/admin/products/:slug/imagesvớiRequireRole<"admin">(B135) — flow 7 bước: auth → multipart extract → size limit 5MB → magic bytes verify → UUID filename → upload S3 → INSERTproduct_imagestable → return URL CDN (B62 implement đầy đủ). - Workspace.dependencies preview B62 add:
infer = "0.16"+uuid = { version = "1", features = ["v4"] }+aws-sdk-s3hoặcobject_store(TBD lock B62 khi implement). - B36 conceptual + preview pattern, KHÔNG tạo file thực tế ở Shop API (Workspace State KHÔNG đổi). File
crates/shop-api/src/extractors/multipart.rswrapperAppMultipart+ handler upload + workspace dep sẽ add ở B62.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
multipart/form-datakhácapplication/x-www-form-urlencodedở điểm gì? Tại sao browser tự đổi Content-Type khi form có<input type="file" />?field.text().awaitvsfield.bytes().await. Khi nào dùng cái nào? Memory implication ra sao với file lớn?- Tại sao KHÔNG trust
Content-Typeheader client cho file upload? Attack vector cụ thể là gì? Pattern verify đúng dùng crate nào? - Path traversal attack với filename
../../../etc/passwdhoạt động ra sao? 2 mitigation strategies MANDATORY là gì? Tại sao UUID v4 safe? - Shop API product image: max size lock vĩnh viễn bao nhiêu? File type accept/cấm lock là gì và tại sao? Lưu vào đâu production và lý do?
Đáp án
multipart/form-datakhácapplication/x-www-form-urlencoded: (a) Format body — urlencoded flatkey=value&key2=value2percent-encoded chỉ chứa text (RFC 1866/3986), tất cả field nối qua&; multipart chia body thành parts qua boundary delimiter ngẫu nhiên (RFC 7578), mỗi part có headerContent-Disposition: form-data; name="..."riêng + optionalContent-Type+ 1 empty line + body. (b) Encoding — urlencoded percent-encode ký tự đặc biệt (@→%40, space→%20/+) tốn 0-200% size tùy nội dung; multipart giữ binary bytes raw không encode, file 1MB body 1MB không phình. (c) File support — urlencoded KHÔNG efficient cho file binary (phải base64 encode tốn 33% size + complexity decode); multipart sinh ra chính cho file upload, body part có thể là binary bytes thẳng. (d) Streaming — urlencoded phải đọc full body trước parse (key-value chain); multipart parse từng part lần lượt qua boundary marker, phù hợp file lớn không load full memory. (e) Wire format — urlencoded ngắn gọn cho text-only; multipart verbose hơn (boundary repeat mỗi part + Content-Disposition header). Tại sao browser tự đổi Content-Type khi form có<input type="file" />: HTML spec yêu cầu khi form có<input type="file" />, browser PHẢI submit vớienctype="multipart/form-data"(defaultapplication/x-www-form-urlencodedsẽ overwrite tự động) — vì urlencoded không support binary file efficient. Browser sinh boundary string ngẫu nhiên (vd----WebKitFormBoundary7MA4YWxkTrZu0gWChrome convention) đảm bảo không trùng nội dung file (entropy đủ lớn), build body theo format multipart (text field + file field), set Content-Type headermultipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW. Dev có thể overrideenctypemanual nhưng nếu giữ default urlencoded với<input type="file" />browser KHÔNG gửi file content (chỉ gửi filename text) — silently fail upload. JSfetchvớiFormDatatự sinh multipart tương tự không cần khai báo enctype.field.text().awaitvsfield.bytes().await: (a)field.text().awaitconsume field thànhStringUTF-8 — phù hợp text field nhỏ (form text input như title, alt_text, sort_order, csrf_token); fail vớiMultipartErrornếu bytes không phải UTF-8 hợp lệ (vd binary jpeg bytes); memory tiêu thụ bằng size text typically < 1KB negligible. (b)field.bytes().awaitconsume field thànhbytes::Bytesraw binary — phù hợp file field (image, PDF, video); luôn thành công với mọi bytes không UTF-8 check; memory tiêu thụ bằng size file đầy đủ (file 5MB → 5MB RAM hold cùng lúc). Khi nào dùng cái nào: text field (input type="text"/"checkbox"/"radio"/"hidden") luônfield.text(); file field (input type="file") luônfield.bytes(). Route logic match quafield.name()rồi gọi method tương ứng. Memory implication với file lớn:field.bytes()load toàn bytes vào RAM cùng lúc → file 100MB → 100MB RAM hold trong suốt thời gian xử lý handler (verify magic bytes, upload S3); 100 request song song mỗi 100MB → 10GB RAM tiêu thụ → OOM crash server. Mitigation 3 lớp: (i) Body size limit quaDefaultBodyLimit::max(N)per route giới hạn cứng (Shop API 5MB product image, 2MB avatar) — request vượt limit reject 413 trước khi đọc body; (ii) Concurrent request limit quatower::limit::ConcurrencyLimitLayerhoặc nginx upstream max_conns; (iii) Streaming alternativefield.chunk().awaitđọc chunk ~8KB liên tục, xử lý từng chunk (vd S3 multipart upload song song không hold full bytes RAM) — phức tạp hơn, dùng khi file lớn >= 50MB; Shop API B62 dùngfield.bytes()đơn giản vì image <= 5MB acceptable hold RAM ngắn (~1 giây upload S3 xong).- Tại sao KHÔNG trust
Content-Typeheader client: client tự gửi header gì cũng được — Content-Type chỉ là claim của client, không phải sự thật về nội dung bytes. Attacker dùng tool như Burp Suite / curl-H "Content-Type: image/jpeg"để gửi file bất kỳ với header gì cũng được; server nếu trust header → false sense of security. Tương tự filename extension.jpgchỉ là tên client tự đặt, không reflect nội dung thật. Attack vector cụ thể: (a) RCE qua PHP shell upload — attacker upload fileshell.phpchứa code<?php system($_GET['cmd']); ?>, đổi Content-Type thànhimage/jpegqua Burp Suite, đổi filename thànhcute.jpg; server save file vào/uploads/cute.jpgvới extension .jpg; nhưng nếu (i) web server misconfig execute .jpg qua PHP handler (ApacheAddHandler application/x-httpd-php .jpgtypo), hoặc (ii) attacker access qua route bypass/uploads/cute.jpg/.phptùy nginx config, hoặc (iii) file include attackinclude "/uploads/" . $_GET['file']→ PHP execute shell → attacker control server full. (b) XSS qua SVG upload — attacker upload SVG có inline<script>steal_cookie()</script>, claim Content-Typeimage/svg+xml; user khác xem product page render SVG inline → JS execute trong context shop.com đánh cắp session cookie. (c) Bypass file type filter — server check Content-Type whitelist["image/jpeg", "image/png"]nhưng client gửi Content-Type giả đúng whitelist, nội dung thật là.exeWindows binary; user download "image" qua link CDN → Windows execute thay vì render → malware install. Pattern verify đúng dùng crate nào: crateinfer 0.16+detect MIME từ magic bytes (N bytes đầu file) — file signature cố định không thể fake mà file vẫn hoạt động được. Mỗi format có signature riêng: JPEGFF D8 FF3 bytes đầu, PNG89 50 4E 47 0D 0A 1A 0A8 bytes, WebP52 49 46 46 ?? ?? ?? ?? 57 45 42 50offset 0-12 với "RIFF" + size + "WEBP", PDF25 50 44 46("%PDF"), GIF47 49 46 38("GIF8"); database File Signature có 500+ format chuẩn quốc tế. Code Shop API:let kind = infer::get(&bytes).ok_or(BadRequest("unknown file type"))?; match kind.mime_type() { "image/jpeg" => Ok("jpg"), "image/png" => Ok("png"), "image/webp" => Ok("webp"), other => Err(BadRequest(format!("unsupported: {}", other))) }— return extension whitelist hardcoded từ verify result, KHÔNG từ client filename. Alternative cratefile-formathoặctree_magictương tự nhưnginferphổ biến nhất 2026 (5000+ download/tháng), zero-dep core, performance check ~1 microsecond. - Path traversal attack với
filename="../../../etc/passwd": code naive concatupload_dir.join(field.file_name())— nếuupload_dir = "/var/uploads"và filename là"../../../etc/passwd",Path::joinresolve thành/var/uploads/../../../etc/passwd; tùy filesystem behavior — Linux native resolve..thành parent (theo logical path lookup) → cuối cùng path là/etc/passwd;tokio::fs::writevới mode write → overwrite file system/etc/passwdchứa danh sách user → server không boot lại được, hoặc tệ hơn attacker tạo user backdoor. Variant tương tự Windows"C:\Windows\System32\evil.dll"absolute path, hoặc"../config/secrets.toml"overwrite app config inject malicious value. Ngay cả filename "bình thường" nhưphoto.jpgvẫn rủi ro: 2 user upload cùng tên → second overwrite first lose data; filename chứa ký tự đặc biệt (;,&,|, newline) có thể command injection nếu code shell-out post-process (vd ImageMagick convert exec); filename quá dài > 255 bytes fail filesystem create. 2 mitigation strategies MANDATORY: (i) Generate UUID v4 server-side cho filename qua crateuuid 1 v4feature —Uuid::new_v4().to_string()trả dạng550e8400-e29b-41d4-a716-446655440000chỉ chứa hex digit [0-9a-f] + dash, entropy 122 bit (collision probability negligible: cần 2.71 quintillion UUID mới có 50% collision), không có path separator (/,\) hay..hay ký tự đặc biệt — luôn filesystem-safe; (ii) Extension từ magic bytes verify (KHÔNG từ client filename) quaverify_image_magic_bytesreturn extension hardcoded whitelist"jpg" | "png" | "webp"— KHÔNG bao giờ là arbitrary string client. Final filenameformat!("{}.{}", Uuid::new_v4(), ext)dạng550e8400-e29b-41d4-a716-446655440000.jpgluôn safe. Tại sao UUID v4 safe: (a) Character set hạn chế [0-9a-f-] không bao giờ chứa path separator hay control character; (b) Entropy 122 bit attacker không guess được filename cụ thể (tránh enumeration attackGET /uploads/admin-avatar.jpgđoán URL); (c) Collision negligible cross-instance không cần coordinate (multi-pod cùng generate UUID không trùng); (d) Time-independent không leak thời gian upload (khác UUID v1/v7 có timestamp embed); (e) Audit trail riêng filename gốc client lưu vào columnoriginal_filename TEXTDB cho truy vết, KHÔNG dùng cho filesystem path. Defense in depth bổ sung: (1) chroot upload directory tách khỏi system path; (2) filesystem permission upload dir 0644 không executable; (3) S3 bucket policy denys3:PutObjectvới key chứa..regex check Lambda authorizer; (4) Content-Disposition response headerattachmentforce browser download không inline render (chống XSS HTML upload). - Shop API product image lock vĩnh viễn: (a) Max size 5MB qua
DefaultBodyLimit::max(5 * 1024 * 1024)route layer cho endpointPOST /api/v1/admin/products/:slug/images— lý do: JPEG 4K (3840x2160) compress quality 85 ~3-4MB đủ chất lượng cho catalog hiển thị desktop + mobile, 5MB cap thêm 25% buffer cho ảnh phức tạp; vượt limit reject 413 Payload Too Large axum default; ngưỡng khác lock: avatar 2MB (portrait 1080p đủ), PDF document tương lai 20MB (invoice/contract đa trang), CSV import tương lai 50MB (bulk catalog 100k row); default endpoint không upload 2MB axum core đủ cho JSON body. (b) File type accept lock vĩnh viễn:image/jpeg(phổ biến nhất, compress lossy hiệu quả cho ảnh photo, support 100% browser/OS),image/png(lossless cho ảnh có transparency hoặc UI element logo/icon, file lớn hơn JPEG nhưng quality cao),image/webp(modern format Google develop, compress 25-35% tốt hơn JPEG cùng quality, support browser 95%+ 2026 bao gồm Safari 14+). Verify qua magic bytesinfer::getmatch MIME, extension whitelist hardcodedjpg|png|webp. CẤM lock vĩnh viễn: GIF — animation overhead frame-by-frame storage tốn size 3-10x static image cùng resolution; nếu cần animation dùng videovideo/mp4(H.264 codec) hoặcvideo/webm(VP9 codec) optimized hơn nhiều, hoặc CSS animation thay; SVG — XSS risk nghiêm trọng vì SVG cho phép inline<script>tag và event handler (onclick,onload); khi serve cùng origincdn.shop.comhoặc inline<img src="...">render → attacker upload SVG có JS đánh cắp cookie (quadocument.cookie) / phishing UI (overlay form fake login); nếu cần icon vector cho UI dùng icon set sprite preload từ designer team commit vào repo, KHÔNG accept SVG upload runtime; Executable: .exe, .sh, .php, .py, .bat — reject magic bytes không match image whitelist; Archive: .zip, .tar, .rar — không có business reason upload, reject. Mọi format khác trả 400 Bad Request"unsupported file type: {detected_mime}". (c) Lưu vào đâu production theo CDN strategy lock B27: S3 / Cloudflare R2 / Bunny CDN — REST API KHÔNG tự serve product image, toàn bộ asset đẩy lên object storage quaaws-sdk-s3crate hoặcobject_storecrate multi-backend (TBD lock B62), serve qua CDN edge (CloudFront / Cloudflare CDN / Bunny CDN). Database lưu URL CDNhttps://cdn.shop.com/products/550e8400-e29b-41d4-a716-446655440000.jpgtrong columnproduct_images.url TEXT, KHÔNG lưu bytes vào PostgreSQL bytea (query chậm 10-100x text + backup khổng lồ 100GB+ image data + transfer cost cao mỗi backup transfer GB). Lý do (4 điểm CDN strategy B27): (i) App server tập trung business logic không cạnh tranh resource serve image GB — handler API focus query DB + auth + validation chứ không transfer bytes; (ii) Edge caching globally POP city user fetch image latency ~30ms thay vì cross-region origin ~200ms — UX page load fast với 20+ product image catalog; (iii) Auto scale Black Friday spike 100x CDN absorb hoàn toàn app server không thấy load — origin chỉ thấy cache miss ban đầu, sau đó CDN serve từ edge; (iv) Cost bandwidth CDN $0.01-0.04/GB rẻ hơn datacenter compute instance egress $0.09/GB AWS EC2 — tiết kiệm 70%+ bandwidth bill scale. Database lưu URL CDN còn lợi: (1) immutable URL với UUID v4 cache 1 nămCache-Control: public, max-age=31536000, immutablebrowser không re-validate dù refresh; (2) cache busting tự nhiên qua filename khác khi upload mới; (3) backup database nhỏ gọn 100MB không phải 100GB; (4) replica + restore database nhanh không hold bytes lớn. Disk localtokio::fs::writeCHỈ dev (single-pod, restart mất file, không CDN coverage); MinIO local development emulate S3 API cho dev test flow upload mà không phải mock S3 client. Crate decision TBD lock B62:aws-sdk-s3(official AWS SDK full feature) hoặcobject_store(multi-backend abstraction swap S3 ↔ R2 không sửa code, Shop API lean về vì R2 cost rẻ hơn S3 ~50% cho egress).
Bài Tiếp Theo
Bài 37: Raw Body & Bytes Extractor — chi tiết Bytes extractor toàn body raw không parse, String UTF-8 extractor, max body size limit qua DefaultBodyLimit::max(N); use case quan trọng webhook signature verify (Stripe Stripe-Signature header HMAC SHA256 với raw body bytes, GitHub X-Hub-Signature-256 tương tự) — KHÔNG thể parse JSON/Form trước verify vì serde re-serialize sẽ khác bytes gốc làm signature không khớp; pattern đọc raw bytes trước verify HMAC, parse JSON sau khi verify OK; Shop API B197 Stripe webhook implement đầy đủ với idempotency Redis check tránh replay attack.
