Danh sách bài viết

Bài 36: Multipart Upload: File + Field

Bài 36 của series Rust RESTful API — chi tiết multipart/form-data format dùng cho HTML form có file upload, khác hoàn toàn application/x-www-form-urlencoded ở B35 (urlencoded có format flat key=value&key2=value2 percent-encoded chỉ chứa text, multipart chia body thành parts qua boundary delimiter ngẫu nhiên với mỗi part có header Content-Disposition: form-data; name="..." riêng và body có thể là text UTF-8 hoặc binary bytes — phù hợp truyền file ảnh, PDF, video không phải encode toàn bộ thành base64 hay percent-encoding tốn 33-200% size); axum::extract::Multipart extractor có sẵn axum core stream từng field qua multipart.next_field().await trả Option<Field> — KHÔNG load toàn body vào memory cùng lúc tránh OOM với file lớn (client gửi 10GB file → server crash nếu load full); mỗi Field có method name() trả tên field, file_name() trả filename client gửi (Option vì text field không có), content_type() trả Content-Type client claim (Option, KHÔNG trust giá trị này cho security), text().await consume field thành String UTF-8 cho text field nhỏ, bytes().await consume field thành Bytes cho file field (cẩn thận memory — file 5MB tiêu thụ 5MB RAM cùng lúc, bound qua body size limit). File size limit anti-DoS MANDATORY — axum default body size 2MB per request từ DefaultBodyLimit, override per route qua .layer(DefaultBodyLimit::max(N)) trên Router; Shop API lock vĩnh viễn 3 ngưỡng theo loại upload: product image 5MB (đủ ảnh JPEG 4K compress quality 85), avatar 2MB (đủ portrait 1080p), PDF document tương lai 20MB (đủ invoice/contract đa trang); request vượt limit axum reject với 413 Payload Too Large body rỗng; lý do limit anti-DoS — attacker spam request với file 10GB consume bandwidth + memory + disk, không limit thì 1 attacker đánh sập server dễ dàng. Magic bytes verify MANDATORY — anti-pattern KHÔNG bao giờ trust Content-Type header client gửi vì attacker upload shell.php đổi Content-Type thành image/jpeg → server save với extension .jpg nhưng thực ra là PHP shell, web server execute lúc serve → RCE; tương tự KHÔNG trust filename extension .jpg vì client tự đặt tên gì cũng được; pattern đúng là verify file signature ở N bytes đầu file (JPEG FF D8 FF, PNG 89 50 4E 47 0D 0A 1A 0A, GIF 47 49 46 38, PDF 25 50 44 46, WebP 52 49 46 46 ... 57 45 42 50) — chuẩn quốc tế File Signature Database; crate infer 0.16+ detect MIME từ bytes tự động, Shop API accept lock vĩnh viễn jpeg/png/webp cho product image (CẤM GIF do animation overhead + size lớn quá so với static image, CẤM SVG do XSS risk inline JavaScript script tag trong SVG render được khi serve cùng origin). Path traversal prevention — anti-pattern dùng filename client gửi trực tiếp save filesystem (filename="../../../etc/passwd" → server save vào path nguy hiểm overwrite system file); pattern đúng generate UUID v4 server-side cho filename qua crate uuid 1 v4 feature (entropy 122 bit collision negligible), extension từ magic bytes verify (KHÔNG từ client filename) — safe path luôn dạng {upload_dir}/{uuid}.{ext} không bao giờ chứa .. hay path control character. Save file destination — disk local tokio::fs::write CHỈ dev (không scale horizontal, restart pod mất file, không CDN coverage); production S3/Cloudflare R2/Bunny CDN theo CDN strategy lock B27 — REST API KHÔNG tự serve product image / asset bundle, đẩy lên object storage qua aws-sdk-s3 crate hoặc object_store crate multi-backend (S3/R2/Azure Blob abstraction), database lưu URL CDN https://cdn.shop.com/products/{uuid}.jpg KHÔNG lưu bytes vào PostgreSQL (bytea column query chậm + backup khổng lồ + transfer cost cao). Pattern Shop API admin upload product image preview B62: endpoint POST /api/v1/admin/products/:slug/images với RequireRole<"admin"> (B135 implement), flow: Multipart extract image field + alt_text text field → size limit 5MB check qua DefaultBodyLimit::max(5 * 1024 * 1024) route layer → field.bytes() collect file → magic bytes verify qua infer::get(&bytes) match jpeg/png/webp → generate UUID v4 filename → upload S3 qua aws-sdk-s3 put_object → INSERT vào table product_images (id, product_id, url, alt_text, sort_order, created_at) lock schema B62 → return Json<ImageDto> { url: cdn_url } 201 Created. Workspace.dependencies sẽ add infer = "0.16" + uuid = { version = "1", features = ["v4"] } + aws-sdk-s3 (hoặc object_store TBD B62) khi B62 implement thực tế. Bài này 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.rs wrapper AppMultipart + handler upload + workspace dep sẽ add ở B62 khi implement endpoint product image admin.

14/06/2026
11 phút đọc
3 lượt xem
1

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

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

  • Hiểu multipart/form-data format và khác biệt với application/x-www-form-urlencoded ở B35 (boundary delimiter tách parts vs flat key=value percent-encoded).
  • Biết Multipart extractor axum core stream từng field qua multipart.next_field().await không load toàn body memory.
  • Nắm cách extract text field qua field.text().await và file field qua field.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 infer detect 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).
2

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/jpeg cho 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 ----abc123 ngẫ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êm filename="phone.jpg" trong Content-Disposition + Content-Type: image/jpeg riê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.

3

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 qua FromRequest (KHÔNG FromRequestParts), đặt CUỐI arg list theo lock B31 giống Form<T>/Json<T>.
  • multipart.next_field().await trả Result<Option<Field>, MultipartError>. Stream parse từng part lần lượt — None bá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().await consume field thành String UTF-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().await consume field thành bytes::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.

4

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ừ AppConfig env var SHOP_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).

5

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 handler onclick; 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ùng icon set sprite 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)
6

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 1 feature v4 — 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|webp trả 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 — Rust PathBuf::join khô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 column original_filename DB 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.

7

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 (column url: 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, immutable vì 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.

8

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:

  1. Auth verify (B112) + role check admin (B135) qua extractor RequireRole<"admin">.
  2. Multipart extract: image file field + optional alt_text text field.
  3. Size limit check 5MB qua DefaultBodyLimit::max(5 * 1024 * 1024) route layer.
  4. Magic bytes verify jpeg/png/webp qua infer::get.
  5. Generate UUID v4 filename.
  6. Upload S3 qua aws-sdk-s3 / object_store.
  7. INSERT vào table product_images (B62 lock schema) + return Json<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.
9

Tổng Kết

  • multipart/form-data với boundary delimiter tách body thành parts (text field + file field), khác hoàn toàn application/x-www-form-urlencoded B35 (flat key=value percent-encoded); browser tự đổi Content-Type khi form có <input type="file" />.
  • axum::extract::Multipart extractor axum core stream từng field qua multipart.next_field().await trả Option<Field>; đặt CUỐI arg list theo lock B31 (consume body).
  • field.text().await cho text field UTF-8 → String; field.bytes().await cho 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 crate infer 0.16+ detect MIME từ N bytes đầu file (JPEG FF D8 FF, PNG 89 50 4E 47 0D 0A 1A 0A, WebP RIFF...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::write CHỈ 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/images với RequireRole<"admin"> (B135) — flow 7 bước: auth → multipart extract → size limit 5MB → magic bytes verify → UUID filename → upload S3 → INSERT product_images table → return URL CDN (B62 implement đầy đủ).
  • Workspace.dependencies preview B62 add: infer = "0.16" + uuid = { version = "1", features = ["v4"] } + aws-sdk-s3 hoặc object_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.rs wrapper AppMultipart + handler upload + workspace dep sẽ add ở B62.
10

Bài Tập Củng Cố

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

  1. multipart/form-data khác application/x-www-form-urlencoded ở điểm gì? Tại sao browser tự đổi Content-Type khi form có <input type="file" />?
  2. field.text().await vs field.bytes().await. Khi nào dùng cái nào? Memory implication ra sao với file lớn?
  3. Tại sao KHÔNG trust Content-Type header client cho file upload? Attack vector cụ thể là gì? Pattern verify đúng dùng crate nào?
  4. Path traversal attack với filename ../../../etc/passwd hoạt động ra sao? 2 mitigation strategies MANDATORY là gì? Tại sao UUID v4 safe?
  5. 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
  1. multipart/form-data khác application/x-www-form-urlencoded: (a) Format body — urlencoded flat key=value&key2=value2 percent-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ó header Content-Disposition: form-data; name="..." riêng + optional Content-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ới enctype="multipart/form-data" (default application/x-www-form-urlencoded sẽ overwrite tự động) — vì urlencoded không support binary file efficient. Browser sinh boundary string ngẫu nhiên (vd ----WebKitFormBoundary7MA4YWxkTrZu0gW Chrome 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 header multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW. Dev có thể override enctype manual 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. JS fetch với FormData tự sinh multipart tương tự không cần khai báo enctype.
  2. field.text().await vs field.bytes().await: (a) field.text().await consume field thành String UTF-8 — phù hợp text field nhỏ (form text input như title, alt_text, sort_order, csrf_token); fail với MultipartError nế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().await consume field thành bytes::Bytes raw 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ôn field.text(); file field (input type="file") luôn field.bytes(). Route logic match qua field.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 qua DefaultBodyLimit::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 qua tower::limit::ConcurrencyLimitLayer hoặc nginx upstream max_conns; (iii) Streaming alternative field.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ùng field.bytes() đơn giản vì image <= 5MB acceptable hold RAM ngắn (~1 giây upload S3 xong).
  3. Tại sao KHÔNG trust Content-Type header 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 .jpg chỉ 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 file shell.php chứa code <?php system($_GET['cmd']); ?>, đổi Content-Type thành image/jpeg qua Burp Suite, đổi filename thành cute.jpg; server save file vào /uploads/cute.jpg với extension .jpg; nhưng nếu (i) web server misconfig execute .jpg qua PHP handler (Apache AddHandler application/x-httpd-php .jpg typo), hoặc (ii) attacker access qua route bypass /uploads/cute.jpg/.php tùy nginx config, hoặc (iii) file include attack include "/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-Type image/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à .exe Windows binary; user download "image" qua link CDN → Windows execute thay vì render → malware install. Pattern verify đúng dùng crate nào: crate infer 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: JPEG FF D8 FF 3 bytes đầu, PNG 89 50 4E 47 0D 0A 1A 0A 8 bytes, WebP 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 offset 0-12 với "RIFF" + size + "WEBP", PDF 25 50 44 46 ("%PDF"), GIF 47 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 crate file-format hoặc tree_magic tương tự nhưng infer phổ biến nhất 2026 (5000+ download/tháng), zero-dep core, performance check ~1 microsecond.
  4. Path traversal attack với filename="../../../etc/passwd": code naive concat upload_dir.join(field.file_name()) — nếu upload_dir = "/var/uploads" và filename là "../../../etc/passwd", Path::join resolve 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::write với mode write → overwrite file system /etc/passwd chứ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.jpg vẫ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 crate uuid 1 v4 feature — Uuid::new_v4().to_string() trả dạng 550e8400-e29b-41d4-a716-446655440000 chỉ 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) qua verify_image_magic_bytes return extension hardcoded whitelist "jpg" | "png" | "webp" — KHÔNG bao giờ là arbitrary string client. Final filename format!("{}.{}", Uuid::new_v4(), ext) dạng 550e8400-e29b-41d4-a716-446655440000.jpg luô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 attack GET /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 column original_filename TEXT DB 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 deny s3:PutObject với key chứa .. regex check Lambda authorizer; (4) Content-Disposition response header attachment force browser download không inline render (chống XSS HTML upload).
  5. Shop API product image lock vĩnh viễn: (a) Max size 5MB qua DefaultBodyLimit::max(5 * 1024 * 1024) route layer cho endpoint POST /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 bytes infer::get match MIME, extension whitelist hardcoded jpg|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 video video/mp4 (H.264 codec) hoặc video/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 origin cdn.shop.com hoặc inline <img src="..."> render → attacker upload SVG có JS đánh cắp cookie (qua document.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 qua aws-sdk-s3 crate hoặc object_store crate multi-backend (TBD lock B62), serve qua CDN edge (CloudFront / Cloudflare CDN / Bunny CDN). Database lưu URL CDN https://cdn.shop.com/products/550e8400-e29b-41d4-a716-446655440000.jpg trong column product_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ăm Cache-Control: public, max-age=31536000, immutable browser 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 local tokio::fs::write CHỈ 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ặc object_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).
11

Bài Tiếp Theo

— 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.