GĐ12 — File upload, Object Storage & Email

Kiểm chứng ngày 2026-10-05: mã multipart (mục 8) qua tsc --strict với @aws-sdk/client-s3 3.1146.0 và ký URL offline; endpoint huỷ đăng ký (mục 14) đã chạy với Express 5.2.1 và Node 24.21; header List-Unsubscribe dựng bằng nodemailer 10.0.15. Chưa chạy với S3, R2 hay hộp thư Gmail thật. Nguồn: RFC 8058, RFC 9989 (DMARC, 05/2026; thay thế RFC 7489), yêu cầu người gửi của Google. Chưa xác minh: presigned UploadPart trên R2. Hai chỗ sửa sau lần kiểm trên (guard UNSUB_SECRET bắt buộc, trần MAX_SIZE ở startMultipart) là mã tham chiếu, chưa chạy lại.

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Đ25 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:

textReady
POST /upload HTTP/1.1Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123------WebKitFormBoundaryABC123Content-Disposition: form-data; name="title"Báo cáo quý 4------WebKitFormBoundaryABC123Content-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.
  • filename và Content-Type của part đều do client tự khai → khô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ỏ):

typescriptReady
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):

typescriptReady
import busboy from 'busboy';import type { Readable } from 'node:stream';import { Upload } from '@aws-sdk/lib-storage';import { DeleteObjectCommand } from '@aws-sdk/client-s3';const MAX = 100 * 1024 * 1024;app.post('/documents', (req, res, next) => {  // Chặn sớm khi client khai Content-Length quá lớn (chưa đọc byte nào).  // Header này không tin tuyệt đối: request chunked không có nó.  if (Number(req.headers['content-length']) > MAX + 64 * 1024) {    return res.status(413).set('Connection', 'close').json({ error: 'file too large' });  }  const bb = busboy({ headers: req.headers, limits: { fileSize: MAX, files: 1, fields: 5 } });  let upload: Upload | undefined, finished: Promise<unknown> | undefined;  let body: Readable | undefined, key = '';  let replied = false;  const reply = (fn: () => void) => { if (!replied) { replied = true; fn(); } };  // trả lời đúng MỘT lần  const cleanup = () => {          // huỷ multipart đang dở, xoá object lỡ ghi    if (!upload) return;    // Đóng stream để Upload không chờ mãi (multipart tự gửi AbortMultipartUpload khi lỗi).    // KHÔNG gọi upload.abort(): nó làm done() reject NGAY, khi PutObject của file nhỏ còn đang bay.    body!.destroy();    // Chỉ xoá SAU KHI Upload đã xong hẳn (thành công muộn hay lỗi đều được); xoá sớm thì    // PutObject đến sau và object ở lại. Nếu một request S3 treo thì `finished` không settle và việc xoá    // không chạy: đặt timeout cho S3 client (`requestHandler: { requestTimeout }`) và dựa vào lifecycle rule ở mục 8.    finished!.catch(() => {}).then(() => s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }))).catch(() => {});  };  const reject = (status: number, error: string) => reply(() => {    res.status(status).set('Connection', 'close').json({ error });    cleanup();  });  res.on('close', () => { if (!replied) { replied = true; cleanup(); } });   // client ngắt giữa chừng  bb.on('file', (_name, fileStream) => {    body = fileStream;    key = `docs/${crypto.randomUUID()}`;    upload = new Upload({ client: s3, params: { Bucket: BUCKET, Key: key, Body: fileStream } });    finished = upload.done();    // Vượt `fileSize`: busboy CẮT stream (fileStream.truncated = true) và bắn 'limit'    // trước 'end', KHÔNG báo lỗi. Không bắt sự kiện này = lưu file cụt và trả 201.    fileStream.on('limit', () => reject(413, 'file too large'));    finished.then(() => reply(() => res.status(201).json({ key })), (e) => reply(() => next(e)));  });  bb.on('filesLimit', () => reject(413, 'too many files'));        // `files`: số file, KHÔNG phải kích thước  bb.on('close', () => { if (!upload) reject(400, 'file required'); });  bb.on('error', (e) => reply(() => { next(e); cleanup(); }));   // multipart hỏng: có thể đã lỡ ghi object  req.pipe(bb);});

Hai giới hạn khác nhau, hai sự kiện khác nhau. limits.fileSize chặn kích thước của từng file và báo bằng sự kiện limit trên stream của file đó (cờ fileStream.truncated). limits.files chặn số file, báo bằng sự kiện filesLimit trên busboy. Nhầm hai thứ này là lỗi kinh điển: handler chỉ nghe filesLimit thì file vượt kích thước vẫn được Upload đọc đến hết phần đã bị cắt và lưu lên S3.

Vì sao cần reply một lần. res.json hai lần ném ERR_HTTP_HEADERS_SENT. Ở đây có tới ba nguồn có thể trả lời (limit, filesLimit, upload.done() hoàn tất hoặc lỗi), và done() reject sau khi body.destroy() ngắt luồng, ngay sau khi ta đã trả 413.

Khách hàng có thể thấy ECONNRESET/EPIPE thay vì 413. Khi server trả lời và đóng kết nối giữa lúc client còn đang gửi thân request, một số client (kể cả fetch của Node) báo lỗi kết nối chứ không đọc được mã 413; curl thường đọc được. Đây là bản chất của HTTP/1.1, không phải lỗi của đoạn code trên. Vì thế lớp bảo vệ đáng tin nhất vẫn là chặn trước khi gửi: client kiểm kích thước và dùng presigned URL (mục 6), còn server kiểm Content-Length như trên.

Mức kiểm chứng: đã chạy thật busboy 1.6 + @aws-sdk/lib-storage 3.1146 với S3 giả (một HTTP server nhận các lệnh S3 và ghi lại object, lệnh huỷ, lệnh xoá), chưa chạy trên S3/R2 thật. Một handler chỉ nghe filesLimit, với giới hạn 1 MiB, lưu file 3 MiB thành object 1 MiB và trả 201. Mã ở trên trả 413, không còn object nào sau khi xong, file hợp lệ vẫn 201; đã chạy đủ các đường: limit (multipart chunked không có Content-Length), filesLimit, thiếu file, client bị kill giữa chừng, và multipart hỏng sau khi file đã lên S3. Hai điểm dễ sai trong cleanup(), cả hai đã gặp khi chạy: (1) thiếu body.destroy() thì ở đường ngắt giữa chừng S3 giả chỉ nhận tạo multipart, một part và lệnh xoá, không nhận AbortMultipartUpload (đã chờ 6 giây); (2) gọi upload.abort() làm done() reject ngay trong khi PutObject của file dưới 5 MiB còn đang bay, nên DeleteObject đi trước và object đến sau ở lại (kịch bản: file 1 MiB rồi một part hỏng; lệnh xoá đi trước lệnh ghi ở 11/12 lần với độ trễ ghi 0 ms và 12/12 lần với 20 ms và 50 ms). Mã hiện tại không gọi abort(), chỉ đóng stream và đợi done() xong hẳn rồi mới xoá: 20 lần cho mỗi mức độ trễ ghi 0, 5, 100 ms với file 1 MiB và 0, 30, 200 ms với file 12 MiB đều không còn object. Race này phụ thuộc độ trễ của S3 nên các con số trên chỉ đúng với S3 giả; hãy thử lại với bucket của bạn. Dù đã huỷ, vẫn nên đặt lifecycle rule "abort incomplete multipart uploads" cho bucket (mục 8) vì process chết đột ngột thì không còn ai gọi huỷ.

Ví dụ — NestJS:

typescriptReady
@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-Type và filename 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.

typescriptReady
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 blocklistif (!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.

typescriptReady
// ❌ 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 DBconst ext = detected.ext;                       // từ magic bytes, không từ tên fileconst 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.
  • ZIP → zip 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:

typescriptReady
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',  // SDK mới (từ 3.729.0) mặc định thêm checksum CRC32 vào presigned PUT (x-amz-checksum-crc32=AAAAAA==,  // checksum của body RỖNG). Client sẽ upload body khác rỗng nên checksum đó sai; `WHEN_REQUIRED` bỏ nó đi.  requestChecksumCalculation: 'WHEN_REQUIRED',  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:

textReady
tenants/{tenantId}/documents/{uuid}.pdf

Tiền tố tenant giúp phân quyền và 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:

textReady
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:

typescriptReady
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,      // chỉ vào chữ ký nhờ signableHeaders bên dưới; client PHẢI gửi đúng header này    ContentLength: input.size,    // ghim size vào chữ ký (AWS S3 ép; R2 chưa xác minh — xem Pitfall bên dưới)  }), {    expiresIn: 300,                              // 5 phút — đủ upload, đủ ngắn nếu URL bị lộ    signableHeaders: new Set(['content-type']),  // mặc định content-type KHÔNG nằm trong chữ ký  });  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ý, và nhớ ContentLength mặc định có trong chữ ký còn ContentType thì không: phải truyền signableHeaders: new Set(['content-type']) cho getSignedUrl (đã chạy @aws-sdk/client-s3 3.1146.0, X-Amz-SignedHeaders mặc định chỉ có content-length;host). Trên AWS S3 còn có lựa chọn presigned POST policy (khai content-length-range linh hoạt hơn); Cloudflare R2 không hỗ trợ presigned POST (tài liệu R2 liệt kê presigned URL cho GET, HEAD, PUT, DELETE và ghi POST không được hỗ trợ), nên với R2 bạn chỉ có presigned PUT. Việc R2 có thực sự ép ContentLength đã ký trên presigned PUT hay không: chưa xác minh. Dù thế nào, đừng coi chữ ký là chốt chặn cuối: bước HeadObject kiểm size ở bước 3 mới là chốt chặn thật, và worker xử lý sau upload (mục 9) kiểm lại magic byte.
  • 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:

typescriptReady
// ❌ Cách tốn kém: mọi byte đi qua server bạn, chiếm connection suốt thời gian tảiconst 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 cho cả "không có" lẫn "của người khác" (xem dướ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)    // dạng `filename*=UTF-8''...` (RFC 6266) giữ được tên có dấu; `filename="%C4%90..."` sẽ hiện nguyên chuỗi %-mã hoá    // `filename` ASCII làm dự phòng; encodeURIComponent chừa lại ' ( ) * nên phải mã hoá nốt (RFC 8187)    ResponseContentDisposition: `attachment; filename="download"; filename*=UTF-8''${      encodeURIComponent(file.originalName).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())    }`,  }), { expiresIn: 60 });  res.redirect(302, url);});

404 hay 403 cho file của người khác? Quy ước xuyên suốt lộ trình: trả 404 khi không muốn lộ sự tồn tại của tài nguyên, 403 chỉ khi việc nó tồn tại là công khai. File của một người dùng (hay tenant) khác rơi vào trường hợp đầu: nếu trả 403, kẻ tấn công dò fileId biết ngay id nào có thật và thu hẹp việc đoán. Truy vấn findFirst({ id, userId }) ở trên cho ra 404 giống hệt nhau cho "không tồn tại" và "của người khác", nên không có kênh phụ nào để phân biệt (kiểm cả thời gian phản hồi nếu dữ liệu nhạy cảm). Trường hợp dùng 403: người dùng biết rõ tài nguyên có thật nhưng thiếu quyền, ví dụ tài liệu trong cùng workspace mà vai trò MEMBER không được xoá; khi đó 404 chỉ gây khó hiểu. Cùng quy ước được kiểm bằng test ở GĐ13 mục 7.

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.

Sơ đồ: luồng multipart với presigned URL cho từng part
textReady
 Client                  API (kiểm quyền, quota)               S3 / R2   | POST /files/multipart |                                      |   |---------------------->| CreateMultipartUpload -------------->|   |                       |<------------- uploadId --------------|   |<-- uploadId + URL ký cho part 1..N (mỗi URL ký UploadPart) ---|   | PUT part 1 ... N (song song, lỗi part nào retry part đó) ---->|   |<----------------------- ETag của từng part -------------------|   | POST /complete [{PartNumber, ETag}] ------------------------->|   |---------------------->| CompleteMultipartUpload ------------>|   |                       | HeadObject (size) -> status READY    |   Bỏ dở: Abort, hoặc lifecycle AbortIncompleteMultipartUpload (7 ngày).

Mọi part (trừ part cuối) phải từ 5 MiB trở lên; client giữ danh sách ETag nên mất trang là mất tiến độ, vì vậy lưu uploadId và các part đã xong (DB hoặc localStorage) để nối lại. Sơ đồ suy ra từ tài liệu S3 multipart; chưa chạy.

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.

Multipart với presigned URL: mã tham chiếu cho từng bước#

Bốn endpoint: bắt đầu, ký từng lô part, hoàn tất, huỷ. Điểm quyết định tính đúng:

  • Kích thước part do server chọn, một giá trị cho mọi part trừ part cuối. S3 đòi part (trừ part cuối) từ 5 MiB; tài liệu R2 đòi các part cùng kích thước trừ part cuối. Tối đa 10.000 part, nên với object lớn phải tăng cỡ part (max(8 MiB, size / 10000); 100 GiB cần part khoảng 10,24 MiB).
  • Ký theo lô khi client cần, không ký sẵn 10.000 URL. Mỗi URL ghim ContentLength của đúng part đó vào chữ ký (cùng lý do như mục 6).
  • Hoàn tất không dựa vào danh sách ETag của client. Server hỏi lại storage bằng ListParts (phân trang: mỗi trang tối đa 1000 part), kiểm số part, thứ tự và kích thước rồi mới CompleteMultipartUpload, sau đó HeadObject kiểm tổng size như mục 6. Vì server tự lấy ETag nên client không cần đọc header ETag; nếu thư viện client của bạn cần đọc, thêm ExposeHeaders: ["ETag"] vào CORS của bucket.
typescriptReady
import { randomUUID } from 'node:crypto'import {  CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand,  AbortMultipartUploadCommand, HeadObjectCommand, paginateListParts, type CompletedPart,} from '@aws-sdk/client-s3'import { getSignedUrl } from '@aws-sdk/s3-request-presigner'// s3, BUCKET, db, HttpError: như các mục 5 và 6const MIN_PART = 8 * 1024 * 1024   // 8 MiB; S3 và R2 đòi part (trừ part cuối) từ 5 MiBconst MAX_PARTS = 10_000const MAX_SIZE = 5 * 1024 ** 3     // trần cho mỗi file (ví dụ 5 GiB): không có trần thì client xin upload khổng lồ// Một kích thước cho MỌI part trừ part cuối (R2 đòi các part cùng kích thước)const partSizeFor = (size: number) => Math.max(MIN_PART, Math.ceil(size / MAX_PARTS))const partLength = (size: number, partSize: number, n: number) =>  Math.min(partSize, size - partSize * (n - 1))// 1. Bắt đầu: tạo upload ở storage + bản ghi PENDING. Chưa ký URL nào.export async function startMultipart(userId: string, input: { size: number; mime: string; filename: string }) {  if (!Number.isSafeInteger(input.size) || input.size < 1 || input.size > MAX_SIZE) throw new HttpError(400, 'bad size')  const partSize = partSizeFor(input.size)  const parts = Math.ceil(input.size / partSize)  const key = `tenants/${userId}/docs/${randomUUID()}`  const { UploadId } = await s3.send(new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: key, ContentType: input.mime }))  const file = await db.file.create({ data: { key, userId, uploadId: UploadId, partSize,    size: input.size, mime: input.mime, originalName: input.filename, status: 'PENDING' } })  return { fileId: file.id, partSize, parts }}// 2. Ký theo lô (tối đa 50 part mỗi lần): URL ký sẵn cho 10.000 part là lãng phí và lộ nhiều.export async function signParts(userId: string, fileId: string, partNumbers: number[]) {  const file = await db.file.findFirst({ where: { id: fileId, userId, status: 'PENDING' } })  if (!file?.uploadId) throw new HttpError(404, 'not found')  const parts = Math.ceil(file.size / file.partSize)  if (partNumbers.length > 50 || partNumbers.some((n) => !Number.isInteger(n) || n < 1 || n > parts))    throw new HttpError(400, 'bad part numbers')  return Promise.all(partNumbers.map(async (n) => ({    partNumber: n,    url: await getSignedUrl(s3, new UploadPartCommand({      Bucket: BUCKET, Key: file.key, UploadId: file.uploadId!, PartNumber: n,      ContentLength: partLength(file.size, file.partSize, n),     // ghim độ dài từng part vào chữ ký    }), { expiresIn: 3600 }),  })))}// 3. Hoàn tất: KHÔNG tin danh sách ETag từ client; hỏi lại storage bằng ListParts.export async function completeMultipart(userId: string, fileId: string) {  const file = await db.file.findFirst({ where: { id: fileId, userId, status: 'PENDING' } })  if (!file?.uploadId) throw new HttpError(404, 'not found')  const expected = Math.ceil(file.size / file.partSize)  const done: CompletedPart[] = []  // ListParts trả tối đa 1000 part mỗi trang: phải phân trang.  for await (const page of paginateListParts({ client: s3 }, { Bucket: BUCKET, Key: file.key, UploadId: file.uploadId }))    for (const p of page.Parts ?? []) {      if (p.PartNumber === undefined || !p.ETag || p.Size !== partLength(file.size, file.partSize, p.PartNumber))        throw new HttpError(400, `part ${p.PartNumber} has wrong size`)      done.push({ PartNumber: p.PartNumber, ETag: p.ETag })    }  done.sort((a, b) => a.PartNumber! - b.PartNumber!)  if (done.length !== expected || done.some((p, i) => p.PartNumber !== i + 1))    throw new HttpError(400, 'missing parts')  await s3.send(new CompleteMultipartUploadCommand({ Bucket: BUCKET, Key: file.key, UploadId: file.uploadId,    MultipartUpload: { Parts: done } }))  const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: file.key }))  if (head.ContentLength !== file.size) throw new HttpError(400, 'size mismatch')  await db.file.update({ where: { id: file.id }, data: { status: 'READY' } })}// 4. Huỷ: giải phóng các part đã lên (vẫn bị tính tiền nếu để treo).export async function abortMultipart(userId: string, fileId: string) {  const file = await db.file.findFirst({ where: { id: fileId, userId, status: 'PENDING' } })  if (!file?.uploadId) return  await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: file.key, UploadId: file.uploadId }))  await db.file.update({ where: { id: file.id }, data: { status: 'ABORTED' } })}

Mức kiểm chứng: tsc --strict sạch với @aws-sdk/client-s3 3.1146.0; ký UploadPartCommand offline (ký là phép tính cục bộ) cho ra URL có partNumber, uploadId, X-Amz-SignedHeaders=content-length;host; với requestChecksumCalculation: 'WHEN_REQUIRED' (mục 5) URL không còn x-amz-checksum-crc32, còn mặc định thì có. Chưa chạy với S3 hay R2 thật. Tài liệu R2 liệt kê presigned URL cho GET, HEAD, PUT, DELETE và có UploadPart, ListParts trong bảng API S3 được hỗ trợ; việc một URL ký cho UploadPart chạy được trên R2: chưa xác minh, hãy thử trên bucket của bạn (nếu không được, dùng Worker binding createMultipartUpload).

Tải lên có thể nối lại sau khi đứt hẳn kết nối (giao thức tus, thư viện Uppy) nằm ngoài phạm vi giai đoạn này; xem tus.io và uppy.io. Kỹ thuật ở đây (lưu uploadId rồi ListParts để biết part nào đã lên) là nền của chúng.


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Đ23), 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Đ03).

Cơ chế. Upload xong → enqueue BullMQ job (GĐ10) → 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:

typescriptReady
import sharp from 'sharp';new Worker('files', async (job) => {      // BullMQ 6: Worker(tên queue, processor, { connection })  if (job.name !== 'process-image') return;  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' } });}, { connection });                        // connection: ioredis, maxRetriesPerRequest: null (GĐ10 mục 11, yêu cầu 2)

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.

typescriptReady
// 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:

typescriptReady
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:

textReady
; 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 selectorresend._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;"

Cơ chế "alignment" — chỗ hay sai. DMARC yêu cầu domain đã xác thực bằng SPF hoặc DKIM phải khớp với domain trong From:. Chế độ mặc định là relaxed: chỉ cần cùng Organizational Domain, nên chữ ký DKIM d=mail.myapp.com với From: no-reply@myapp.com vẫn aligned. Lỗi thật hay gặp là provider ký bằng domain của chính họ (ví dụ d=provider.example) khi From: là @myapp.com: DKIM pass nhưng DMARC fail vì không aligned. Chi tiết ở phần alignment ngay dưới mục này.

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.

Alignment chi tiết: SPF, DKIM và chế độ relaxed/strict#

Mỗi cơ chế đem một domain khác nhau so với From::

Cơ chếDomain đem so với From:Chỗ dễ sai
SPFdomain trong envelope MAIL FROM (header Return-Path), không phải From:provider dùng Return-Path của họ: SPF pass nhưng không aligned
DKIMtag d= của chữ kýchưa thêm bản ghi DKIM của domain mình nên provider ký bằng domain của họ

DMARC pass khi ít nhất một trong hai vừa pass vừa aligned, nên chỉ cần DKIM aligned là đủ dù SPF lệch. Hai tag đổi chế độ, mặc định đều là r: adkim=r|s cho DKIM, aspf=r|s cho SPF. Relaxed: cùng Organizational Domain; RFC 9989 xác định domain này bằng DNS Tree Walk (RFC 7489 cũ dùng Public Suffix List, và người nhận cũ có thể vẫn làm theo cách đó). Strict: trùng chính xác.

From:DKIM d=adkimDKIM aligned?
news@myapp.commail.myapp.comr (mặc định)có, cùng myapp.com
news@myapp.commail.myapp.comskhông
news@myapp.comprovider.exampler hoặc skhông

Tag p= là chính sách của domain có bản ghi; sp= là chính sách cho subdomain không có bản ghi DMARC riêng. Người nhận tra _dmarc.<domain trong From> trước, không có thì dùng _dmarc của Organizational Domain. Ví dụ gửi từ mail.myapp.com mà chỉ có _dmarc.myapp.com với p=none; sp=quarantine: mail fail từ subdomain bị xử lý theo quarantine, không phải none. Siết p= lên domain gốc mà quên sp= là cách làm lộ lỗ hổng ở subdomain; đặt sp= có chủ ý. RFC 9989 thêm tag np= cho subdomain không tồn tại và đưa pct vào trạng thái historic, nên ví dụ bản ghi ở trên không còn pct.

Google yêu cầu From: aligned với domain SPF hoặc domain DKIM (trang yêu cầu người gửi). Quy tắc alignment và lookup nằm trong RFC 9989 (mục 4.10 là DNS Tree Walk; RFC 9989 thay thế RFC 7489, kiểm trên rfc-editor.org ngày 2026-10-05).


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:

typescriptReady
// ❌ SAI trên 3 phương diệnapp.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ấtapp.post('/signup', async (req, res) => {  const user = await db.$transaction(async (tx) => {    const u = await tx.user.create({ data });    // Outbox (GĐ10 mục 4): 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:

typescriptReady
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}:${tokenRecord.id}`,   // id bản ghi token, không dùng token thô});

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

typescriptReady
// 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:

typescriptReady
// 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:

typescriptReady
// ❌ input = "Hi\r\nBcc: victim@x.com" → thêm được người nhậnsubject: `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 BODYsubject: `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:

typescriptReady
// 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

Huỷ đăng ký một chạm cho mail marketing#

Mail marketing cần cách huỷ đăng ký mà người nhận bấm được ngay trong giao diện Gmail/Yahoo, không phải mở trang web. Chuẩn là RFC 8058: hai header.

textReady
List-Unsubscribe: <https://news.myapp.com/unsubscribe/TOKEN>, <mailto:unsub@news.myapp.com?subject=unsubscribe>List-Unsubscribe-Post: List-Unsubscribe=One-Click
  • Chỉ một header List-Unsubscribe, các URI cách nhau bằng dấu phẩy; có ít nhất một URI https. RFC 8058 yêu cầu cả hai header nằm trong chữ ký DKIM (tag h=).
  • Khi người dùng bấm, máy chủ của người nhận gửi POST tới URI https với body List-Unsubscribe=One-Click. Endpoint này không được đòi cookie hay đăng nhập và không được redirect.
  • GET tới cùng URL không được huỷ đăng ký: bộ quét link và trình xem trước mail gọi GET tự động. GET chỉ hiện trang xác nhận.
  • Google yêu cầu người gửi hơn 5.000 mail/ngày tới Gmail phải hỗ trợ one-click cho mail marketing và mail đã đăng ký, kèm link huỷ nhìn thấy được trong thân mail (nguồn). Dưới ngưỡng đó, làm theo vẫn hợp lý: người không tìm được nút huỷ thường bấm "báo spam".

Dựng header với nodemailer: truyền qua headers với một chuỗi đã gộp. Tuỳ chọn list: { unsubscribe: [...] } của nodemailer 10.0.15 phát ra hai dòng List-Unsubscribe riêng, không phải dạng một header như trên (đã chạy kiểm).

typescriptReady
await transporter.sendMail({  from: 'news@news.myapp.com', to, subject, text, html,  headers: {    'List-Unsubscribe': `<${url}>, <mailto:unsub@news.myapp.com?subject=unsubscribe>`,    'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',  },})

Endpoint, với token ký HMAC để link không đoán được và không cần bảng tra:

typescriptReady
import crypto from 'node:crypto'import express from 'express'// db: Prisma client; model marketingOptOut { email String @id }declare const db: { marketingOptOut: { upsert(a: { where: { email: string }; create: { email: string }; update: Record<string, never> }): Promise<unknown> } }// Thiếu biến môi trường thì dừng ngay: secret mặc định công khai cho phép ai cũng ký được token.const SECRET = process.env.UNSUB_SECRETif (!SECRET) throw new Error('UNSUB_SECRET is required')// Token = email + chữ ký HMAC: không đoán được, không cần đăng nhập, không cần bảng tra.export const makeUnsubToken = (email: string) => {  const e = Buffer.from(email).toString('base64url')  return `${e}.${crypto.createHmac('sha256', SECRET).update(e).digest('base64url')}`}function readToken(token: string): string | null {  const [e, sig] = token.split('.')  if (!e || !sig) return null  const mac = crypto.createHmac('sha256', SECRET).update(e).digest()  const given = Buffer.from(sig, 'base64url')  if (given.length !== mac.length || !crypto.timingSafeEqual(given, mac)) return null  return Buffer.from(e, 'base64url').toString()}export const app = express()// Trang xác nhận cho người bấm link trong thân mail. GET KHÔNG huỷ đăng ký:// bộ quét link và trình xem trước mail sẽ tự gọi GET.app.get('/unsubscribe/:token', (req, res) => {  if (!readToken(req.params.token)) return res.sendStatus(404)  res.type('html').send(`<form method="post"><button>Xác nhận huỷ đăng ký</button></form>`)})// POST: vừa là nút xác nhận của trang trên, vừa là one-click của Gmail/Yahoo (RFC 8058).// Không cookie, không đăng nhập, KHÔNG redirect, trả 200.app.post('/unsubscribe/:token', async (req, res) => {  const email = readToken(req.params.token)  if (!email) return res.sendStatus(404)  await db.marketingOptOut.upsert({ where: { email }, create: { email }, update: {} })  res.sendStatus(200)})

Người đã huỷ marketing vẫn phải nhận mail giao dịch (reset password, hoá đơn): bảng marketingOptOut khác emailSuppression ở trên, và chỉ worker marketing mới kiểm nó.

Mức kiểm chứng: tsc --strict sạch; đã chạy trên Node 24.21 và Express 5.2.1: GET trả 200 và ghi 0 lần, POST (body List-Unsubscribe=One-Click, không theo redirect) trả 200 và ghi đúng một lần, token sai trả 404 và không ghi thêm. Header dựng bằng nodemailer đã in ra đúng một dòng gộp. Chưa gửi thật tới Gmail; nếu dùng provider (Resend, SES), kiểm trong "Show original" rằng h= của DKIM-Signature có cả hai header.


Thực hành#

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

A — Upload:

  1. Endpoint POST /files/upload-url cấp presigned PUT (kiểm auth + quota + mime + size), tạo bản ghi PENDING.

    Đáp án

    Validate bằng zod, rồi dùng đúng createUploadUrl ở mục 6 (ghim ContentLength; ContentType chỉ vào chữ ký nhờ signableHeaders; expiresIn: 300). Giữ requestChecksumCalculation: 'WHEN_REQUIRED' ở client (mục 5). Mong đợi: JSON { uploadUrl, fileId }; uploadUrl có X-Amz-Expires=300 và X-Amz-Signature; bản ghi PENDING. Với SDK mặc định, URL còn có x-amz-checksum-crc32=AAAAAA==; bỏ nó bằng WHEN_REQUIRED (việc S3 thật từ chối URL mặc định là suy luận, chưa xác minh).

  2. Endpoint POST /files/:id/confirm → HeadObject xác minh → READY → enqueue job.

    Đáp án
    typescriptReady
    const file = await db.file.findFirst({ where: { id, userId } })if (!file) throw new NotFoundException()                   // của người khác cũng 404 (mục 7)if (file.status === 'READY') return file                   // confirm lần hai: idempotent, trả 200if (file.status !== 'PENDING') throw new ConflictException()const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: file.key }))  .catch((e) => { if (e.name === 'NotFound') throw new BadRequestException('not uploaded'); throw e })if (head.ContentLength !== file.size) throw new BadRequestException('size mismatch')const { count } = await db.file.updateMany({ where: { id, status: 'PENDING' }, data: { status: 'READY' } })if (count === 1)   // chỉ request thắng cuộc mới enqueue  await queue.add('process-file', { fileId: id }, { jobId: `process-${id}` })   // jobId: vẫn một job nếu trùng

    Mong đợi: confirm khi chưa PUT → 400; confirm với id của người khác → 404; confirm hai lần → lần hai 200 (idempotent, không enqueue thêm); hai request song song chỉ một request đổi trạng thái nhờ updateMany có điều kiện PENDING.

  3. Worker: validate magic bytes từ object đã upload (không tin mime lúc xin URL), resize ảnh bằng sharp, strip EXIF.

    Đáp án

    Chỉ đọc đầu object, không tải cả file:

    typescriptReady
    const r = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: file.key, Range: 'bytes=0-4099' }))const head = Buffer.from(await r.Body!.transformToByteArray())const t = await fileTypeFromBuffer(head)if (!t || !ALLOWED.has(t.mime)) {  await db.file.update({ where: { id: file.id }, data: { status: 'REJECTED' } })  await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: file.key }))  return}// ảnh: sharp như mục 9 (limitInputPixels, rotate() rồi webp)

    Mong đợi: file PNG thật → READY + bản thumbnail; file HTML đổi tên → REJECTED, object bị xoá.

  4. GET /files/:id/download → kiểm quyền → presigned GET 60s + Content-Disposition: attachment → 302.

    Đáp án

    Dùng handler ở mục 7 (findFirst({ id, userId }), expiresIn: 60, attachment). Mong đợi: người sở hữu nhận 302, Location có X-Amz-Expires=60 và response-content-disposition=attachment...; người khác nhận 404 giống hệt id không tồn tại.

  5. Xoá mềm + job purge hoãn 7 ngày. Cron dọn PENDING quá 24h.

    Đáp án

    Xoá mềm như mục 10. Cron bằng BullMQ 6 (không dùng repeat):

    typescriptReady
    await queue.upsertJobScheduler('cleanup-pending', { pattern: '0 3 * * *', tz: 'Asia/Ho_Chi_Minh' }, { name: 'cleanup-pending' })// handlerconst old = await db.file.findMany({ where: { status: 'PENDING', createdAt: { lt: new Date(Date.now() - 86_400_000) } } })for (const f of old) {  await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: f.key }))   // xoá object không tồn tại vẫn thành công  await db.file.delete({ where: { id: f.id } })}
  6. Bật lifecycle AbortIncompleteMultipartUpload trên bucket.

    Đáp án

    Với AWS S3:

    bashReady
    aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" --lifecycle-configuration '{  "Rules": [{ "ID": "abort-mpu", "Status": "Enabled", "Filter": { "Prefix": "" },              "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } }] }'aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET"   # kiểm: thấy lại rule

    R2 cấu hình lifecycle riêng (dashboard/Wrangler); xem tài liệu R2, cách cấu hình chưa xác minh ở đây.

  7. Tự tấn công: đổi tên test.html → test.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.

    Đáp án

    Test (Vitest, chưa chạy; kiểm hai hàm thuần nên không cần S3 hay supertest):

    typescriptReady
    const safeName = (n: string) => path.basename(n).slice(0, 255)   // chỉ để hiển thị, lưu ở originalNameexpect(safeName('../../etc/passwd')).toBe('passwd')         // tên độc không thành đường dẫnconst html = Buffer.from('<html><script>alert(1)</script></html>')expect(await fileTypeFromBuffer(html)).toBeUndefined()      // file-type không nhận ra HTML → bị chặnconst key = makeKey('user1', 'png')                         // makeKey = `tenants/${u}/docs/${randomUUID()}.${ext}`, không nhận filenameexpect(makeKey('u1', 'png')).toMatch(/^tenants\/u1\/docs\/[0-9a-f-]{36}\.png$/)expect(key).not.toContain('..')

    Mong đợi: test.html đổi tên test.png bị REJECTED; filename="../../etc/passwd" chỉ nằm trong cột originalName (nên cắt bằng path.basename và độ dài), key sinh ra vẫn là tenants/<user>/docs/<uuid>.<ext>.

  8. Làm multipart với presigned URL cho file lớn (bốn endpoint: bắt đầu, ký lô part, hoàn tất, huỷ).

    Đáp án

    Dùng mã ở mục 8 (phần multipart với presigned URL). Với file 20 MiB, part 8 MiB cho ba part, độ dài 8, 8 và 4 MiB (đã tính bằng node -e); file 100 GiB cần part 10,24 MiB để đủ trong 10.000 part. Kiểm tra bằng bốn tình huống:

    textReady
    1. thiếu part 3, gọi complete     -> 400 missing parts, chưa Complete2. part 2 PUT 7 MiB (ký 8 MiB)    -> storage từ chối (chữ ký ghim độ dài)3. gọi abort rồi sign tiếp        -> 404 (status không còn PENDING)4. user B gọi sign file của user A -> 404 (quy ước mục D)

    Code tham chiếu: tsc --strict đã chạy, ký URL offline đã chạy; bốn tình huống trên suy ra từ code, chưa chạy với S3/R2 thật (không dựng hạ tầng trong lời giải). Trên R2 hãy thử riêng việc ký UploadPart; nếu không được, dùng Worker binding.

Khung và mã dùng chung

Tự làm trước, rồi mở. Toàn bộ là code tham chiếu, chưa chạy (SDK @aws-sdk/client-s3 3.x, BullMQ 6, Prisma 7, file-type); mục "Kết quả mong đợi" suy ra từ code và tài liệu, không phải quan sát.

textReady
 POST /files/upload-url ─► DB: PENDING ─► presigned PUT (300 s)                                          ─► client PUT ─► S3 POST /files/:id/confirm ─► HeadObject (size) ─► READY                                          ─► queue.add('process-file') worker: GetObject Range 0-4099 ─► magic bytes         ├─ đúng: sharp + READY         └─ sai: REJECTED + DeleteObject GET /files/:id/download ─► findFirst({id, userId})                                          ─► presigned GET 60 s ─► 302

Lỗi hay gặp: tin Content-Type lúc xin URL thay vì kiểm magic bytes sau upload; confirm không HeadObject; lấy findUnique({ id }) làm lộ file người khác; quên CORS khi PUT từ trình duyệt; cron dọn xoá object nhưng quên dòng DB (hoặc ngược lại).

B — Email:

  1. EmailPort + 2 adapter (Resend cho prod, Mailpit SMTP cho dev qua Docker Compose).

    Đáp án

    Dev dùng Mailpit trong compose (SMTP cổng 1025, giao diện 8025):

    textReady
    mailpit: { image: axllent/mailpit, ports: ["8025:8025", "1025:1025"] }
    typescriptReady
    import nodemailer from 'nodemailer'export class SmtpAdapter implements EmailPort {  private t = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: 1025, secure: false })  async send(m: Parameters<EmailPort['send']>[0]) {    const r = await this.t.sendMail({ from: process.env.MAIL_FROM, to: m.to, subject: m.subject,                                      html: m.html, text: m.text, replyTo: m.replyTo })    return { messageId: r.messageId }  }}// ResendAdapter: resend.emails.send({ from, to, subject, html, text }, { idempotencyKey })//   (tên tham số idempotencyKey: đối chiếu tài liệu Resend trước khi dùng)

    Mong đợi: sau signup mở http://localhost:8025 thấy mail có cả tab HTML và Text.

  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.

    Đáp án

    Token hash + hết hạn + dùng một lần, đánh dấu dùng trong một câu lệnh để hai request song song không cùng thắng:

    typescriptReady
    const raw = crypto.randomBytes(32).toString('base64url')await db.emailVerification.create({ data: { userId, tokenHash: sha256(raw), expiresAt: addMinutes(new Date(), 30) } })// dùng tokenawait db.$transaction(async (tx) => {                     // đánh dấu dùng + xác thực email cùng một transaction  const rows = await tx.$queryRaw<{ user_id: string }[]>`UPDATE email_verification SET used_at = now()    WHERE token_hash = ${sha256(token)} AND used_at IS NULL AND expires_at > now() RETURNING user_id`  if (rows.length !== 1) throw new BadRequestException('invalid or expired token')  await tx.user.update({ where: { id: rows[0].user_id }, data: { emailVerifiedAt: new Date() } })})

    idempotencyKey của job gửi mail: verify:${userId}:${tokenId} (dùng id bản ghi token, không dùng token thô trong key). Mong đợi: dùng lại cùng link lần hai → 400; quá 30 phút (fake timers) → 400; DB không chứa token thô.

  3. Reset password: cùng thông báo cho mọi trường hợp (chống enumeration).

    Đáp án

    Luôn trả cùng một phản hồi, việc tìm user và gửi mail chạy ở worker:

    typescriptReady
    app.post('/auth/forgot', async (req, res) => {  const { email } = ForgotBody.parse(req.body)  await queue.add('forgot-password', { email },           // worker tự tra user; không có thì thôi    { removeOnComplete: true, removeOnFail: { age: 3_600 } })   // payload có PII: không giữ lâu trong Redis  res.status(202).json({ message: 'Nếu email tồn tại, chúng tôi đã gửi hướng dẫn' })})

    Mong đợi: email có và không có trong DB cho cùng mã 202, cùng body, thời gian phản hồi tương đương (không chạm DB trong handler).

  4. Webhook bounce/complaint (verify HMAC — GĐ09 mục 15) → suppression list → kiểm tra trước khi gửi.

    Đáp án

    HMAC trên raw body, so sánh thời gian cố định, kiểm tra suppression trước khi gửi:

    typescriptReady
    app.post('/webhooks/email', express.raw({ type: 'application/json' }), async (req, res) => {  const sig = Buffer.from(String(req.header('x-signature') ?? ''), 'hex')  const mac = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET!).update(req.body).digest()  if (sig.length !== mac.length || !crypto.timingSafeEqual(sig, mac)) return res.sendStatus(401)  const { type, email } = JSON.parse(req.body.toString())  if (type === 'email.bounced.hard' || type === 'email.complained')    await db.emailSuppression.upsert({ where: { email }, create: { email, reason: type }, update: {} })  res.sendStatus(200)})// trong worker, trước send(): if (await db.emailSuppression.findUnique({ where: { email } })) return

    Tên header và công thức ký (có thêm timestamp hay không) là của từng provider; đối chiếu tài liệu provider. Cơ chế HMAC chung: GĐ09 mục 15. Mong đợi: chữ ký sai → 401, không ghi DB; chữ ký đúng → có dòng suppression; gửi mail tới địa chỉ đó sau này bị bỏ qua.

  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.

    Đáp án

    Tạo bản ghi theo mục 13 cho subdomain gửi, rồi kiểm:

    bashReady
    dig +short TXT mail.myapp.com          # thấy v=spf1 include:...dig +short TXT resend._domainkey.mail.myapp.comdig +short TXT _dmarc.myapp.com        # v=DMARC1; p=none; ...

    Chưa chạy (cần domain thật). Trong Gmail, "Show original" nên có dòng dạng sau (mẫu minh hoạ, không phải quan sát):

    textReady
    SPF:   PASS with IP ...DKIM:  'PASS' with domain mail.myapp.comDMARC: 'PASS'

    DKIM PASS mà DMARC FAIL nghĩa là domain trong From: không aligned với domain ký/SPF (mục 13). Trong Authentication-Results, so header.from= với header.d= (DKIM) và smtp.mailfrom= (SPF): đó là ba domain đem đối chiếu.

  6. Thêm huỷ đăng ký một chạm cho mail marketing: header List-Unsubscribe, endpoint POST và bảng marketingOptOut.

    Đáp án

    Dùng header và endpoint ở mục 14 (phần huỷ đăng ký một chạm). Tự kiểm bằng curl trên server dev:

    bashReady
    curl -i http://localhost:3000/unsubscribe/$TOKEN                    # 200, trang xác nhận, chưa huỷcurl -i -X POST -d 'List-Unsubscribe=One-Click' \  http://localhost:3000/unsubscribe/$TOKEN                          # 200, không Location, có bản ghi opt-outcurl -i -X POST http://localhost:3000/unsubscribe/sai.token         # 404, không ghi

    Mong đợi: GET không ghi; POST ghi đúng một dòng (chạy lại vẫn một dòng nhờ upsert); worker marketing bỏ qua địa chỉ có trong bảng, worker giao dịch thì không. Đã chạy ba request tương ứng bằng fetch với server tạm (GET 200 và 0 lần ghi; POST 200 và 1 lần ghi; token sai 404); chưa thử với Gmail thật.

Khung và mã dùng chung

Tự làm trước, rồi mở. Code tham chiếu, chưa chạy (nodemailer, Resend SDK, BullMQ 6); kết quả "mong đợi" suy ra từ code. Việc gửi tới Gmail và đọc header cần domain thật nên không chạy được trong lời giải.

textReady
 POST /auth/signup ─ 1 transaction: user + outbox(SEND_VERIFY_EMAIL)                     ─► relay ─► queue worker: kiểm suppression ─► EmailPort.send(idempotencyKey)                     ─► Mailpit (dev) | Resend (prod) POST /webhooks/email ─ verify HMAC trên raw body ─► emailSuppression.upsert

Lỗi hay gặp: gửi mail trong handler HTTP; express.json() chạy trước webhook nên mất raw body; log token thô; so sánh chữ ký bằng ===; trả "Email không tồn tại" ở reset; để SPF có hai bản ghi TXT.


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.

    Đáp án

    Body gồm các part ngăn bởi boundary do client chọn; mỗi part có header (Content-Disposition, Content-Type) rồi nội dung. express.json() chỉ parse application/json, gặp multipart thì req.body rỗng/undefined. Sai thường gặp: base64 vào JSON (phình 33%, nằm hết trong RAM). Xem mục 2.

  • Phân biệt memory / disk / stream; biết khi nào bắt buộc stream.

    Đáp án

    Memory gom vào Buffer (file nhỏ, đã giới hạn), disk ghi tạm, stream đẩy thẳng sang đích với bộ nhớ gần như hằng số. Bắt buộc stream khi file lớn hoặc nhiều request đồng thời; multer mặc định không giới hạn kích thước nên luôn đặt limits.fileSize.

  • Đặt limit ở cả reverse proxy và parser; giải thích lỗi 413 đến từ đâu.

    Đáp án

    Nginx client_max_body_size (mặc định 1m) trả 413 trước khi tới Node; parser (limits.fileSize) trả 413 từ app. Chỉnh hai tầng khớp nhau. Tự kiểm: gửi file lớn hơn từng giới hạn, xem body lỗi để biết tầng nào trả.

  • Validate bằng magic bytes + allowlist, không tin Content-Type và filename.

    Đáp án

    fileTypeFromBuffer đọc chữ ký thật (PNG 89 50 4E 47, PDF %PDF), đối chiếu allowlist; Content-Type và filename do client khai nên chỉ là metadata. Tự kiểm: test.html đổi tên .png bị từ chối.

  • Nêu được 3 rủi ro của SVG / ZIP / ảnh bomb và cách chặn từng cái.

    Đáp án

    SVG chứa <script> (stored XSS): cấm, sanitize, hoặc phục vụ từ domain khác + attachment. ZIP: zip bomb và zip slip: giới hạn tổng dung lượng, số entry, chuẩn hoá đường dẫn. Ảnh bomb (khai kích thước khổng lồ): limitInputPixels của sharp.

  • Giải thích vì sao user content phải phục vụ từ domain khác app.

    Đáp án

    File HTML/SVG phục vụ cùng origin chạy JS trong origin của app và đọc được cookie/storage. Dùng domain riêng, Content-Disposition: attachment, X-Content-Type-Options: nosniff.

  • Triển khai đủ luồng presigned 3 bước, có HeadObject xác minh; ghim size/type vào chữ ký.

    Đáp án

    (1) API kiểm quyền, tạo PENDING, ký PUT có ContentType/ContentLength; (2) client PUT thẳng lên storage; (3) API HeadObject kiểm tồn tại và size rồi READY. Tự kiểm: confirm khi chưa upload phải 400. Presigned POST chỉ có trên AWS S3, R2 không hỗ trợ; HeadObject mới là chốt chặn thật.

  • Bucket private; không có object nào public-read.

    Đáp án

    Truy cập chỉ qua presigned URL hoặc credential. Tự kiểm: curl -I URL object không có chữ ký phải 403; AWS: kiểm Block Public Access và get-bucket-policy-status. Không có ACL/policy public-read.

  • Bật lifecycle abort multipart; giải thích được khoản phí ẩn nếu không bật.

    Đáp án

    Part của upload dở vẫn chiếm dung lượng tính phí nhưng không hiện trong danh sách object; rule AbortIncompleteMultipartUpload (ví dụ 7 ngày) dọn chúng. Tự kiểm: get-bucket-lifecycle-configuration thấy rule. Xem mục 8.

  • Xử lý nặng chạy trong worker, không trong request handler.

    Đáp án

    Handler chỉ enqueue và trả 202 + PROCESSING; worker resize/trích text/quét. Lý do: CPU-bound chặn event loop của cả server và vượt timeout proxy.

  • Có chiến lược chống orphan cả hai chiều (DB-first, xoá mềm, job đối soát).

    Đáp án

    Ghi DB PENDING trước khi ký URL (orphan DB dễ dọn, orphan S3 vô hình); xoá thì xoá mềm rồi purge object hoãn; cron dọn PENDING > 24 giờ và đối soát key S3 với DB (cảnh báo trước khi xoá tự động).

  • Giải thích vì sao multipart hoàn tất bằng ListParts của server thay vì danh sách ETag do client gửi.

    Đáp án

    Client không đáng tin: nó có thể gửi thiếu part, sai thứ tự hoặc part kích thước khác ý định. Server hỏi lại storage (ListParts, phân trang tối đa 1000 part mỗi trang), kiểm số part, thứ tự và Size từng part rồi mới CompleteMultipartUpload, và HeadObject kiểm tổng size. Phụ thêm: client không cần đọc header ETag, nên CORS không phải ExposeHeaders: ["ETag"] trừ khi thư viện client đòi.

Email

  • Tách transactional / marketing bằng subdomain riêng; giải thích được reputation lây chéo.

    Đáp án

    Transactional do hành động của một người; marketing gửi hàng loạt theo lịch của bạn. Reputation gắn với domain/IP gửi: chiến dịch bị báo spam kéo tụt cả mail reset password nếu dùng chung. Dùng mail. cho transactional, news. cho marketing, không gửi từ domain gốc.

  • Cấu hình SPF + DKIM + DMARC và đọc được header xác thực trong mail đã nhận.

    Đáp án

    Ba bản ghi DNS ở mục 13; kiểm bằng dig +short TXT ..., rồi gửi mail thật và đọc Authentication-Results (hoặc "Show original" của Gmail) thấy spf=pass, dkim=pass, dmarc=pass. Cần domain thật nên đây là bước tự làm ngoài code.

  • Phân biệt alignment của SPF với alignment của DKIM, và nói được adkim, aspf, sp đổi điều gì.

    Đáp án

    SPF so domain của MAIL FROM (Return-Path) với From:; DKIM so tag d=. DMARC pass khi ít nhất một cơ chế vừa pass vừa aligned. adkim và aspf chọn relaxed (r, mặc định, cùng Organizational Domain) hay strict (s, trùng chính xác). sp là chính sách cho subdomain không có bản ghi DMARC riêng. Tự kiểm: với From: news@myapp.com và d=mail.myapp.com, adkim=r aligned, adkim=s không.

  • Giải thích DMARC alignment và vì sao DKIM pass vẫn có thể DMARC fail.

    Đáp án

    DMARC pass khi SPF hoặc DKIM pass và domain xác thực khớp domain trong From:. Với chế độ relaxed mặc định, d=mail.myapp.com và From: ...@myapp.com vẫn khớp (cùng Organizational Domain). DKIM pass mà DMARC fail xảy ra khi d= là domain của provider, hoặc khi bản ghi DMARC đặt adkim=s (strict, phải trùng chính xác).

  • Gửi mail qua queue + outbox, có retry và idempotencyKey.

    Đáp án

    Ghi outbox cùng transaction với dữ liệu, relay đẩy vào queue, worker gửi và tự retry. idempotencyKey giúp retry sau timeout không gửi hai mail; thời hạn dedupe là tuỳ provider (đọc tài liệu provider, bài không khẳng định con số).

  • Có suppression list từ webhook bounce/complaint, kiểm tra trước mỗi lần gửi.

    Đáp án

    Webhook hard bounce/complaint ghi địa chỉ vào bảng; worker kiểm bảng trước mỗi lần gửi. Tự kiểm: gửi webhook giả có chữ ký đúng rồi thử gửi mail tới địa chỉ đó, mail bị bỏ qua.

  • Mail luôn có bản text kèm html.

    Đáp án

    HTML-only bị lọc spam chấm điểm cao hơn và một số client chỉ đọc text. Tự kiểm: mail trong Mailpit có cả tab HTML và Text.

  • Chống user enumeration ở reset password; chống header injection.

    Đáp án

    Reset luôn trả cùng một thông báo và cùng thời gian (đẩy việc sang worker); bỏ \r/\n khỏi giá trị đưa vào header, nội dung động chỉ ở body và escape HTML. Test: name = "Hi\r\nBcc: x@y.z" không tạo header Bcc.

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

    Đáp án

    crypto.randomBytes(32) base64url; lưu sha256(token); hết hạn 15 đến 60 phút; dùng một lần bằng UPDATE ... WHERE used_at IS NULL AND expires_at > now() kiểm số dòng. Sai thường gặp: lưu token thô (rò DB là reset được mọi tài khoản).

  • Mail marketing có List-Unsubscribe + List-Unsubscribe-Post một chạm; GET không huỷ đăng ký.

    Đáp án

    Một header List-Unsubscribe có URI https, header List-Unsubscribe-Post: List-Unsubscribe=One-Click, cả hai trong chữ ký DKIM. Endpoint POST không cookie, không redirect, trả 200 và ghi opt-out; GET chỉ hiện trang xác nhận vì bộ quét link tự gọi GET. Tự kiểm bằng ba lệnh curl ở Thực hành B6.


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Đ15.

    Hướng trả lời hiện tại (chưa chốt)

    Giữ client S3 với endpoint cấu hình được để đổi nhà cung cấp sau; chọn S3 hay R2 theo nơi deploy ở GĐ15 và chi phí tải xuống thực tế.

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

    Hướng trả lời hiện tại (chưa chốt)

    Chỉ quét virus 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.

    Hướng trả lời hiện tại (chưa chốt)

    Không nhận SVG cho tới khi có nhu cầu thật; khi có thì dùng thư viện sanitize có sẵn, khô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ướng trả lời hiện tại (chưa chốt)

    Giữ EmailPort để đổi provider; chọn provider sau khi đo khối lượng gửi và tỉ lệ bounce thực tế.