Skip to content

HARRY-14 — GĐ11 — File upload, Object Storage & Email

GĐ11 — File upload, Object Storage & Email

Study note cho FE engineer (JS/TS mạnh) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Hai mảng này bị bỏ qua trong hầu hết roadmap nhưng mọi SaaS đều cần: người dùng upload tài liệu (Capstone GĐ22 cần để làm RAG), hệ thống gửi mail verify / reset password / hoá đơn. Cả hai đều có bề mặt tấn công lớn và nhiều bẫy vận hành.


Phần A — File upload & Object Storage

1. Vì sao upload là bài toán backend khó

Định nghĩa. Upload = client gửi dữ liệu nhị phân (thường lớn) lên server, server lưu trữ bền vững và trả về định danh để truy cập lại sau.

Tại sao quan trọng. Ở FE bạn viết <input type="file"> + FormData là xong. Ở BE, cùng một request đó mở ra 5 vấn đề cùng lúc:

  • Bộ nhớ — file 500MB nạp hết vào RAM là chết process (Node mặc định heap ~1.5–4GB, và bạn có nhiều request đồng thời).
  • Bảo mật — file là code do người lạ gửi lên. Tên file, nội dung, kiểu file đều có thể là vũ khí.
  • Lưu ở đâu — đĩa server là sai (container ephemeral, không scale ngang được).
  • Thời gian — upload chậm giữ connection lâu, dễ timeout ở reverse proxy.
  • Nhất quán — file lên storage thành công nhưng DB rollback → file mồ côi, hoặc ngược lại.

Pitfall. Coi upload như một endpoint CRUD bình thường. Nó là luồng dữ liệu không tin cậy, kích thước không giới hạn, từ người lạ. Mọi quyết định thiết kế phải xuất phát từ đó.


2. multipart/form-data — cơ chế thật sự

Định nghĩa. Content type cho phép gửi nhiều "part" trong một body, mỗi part có header riêng và có thể là nhị phân.

Tại sao quan trọng. Đây là lý do express.json() không parse được upload — nó chỉ hiểu JSON. Bạn cần parser riêng, và parser đó quyết định file đi vào RAM hay đi vào stream.

Cơ chế — body thật trên dây trông như thế này:

http
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123

------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="title"

Báo cáo quý 4
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf

%PDF-1.7 ...bytes nhị phân...
------WebKitFormBoundaryABC123--

Điểm mấu chốt:

  • boundary là chuỗi phân tách do client tự chọn. Parser dò chuỗi này để cắt part.
  • filenameContent-Type của part đều do client tự khaikhông tin được (mục 4).
  • Các field text (như title) trộn lẫn với file. Nên field text luôn tới trước file trong form — nếu không, khi stream file bạn chưa biết metadata.

Pitfall. Base64-encode file rồi nhét vào JSON để "cho tiện". Base64 làm phình dữ liệu +33%, buộc toàn bộ file vào RAM, và phá vỡ mọi cơ chế stream. Chỉ dùng cho file rất nhỏ (avatar < 100KB) và biết rõ mình đang đánh đổi gì.


3. Nhận file: memory vs disk vs stream

Định nghĩa. Ba chiến lược parser xử lý byte đến:

  • Memory — gom hết vào Buffer trong RAM.
  • Disk (temp) — ghi ra file tạm, trả về đường dẫn.
  • Stream — đẩy thẳng byte sang đích (S3) khi đang nhận, không lưu trung gian.

Tại sao quan trọng. Đây là quyết định về khả năng sống sót của server. Memory với file lớn = OOM. Stream = hằng số bộ nhớ bất kể file bao to.

Ví dụ — Express + multer (memory, chỉ cho file nhỏ):

ts
import multer from 'multer';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024,   // 5MB — BẮT BUỘC, mặc định là VÔ HẠN
    files: 1,                    // chặn gửi 10.000 file trong 1 request
    fields: 10,
    fieldNameSize: 100,
  },
});

app.post('/avatar', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'file required' });
  // req.file.buffer nằm trong RAM — chấp nhận được vì đã giới hạn 5MB
  await uploadToS3(req.file.buffer, req.file.mimetype);
  res.status(201).json({ ok: true });
});

Ví dụ — stream thẳng lên S3, bộ nhớ không đổi (file lớn):

ts
import busboy from 'busboy';
import { Upload } from '@aws-sdk/lib-storage';

app.post('/documents', (req, res, next) => {
  const bb = busboy({ headers: req.headers, limits: { fileSize: 100 * 1024 * 1024 } });

  bb.on('file', async (_name, fileStream, info) => {
    const key = `docs/${crypto.randomUUID()}`;
    try {
      // lib-storage tự chia multipart part và upload song song, đọc từ stream
      await new Upload({ client: s3, params: { Bucket: BUCKET, Key: key, Body: fileStream } }).done();
      res.status(201).json({ key });
    } catch (e) { next(e); }
  });

  // PHẢI xử lý: file vượt limit thì stream bị cắt giữa chừng
  bb.on('filesLimit', () => res.status(413).json({ error: 'too many files' }));
  req.pipe(bb);
});

Ví dụ — NestJS:

ts
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async upload(@UploadedFile(new ParseFilePipe({
  validators: [
    new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
    new FileTypeValidator({ fileType: /^(image\/jpeg|image\/png)$/ }),
  ],
})) file: Express.Multer.File) { /* ... */ }

Pitfall. Quên limits.fileSize. Mặc định của multer là không giới hạn — một request duy nhất gửi file 20GB làm sập server. Và nhớ rằng reverse proxy cũng có limit riêng: Nginx mặc định client_max_body_size 1m → upload 5MB trả 413 trước khi chạm tới Node. Phải chỉnh cả hai tầng cho khớp.


4. Validate — file là dữ liệu thù địch

Định nghĩa. Kiểm chứng file trước khi lưu: kích thước, kiểu thật, tên, nội dung.

Tại sao quan trọng. Content-Typefilename do client khai. Kẻ tấn công đổi tên shell.php thành avatar.png và khai Content-Type: image/png trong 5 giây. Nếu bạn tin, bạn đang cho người lạ ghi file tuỳ ý lên hệ thống.

Cơ chế — 4 lớp kiểm tra:

(a) Magic bytes — kiểu THẬT của file. Mọi định dạng có chữ ký nhị phân ở đầu file: PNG = 89 50 4E 47, PDF = %PDF, JPEG = FF D8 FF.

ts
import { fileTypeFromBuffer } from 'file-type';

const detected = await fileTypeFromBuffer(buffer);
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);

// allowlist (liệt kê cái ĐƯỢC PHÉP), không bao giờ dùng blocklist
if (!detected || !ALLOWED.has(detected.mime)) {
  throw new BadRequestException('Unsupported file type');
}
// Đối chiếu chéo: client khai một đằng, thực tế một nẻo → hành vi đáng ngờ
if (detected.mime !== file.mimetype) logger.warn({ claimed: file.mimetype, actual: detected.mime });

(b) Tên file — không bao giờ dùng tên client gửi.

ts
// ❌ path traversal: filename = "../../../../etc/cron.d/backdoor"
const path = `./uploads/${file.originalname}`;

// ✅ tự sinh tên; giữ tên gốc CHỈ như metadata hiển thị trong DB
const ext = detected.ext;                       // từ magic bytes, không từ tên file
const key = `docs/${userId}/${crypto.randomUUID()}.${ext}`;

(c) Kích thước — kiểm ở 3 tầng. Reverse proxy (client_max_body_size) → parser (limits.fileSize) → và với presigned URL thì thêm policy phía S3 (mục 6). Không tin Content-Length client khai.

(d) Nội dung nguy hiểm dù đúng định dạng.

  • SVG là XML → chứa được <script>stored XSS khi ai đó mở. Coi SVG như HTML: hoặc cấm, hoặc sanitize (DOMPurify), hoặc chỉ phục vụ từ domain khác + Content-Disposition: attachment.
  • ZIPzip bomb (file 42KB giải nén thành 4.5PB) và zip slip (entry tên ../../). Nếu giải nén, phải giới hạn tổng dung lượng và số entry, và chuẩn hoá mọi đường dẫn.
  • Office/PDF có macro và JS nhúng. Nếu người dùng khác tải về được, cần quét virus (ClamAV, hoặc dịch vụ của cloud).

Pitfall. Phục vụ file người dùng upload từ cùng domain với app. Một file HTML/SVG upload lên sẽ chạy JS trong origin của bạn, đọc được cookie session → chiếm tài khoản. Luôn phục vụ user content từ domain riêng (usercontent-abc.com, hoặc R2/S3 domain) và set Content-Disposition: attachment + X-Content-Type-Options: nosniff.


5. Lưu ở đâu — object storage vs filesystem vs DB

Định nghĩa. Object storage (S3, Cloudflare R2, GCS) = kho key→blob qua HTTP, dung lượng gần như vô hạn, có phiên bản, lifecycle, CDN.

Tại sao quan trọng. Đây là quyết định kiến trúc, không phải sở thích.

Nơi lưuƯuNhượcKết luận
Đĩa serverĐơn giản nhấtContainer restart là mất sạch; chạy 2 instance thì instance B không thấy file của A; backup thủ côngChỉ dùng cho file tạm trong 1 request
Database (BYTEA/BLOB)Có transaction, backup chungPhình DB, backup/restore chậm khủng khiếp, tốn RAM cache của DB cho dữ liệu không truy vấn đượcChỉ khi file rất nhỏ (<100KB) và cần tính nguyên tử tuyệt đối
Object storageRẻ, vô hạn, CDN sẵn, scale ngang, có lifecycleKhông có transaction với DB (mục 10)Mặc định. Chọn cái này.

Cơ chế S3 tối thiểu cần nắm: bucket (thùng chứa) → key (đường dẫn phẳng, dấu / chỉ là quy ước hiển thị) → object (bytes + metadata). Bucket mặc định private; truy cập bằng credential hoặc presigned URL.

Ví dụ — client S3 dùng chung được cho R2:

ts
import { S3Client } from '@aws-sdk/client-s3';

// Cloudflare R2 tương thích API S3 → cùng SDK, chỉ khác endpoint.
// R2 không tính phí egress → rẻ hơn hẳn S3 cho ứng dụng nhiều lượt tải.
export const s3 = new S3Client({
  region: 'auto',
  endpoint: process.env.S3_ENDPOINT,           // bỏ dòng này nếu dùng AWS S3 thật
  credentials: {
    accessKeyId: env.S3_ACCESS_KEY_ID,
    secretAccessKey: env.S3_SECRET_ACCESS_KEY,
  },
});

Thiết kế key — quan trọng hơn vẻ ngoài:

tenants/{tenantId}/documents/{uuid}.pdf     # tiền tố tenant → phân quyền + xoá theo tenant dễ

Đừng để tên file người dùng, đừng để thông tin nhạy cảm trong key (key hay lọt vào log, Referer).

Pitfall. Bật public read cho cả bucket vì "cho tiện". Toàn bộ tài liệu của mọi khách hàng chỉ cách nhau một lần đoán key — và bot quét bucket công khai chạy 24/7. Bucket luôn private; truy cập qua presigned URL (mục 7).


6. Presigned URL — cho client upload thẳng lên storage

Định nghĩa. URL có chữ ký, hết hạn theo thời gian, cho phép người cầm nó thực hiện đúng một thao tác (PUT hoặc GET) lên đúng một key, mà không cần credential.

Tại sao quan trọng. Với luồng "client → server → S3", mọi byte đi xuyên qua server bạn: tốn băng thông, chiếm worker, giữ connection lâu, giới hạn bởi request timeout của PaaS (nhiều nơi 30–100s). Presigned URL cho phép client bắn thẳng lên S3, server chỉ ký giấy phép — nhanh, rẻ, scale vô hạn.

Cơ chế — luồng 3 bước:

1. Client → API:  "tôi muốn upload report.pdf, 4MB, application/pdf"
   API: kiểm auth + quota + kiểu file + size → tạo bản ghi DB (status=PENDING)
        → ký URL → trả { uploadUrl, key, fileId }
2. Client → S3:   PUT {uploadUrl}  (body là file, không qua server bạn)
3. Client → API:  "xong rồi, fileId=..."
   API: HeadObject xác minh file CÓ THẬT + đúng size → status=READY

Ví dụ — bước 1 và 3:

ts
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';

// --- Bước 1: cấp phép ---
async function createUploadUrl(userId: string, input: { filename: string; size: number; mime: string }) {
  if (!ALLOWED.has(input.mime)) throw new BadRequestException('Unsupported type');
  if (input.size > 100 * 1024 * 1024) throw new BadRequestException('Too large');
  await assertQuota(userId, input.size);

  const key = `tenants/${userId}/docs/${crypto.randomUUID()}`;
  const file = await db.file.create({
    data: { key, userId, originalName: input.filename, size: input.size, mime: input.mime, status: 'PENDING' },
  });

  const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: input.mime,      // client PHẢI gửi đúng header này, nếu không chữ ký sai
    ContentLength: input.size,    // ghim size vào chữ ký → không upload file to hơn được
  }), { expiresIn: 300 });        // 5 phút — đủ upload, đủ ngắn nếu URL bị lộ

  return { uploadUrl, fileId: file.id };
}

// --- Bước 3: xác minh, KHÔNG tin lời client ---
async function confirmUpload(userId: string, fileId: string) {
  const file = await db.file.findFirst({ where: { id: fileId, userId } });  // luôn lọc theo owner
  if (!file) throw new NotFoundException();

  // Client có thể gọi confirm mà chưa upload gì. Phải tự kiểm chứng với S3.
  const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: file.key }));
  if (head.ContentLength !== file.size) throw new BadRequestException('Size mismatch');

  await db.file.update({ where: { id: fileId }, data: { status: 'READY' } });
  await scanQueue.add('scan-and-extract', { fileId });   // xử lý nặng đẩy sang queue (mục 9)
}

Pitfall.

  • Không ghim ContentLength/ContentType vào chữ ký → client xin ký cho file 1MB rồi upload 10GB. Phải đưa vào lệnh ký, hoặc dùng presigned POST policy (cho phép khai content-length-range linh hoạt hơn).
  • Tin bước 3 mà không HeadObject → DB đầy bản ghi READY trỏ tới object không tồn tại.
  • expiresIn quá dài (7 ngày) → URL lọt vào log/lịch sử chat là ai cũng ghi đè được key đó.
  • Quên CORS trên bucket → trình duyệt chặn PUT từ origin của bạn. Đây là lỗi đầu tiên ai cũng gặp; cấu hình CORS rule ở phía S3/R2, không phải ở server.

7. Cho tải xuống — file private

Định nghĩa. Ngược lại của mục 6: presigned GET URL, hết hạn ngắn, cấp sau khi đã kiểm tra quyền.

Cơ chế — và vì sao không stream qua server:

ts
// ❌ Cách tốn kém: mọi byte đi qua server bạn, chiếm connection suốt thời gian tải
const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
obj.Body.pipe(res);

// ✅ Kiểm quyền → ký URL ngắn hạn → redirect. Server chỉ tốn vài ms.
app.get('/files/:id/download', async (req, res) => {
  const file = await db.file.findFirst({ where: { id: req.params.id, userId: req.user.id } });
  if (!file) return res.sendStatus(404);          // 404 chứ không 403 — đừng lộ sự tồn tại

  const url = await getSignedUrl(s3, new GetObjectCommand({
    Bucket, Key: file.key,
    // ép trình duyệt tải về với tên gốc, thay vì render inline (chống XSS qua HTML/SVG)
    ResponseContentDisposition: `attachment; filename="${encodeURIComponent(file.originalName)}"`,
  }), { expiresIn: 60 });

  res.redirect(302, url);
});

Pitfall. Trả presigned URL trong list API (100 file = 100 URL ký sẵn, phần lớn không dùng, và lộ hết nếu response bị log). Chỉ ký khi người dùng thực sự bấm tải.


8. File lớn — multipart upload & resumable

Định nghĩa. S3 multipart upload: chia file thành part (tối thiểu 5MB, trừ part cuối), upload song song, rồi gọi CompleteMultipartUpload để ghép.

Tại sao quan trọng. Upload một mạch 5GB qua mạng di động gần như chắc chắn đứt giữa chừng — và bạn phải làm lại từ đầu. Multipart cho phép retry từng part, và upload song song nhanh hơn nhiều.

Cơ chế. CreateMultipartUpload → nhận uploadId → ký presigned URL cho từng part → client PUT từng part, nhận ETag → gửi danh sách {PartNumber, ETag} về server → CompleteMultipartUpload.

Pitfall vận hành. Upload dở dang vẫn tính tiền lưu trữ nhưng không hiện trong list object — hoá đơn phình lên mà không hiểu vì sao. Bắt buộc bật lifecycle rule AbortIncompleteMultipartUpload sau 7 ngày. Đây là lỗi tốn tiền âm thầm phổ biến nhất với S3.


9. Xử lý sau upload — luôn bất đồng bộ

Định nghĩa. Việc nặng sau khi file lên: resize ảnh, trích text từ PDF, chunk + embed cho RAG (GĐ20), quét virus, transcode video.

Tại sao quan trọng. Trích text một PDF 200 trang mất 30 giây. Làm trong request handler = timeout, và CPU-bound làm nghẽn event loop của toàn bộ server (GĐ2).

Cơ chế. Upload xong → enqueue BullMQ job (GĐ8) → trả 202 Accepted + status: PROCESSING → worker xử lý → cập nhật status: READY → thông báo (WebSocket/polling).

Ví dụ — resize ảnh trong worker:

ts
import sharp from 'sharp';

worker.process('process-image', async (job) => {
  const file = await db.file.findUniqueOrThrow({ where: { id: job.data.fileId } });
  const original = await getObjectBuffer(file.key);

  // sharp CHỐNG được ảnh độc: giới hạn pixel, không tin metadata
  const resized = await sharp(original, { limitInputPixels: 50_000_000 })
    .rotate()                          // áp dụng EXIF orientation rồi XOÁ EXIF
    .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
    .webp({ quality: 82 })
    .toBuffer();

  await putObject(`${file.key}-thumb.webp`, resized, 'image/webp');
  await db.file.update({ where: { id: file.id }, data: { status: 'READY' } });
});

Pitfall.

  • EXIF chứa GPS. Ảnh chụp từ điện thoại có toạ độ chính xác nhà người dùng. Public ảnh mà không strip metadata = rò rỉ vị trí. sharp mặc định bỏ metadata khi xử lý — nhưng nếu bạn lưu file gốc và phục vụ trực tiếp thì không.
  • Decompression bomb: ảnh PNG 10KB khai kích thước 50.000×50.000 → giải nén ra ~10GB RAM → OOM. limitInputPixels chặn việc này.

10. File ↔ DB: bài toán nhất quán

Định nghĩa. Object storage không tham gia transaction của Postgres. Hai hệ thống lưu trữ độc lập → luôn có khe hở.

Tại sao quan trọng. Hai chiều hỏng:

  • Orphan file: upload S3 xong, db.create lỗi → file nằm đó vĩnh viễn, tốn tiền, không ai biết.
  • Broken record: DB có bản ghi, S3 không có object → app crash khi mở.

Cơ chế — hai nguyên tắc:

(a) DB là nguồn sự thật, ghi DB trước. Bản ghi PENDING tạo trước khi ký URL (mục 6). Orphan trong DB thì vô hại và dễ dọn; orphan trong S3 thì vô hình.

(b) Xoá thì làm ngược lại, và làm mềm.

ts
// Xoá S3 trước rồi mới xoá DB là SAI: nếu xoá DB lỗi → user thấy file nhưng mở ra 404.
await db.file.update({ where: { id }, data: { status: 'DELETED', deletedAt: new Date() } });
await deleteQueue.add('purge-object', { key: file.key }, { delay: 7 * 24 * 3600_000 });
// Hoãn 7 ngày → còn cứu được nếu người dùng xoá nhầm; job có retry nếu S3 lỗi.

(c) Job dọn định kỳ (cron BullMQ, hàng đêm):

  • Object PENDING quá 24h mà chưa READY → xoá object + bản ghi.
  • Đối soát: liệt kê key trên S3, so với DB → báo cáo lệch (đừng xoá tự động ngay, hãy alert trước).

Pitfall. Xoá bản ghi DB bằng DELETE cứng mà quên xoá object → chi phí storage tăng đều mỗi tháng và không ai lần ra được nguyên nhân, vì DB không còn dấu vết gì về những key đó.


Phần B — Email

11. Transactional vs marketing — và vì sao phải tách

Định nghĩa.

  • Transactional — gửi cho một người do hành động của họ: verify email, reset password, hoá đơn, cảnh báo hết quota.
  • Marketing/bulk — gửi cho nhiều người theo lịch của bạn: newsletter, khuyến mãi.

Tại sao quan trọng. Đây không phải phân loại hình thức mà là quyết định hạ tầng:

  • Pháp lý khác nhau: marketing cần opt-in + link unsubscribe (GDPR, CAN-SPAM). Transactional thì không.
  • Reputation lây chéo: một chiến dịch marketing bị nhiều người bấm "spam" sẽ kéo tụt uy tín của IP/domain đang gửi. Nếu dùng chung, email reset password cũng rơi vào spam → người dùng không đăng nhập được → mất khách hàng thật.

Cơ chế. Tách subdomain gửi: mail.myapp.com cho transactional, news.myapp.com cho marketing. Hai domain có reputation độc lập. Không bao giờ gửi từ domain gốc (myapp.com) — hỏng reputation là ảnh hưởng cả email công ty.

Pitfall. Nhét link "đăng ký nhận tin" hay banner khuyến mãi vào email hoá đơn → email đó thành marketing về mặt phân loại của bộ lọc spam, và về mặt pháp lý.


12. SMTP vs API provider

Định nghĩa. SMTP là giao thức gốc để gửi mail. Email API (Resend, Postmark, SendGrid, SES) là HTTP API bọc ngoài, kèm dịch vụ vận hành.

Cơ chế & lựa chọn:

SMTP thuần (tự dựng)Email API provider
DeliverabilityBạn tự lo IP reputationProvider lo, có IP pool đã warm
Bounce/complaintTự parse mail trả vềWebhook có cấu trúc
Chi phí"Rẻ" nhưng tốn thời gian vận hànhVài chục nghìn email đầu thường free
Cổng 25Hầu hết cloud chặn cổng 25 outboundKhông liên quan

Kết luận: dùng provider. Tự dựng mail server để gửi mail sản phẩm là quyết định gần như luôn sai — deliverability là bài toán reputation nhiều năm, không phải bài toán kỹ thuật.

Ví dụ — abstraction để không khoá cứng vào một nhà cung cấp:

ts
export interface EmailPort {
  send(msg: {
    to: string; subject: string; html: string; text: string;
    idempotencyKey?: string; replyTo?: string;
  }): Promise<{ messageId: string }>;
}

export class ResendAdapter implements EmailPort { /* ... */ }
export class SmtpAdapter implements EmailPort { /* nodemailer — dùng cho local/dev */ }

Pitfall dev. Dùng API thật ở môi trường dev → gửi mail nhầm cho email thật của khách hàng khi test với dữ liệu sao chép từ prod. Ở dev dùng Mailpit/MailHog (SMTP giả, có UI xem mail trong trình duyệt) hoặc sandbox mode của provider. Và trên staging, luôn có allowlist domain người nhận.


13. Deliverability — SPF, DKIM, DMARC

Định nghĩa. Ba bản ghi DNS chứng minh bạn có quyền gửi mail thay mặt domain.

  • SPF — liệt kê server/IP nào được phép gửi cho domain này.
  • DKIM — chữ ký số trên nội dung mail; người nhận lấy public key từ DNS để xác minh mail không bị sửa và đúng nguồn.
  • DMARC — chính sách: nếu SPF/DKIM fail thì làm gì (none = kệ, quarantine = vào spam, reject = từ chối), và gửi báo cáo về đâu.

Tại sao quan trọng. Không có ba bản ghi này, mail của bạn vào thẳng spam ở Gmail/Outlook. Từ 2024, Gmail và Yahoo bắt buộc SPF + DKIM + DMARC với người gửi số lượng lớn. Đây không còn là "nên có".

Ví dụ — DNS records:

dns
; SPF — chỉ MỘT bản ghi TXT SPF cho mỗi domain (nhiều bản ghi = fail toàn bộ)
mail.myapp.com.   TXT  "v=spf1 include:_spf.resend.com ~all"

; DKIM — provider cấp, mỗi provider một selector
resend._domainkey.mail.myapp.com.  TXT  "v=DKIM1; k=rsa; p=MIGfMA0GCS..."

; DMARC — bắt đầu p=none để quan sát, siết dần sau khi đọc báo cáo
_dmarc.myapp.com. TXT  "v=DMARC1; p=none; rua=mailto:dmarc@myapp.com; pct=100"

Cơ chế "alignment" — chỗ hay sai. DMARC yêu cầu domain trong From: khớp với domain đã xác thực SPF/DKIM. Gửi From: no-reply@myapp.com nhưng chỉ ký DKIM cho mail.myapp.com → DKIM pass nhưng DMARC fail vì không aligned. Phải khớp domain (hoặc subdomain, tuỳ chế độ relaxed/strict).

Thực hành vận hành:

  • Bắt đầu p=none, đọc báo cáo rua vài tuần, xác nhận không có luồng mail hợp lệ nào bị fail → mới nâng lên quarantine rồi reject.
  • Domain mới cần warm-up: tăng dần lượng gửi trong 2–4 tuần. Bắn 100.000 mail từ domain mới toanh trong ngày đầu = bị chặn ngay.
  • Theo dõi bounce rate (<2%) và complaint rate (<0.1%). Vượt ngưỡng là provider sẽ khoá tài khoản.

Pitfall. Dùng From: user@gmail.com (email của người dùng) để gửi thay họ → SPF/DKIM của Gmail fail vì bạn không phải Gmail → DMARC reject của Gmail chặn thẳng. Cách đúng: From: notifications@mail.myapp.com + Reply-To: user@gmail.com.


14. Gửi email đúng cách trong hệ thống

Định nghĩa. Email là I/O ra ngoài, chậm, có thể lỗi — phải xử lý như mọi external call: async, retry, idempotent.

Ví dụ — sai và đúng:

ts
// ❌ SAI trên 3 phương diện
app.post('/signup', async (req, res) => {
  const user = await db.user.create({ data });
  await sendVerificationEmail(user.email);   // (1) chậm 200–2000ms, giữ request
                                             // (2) provider lỗi → user KHÔNG được tạo dù DB đã ghi
  res.status(201).json(user);                // (3) không retry được
});

// ✅ ĐÚNG: tách khỏi vòng đời request, có retry, không mất
app.post('/signup', async (req, res) => {
  const user = await db.$transaction(async (tx) => {
    const u = await tx.user.create({ data });
    // Outbox (GĐ8): ghi ý định gửi mail TRONG CÙNG transaction.
    // Rollback thì cũng không có mail nào được gửi.
    await tx.outbox.create({ data: { type: 'SEND_VERIFY_EMAIL', payload: { userId: u.id } } });
    return u;
  });
  res.status(201).json(user);
});
// Outbox relay đọc bản ghi đã commit → đẩy vào BullMQ → worker gửi mail, tự retry

Idempotency — chống gửi trùng:

ts
await email.send({
  to: user.email,
  subject: 'Xác thực tài khoản',
  html, text,
  // Job retry (do timeout mạng dù provider đã nhận) sẽ KHÔNG gửi lần hai.
  // Đa số provider dedupe theo key này trong 24h.
  idempotencyKey: `verify:${user.id}:${verifyToken}`,
});

Bounce & complaint webhook — bắt buộc:

ts
// Provider gọi về khi mail bật lại (bounce) hoặc bị bấm "spam" (complaint)
app.post('/webhooks/email', verifyWebhookSignature, async (req, res) => {
  const { type, email } = req.body;
  if (type === 'email.bounced.hard' || type === 'email.complained') {
    // Suppression list: KHÔNG BAO GIỜ gửi lại địa chỉ này.
    // Tiếp tục gửi cho hard bounce = tự huỷ reputation.
    await db.emailSuppression.upsert({ where: { email }, create: { email, reason: type }, update: {} });
  }
  res.sendStatus(200);
});

Và kiểm tra suppression list trước mỗi lần gửi.

Nội dung mail — luôn kèm bản text:

ts
// HTML-only bị nhiều bộ lọc chấm điểm spam cao hơn; và một số client chỉ đọc text.
await email.send({ to, subject, html: render(tpl, data), text: htmlToText(html) });

Pitfall bảo mật — 2 cái phải nhớ:

  1. User enumeration qua reset password. Trả "Email không tồn tại" cho phép kẻ tấn công dò xem ai có tài khoản. Luôn trả cùng một thông báo ("Nếu email tồn tại, chúng tôi đã gửi hướng dẫn") và mất cùng khoảng thời gian trong cả hai trường hợp.

  2. Header injection. Nhét dữ liệu người dùng thẳng vào subject/header:

ts
// ❌ input = "Hi\r\nBcc: victim@x.com" → thêm được người nhận
subject: `Chào ${req.body.name}`
// ✅ strip \r \n khỏi mọi giá trị đi vào header; nội dung động chỉ nằm trong BODY
subject: `Chào ${sanitizeHeader(req.body.name)}`

Và escape HTML trong body — email cũng dính XSS (một số webmail render HTML).

Token trong link email:

ts
// Link verify/reset phải: dùng token ngẫu nhiên đủ mạnh, HASH trước khi lưu DB
// (rò rỉ DB = kẻ tấn công reset được mọi tài khoản), hết hạn ngắn (15–60 phút),
// dùng MỘT LẦN rồi vô hiệu.
const raw = crypto.randomBytes(32).toString('base64url');
await db.passwordReset.create({
  data: { userId, tokenHash: sha256(raw), expiresAt: addMinutes(new Date(), 30) },
});
const link = `${env.APP_URL}/reset?token=${raw}`;   // chỉ bản raw đi vào mail

Thực hành

Nâng cấp Dự án 3 (GĐ6):

A — Upload:

  1. Endpoint POST /files/upload-url cấp presigned PUT (kiểm auth + quota + mime + size), tạo bản ghi PENDING.
  2. Endpoint POST /files/:id/confirmHeadObject xác minh → READY → enqueue job.
  3. Worker: validate magic bytes từ object đã upload (không tin mime lúc xin URL), resize ảnh bằng sharp, strip EXIF.
  4. GET /files/:id/download → kiểm quyền → presigned GET 60s + Content-Disposition: attachment → 302.
  5. Xoá mềm + job purge hoãn 7 ngày. Cron dọn PENDING quá 24h.
  6. Bật lifecycle AbortIncompleteMultipartUpload trên bucket.
  7. Tự tấn công: đổi tên test.htmltest.png, gửi lên → xác nhận bị chặn ở magic bytes. Thử filename="../../etc/passwd" → xác nhận key sinh ra vẫn an toàn.

B — Email:

  1. EmailPort + 2 adapter (Resend cho prod, Mailpit SMTP cho dev qua Docker Compose).
  2. Luồng verify email: outbox → BullMQ → gửi, có idempotencyKey, token hash + hết hạn 30 phút + dùng một lần.
  3. Reset password: cùng thông báo cho mọi trường hợp (chống enumeration).
  4. Webhook bounce/complaint (verify HMAC — GĐ8 mục 15) → suppression list → kiểm tra trước khi gửi.
  5. Cấu hình SPF + DKIM + DMARC (p=none) cho một subdomain thật. Gửi thử tới Gmail, mở "Show original" và xác nhận SPF: PASS, DKIM: PASS, DMARC: PASS.

Done khi

Upload / Storage

  • [ ] Giải thích được cấu trúc multipart/form-data và vì sao express.json() không parse được.
  • [ ] Phân biệt memory / disk / stream; biết khi nào bắt buộc stream.
  • [ ] Đặt limit ở cả reverse proxy và parser; giải thích lỗi 413 đến từ đâu.
  • [ ] Validate bằng magic bytes + allowlist, không tin Content-Typefilename.
  • [ ] Nêu được 3 rủi ro của SVG / ZIP / ảnh bomb và cách chặn từng cái.
  • [ ] Giải thích vì sao user content phải phục vụ từ domain khác app.
  • [ ] Triển khai đủ luồng presigned 3 bước, có HeadObject xác minh; ghim size/type vào chữ ký.
  • [ ] Bucket private; không có object nào public-read.
  • [ ] Bật lifecycle abort multipart; giải thích được khoản phí ẩn nếu không bật.
  • [ ] Xử lý nặng chạy trong worker, không trong request handler.
  • [ ] Có chiến lược chống orphan cả hai chiều (DB-first, xoá mềm, job đối soát).

Email

  • [ ] Tách transactional / marketing bằng subdomain riêng; giải thích được reputation lây chéo.
  • [ ] Cấu hình SPF + DKIM + DMARC và đọc được header xác thực trong mail đã nhận.
  • [ ] Giải thích DMARC alignment và vì sao DKIM pass vẫn có thể DMARC fail.
  • [ ] Gửi mail qua queue + outbox, có retry và idempotencyKey.
  • [ ] Có suppression list từ webhook bounce/complaint, kiểm tra trước mỗi lần gửi.
  • [ ] Mail luôn có bản text kèm html.
  • [ ] Chống user enumeration ở reset password; chống header injection.
  • [ ] Token trong link: ngẫu nhiên mạnh, hash trước khi lưu, hết hạn ngắn, dùng một lần.

Câu hỏi mở / chưa giải quyết

  • S3 hay Cloudflare R2? R2 miễn phí egress (rẻ hơn nhiều nếu người dùng tải nhiều); S3 tích hợp sâu hơn với hệ AWS còn lại. Chốt theo nơi deploy ở GĐ14.
  • Quét virus: ClamAV tự host (tốn RAM, phải cập nhật signature) vs dịch vụ trả phí. Chỉ cần khi người dùng tải file của nhau — cân nhắc theo sản phẩm.
  • Có nên cho phép upload SVG? Nếu là app thiết kế thì buộc phải — khi đó cần sanitize pipeline riêng, đừng tự viết.
  • Provider email: Resend (DX tốt, mới) vs Postmark (deliverability transactional tốt nhất, đắt hơn) vs SES (rẻ nhất, vận hành thủ công nhiều). Quyết khi biết khối lượng gửi thật.

Học bằng cách build. Chứng minh, đừng tin.