
Bảo mật webhook endpoint: từ một endpoint "trần trụi" đến một endpoint đáng tin
Table of Contents13 sections
Chào các bạn, mình là Tiến đến từ Classmethod Danang. Công việc hàng ngày của mình gắn nhiều với các hệ thống phân tán và tích hợp bên thứ ba, mà trong đó webhook gần như là thứ không thể tránh khỏi: cổng thanh toán, nhà cung cấp SMS, CI/CD, CRM, tất cả đều muốn "gọi lại" vào hệ thống của bạn.
Bài viết này mình muốn nói về một trường hợp cụ thể và cũng là trường hợp dễ gây thiệt hại nhất: endpoint webhook nhận kết quả giao dịch từ cổng thanh toán để cập nhật trạng thái thành công hoặc thất bại. Mình sẽ không đưa ra phương án tốt nhất ngay từ đầu, mà đi từ một endpoint đơn giản nhất rồi nâng cấp dần, mỗi bước giải quyết một lớp vấn đề. Cách đi này giúp bạn hiểu tại sao mỗi cơ chế tồn tại, thay vì copy một đoạn code verify signature mà không biết nó chống được gì.
Một endpoint nhỏ, quyền lực rất lớn
Hệ thống của chúng ta có luồng thanh toán quen thuộc:
Người dùng tạo đơn hàng, hệ thống tạo một transaction ở trạng thái
pending.Người dùng được redirect sang cổng thanh toán.
Cổng thanh toán xử lý, rồi gọi
POST /webhooks/paymentvề phía chúng ta để thông báo kết quả.Chúng ta cập nhật transaction thành
succeededhoặcfailed, rồi mở quyền sử dụng dịch vụ, trừ kho, ghi nhận doanh thu.
Phiên bản đầu tiên thường trông như thế này:
app.post('/webhooks/payment', async (req, res) => {
const { orderId, status, amount } = req.body;
await db.transactions.update(
{ orderId },
{ status, paidAmount: amount },
);
if (status === 'succeeded') {
await fulfillment.release(orderId);
}
res.sendStatus(200);
});Code này chạy được, demo trơn tru, và cũng là một trong những đoạn code nguy hiểm nhất trong hệ thống. Lý do rất đơn giản: đây là một endpoint public, không xác thực, và nó có quyền chuyển một đơn hàng từ "chưa trả tiền" sang "đã trả tiền".
Hãy thử liệt kê những gì có thể xảy ra:
Giả mạo hoàn toàn: bất kỳ ai biết URL đều có thể
curlvào và tự đánh dấu đơn hàng của mình là đã thanh toán. Và URL thì rất khó giữ kín: nó xuất hiện trong access log, trong dashboard của provider, trong tài liệu nội bộ, trong Slack, trong ảnh chụp màn hình lúc debug.Sửa nội dung: kẻ tấn công gửi đúng format nhưng với
amountnhỏ hơn, hoặcorderIdcủa đơn hàng khác.Replay: một request hợp lệ bị bắt lại và gửi lại nhiều lần. Nếu logic của bạn có cộng tiền vào ví hoặc cộng credit, hậu quả rất cụ thể.
Duplicate hợp pháp: bản thân provider cũng sẽ retry khi không nhận được
2xx. Cùng một event có thể đến hai, ba lần một cách hoàn toàn bình thường.Out of order: event
failed(phát sinh trước) có thể đến sau eventsucceededdo retry hoặc do network. Endpoint hiện tại sẽ ghi đè trạng thái cuối cùng bằng một trạng thái cũ.Quá tải: endpoint xử lý đồng bộ, gọi cả fulfillment bên trong. Provider timeout, retry, và bạn có một vòng lặp khuếch đại.

Vấn đề cốt lõi: server của bạn không có cách nào phân biệt request thật từ provider và request giả từ người khác. Mọi thứ phía sau đều là hệ quả của việc đó. Bây giờ chúng ta đi giải quyết dần.
Bước 1: Một shared secret trong header
Cải tiến đầu tiên mà ai cũng nghĩ tới: đặt một token bí mật, cấu hình trong dashboard của provider, và kiểm tra ở phía nhận.
app.post('/webhooks/payment', async (req, res) => {
if (req.get('X-Webhook-Token') !== process.env.WEBHOOK_TOKEN) {
return res.sendStatus(401);
}
// ...
});Đây là một bước tiến thật sự: kẻ tấn công ngẫu nhiên không còn gọi được endpoint. Nhưng nó vẫn còn nhiều lỗ hổng:
Token là bearer secret: ai có nó thì làm được mọi thứ. Nó đi kèm trong mọi request, nên chỉ cần một chỗ log request header, một proxy trung gian ghi log, hoặc một lần chia sẻ file cấu hình là secret rò rỉ.
Nếu bạn để token trong query string (
?token=abc) thì gần như chắc chắn nó sẽ nằm trong access log của load balancer, trong Referer, trong hệ thống APM.Token không nói gì về nội dung request. Nó chứng minh "người gửi biết secret", chứ không chứng minh "payload này chưa bị sửa".
So sánh bằng
!==là so sánh không constant-time, về lý thuyết mở ra timing attack. Với secret dài thì rủi ro thực tế thấp, nhưng đã làm thì làm cho đúng.
Nói cách khác, bước 1 giải quyết được "ai cũng gọi được", nhưng chưa giải quyết được "nội dung có toàn vẹn hay không" và "secret có bền không".
Bước 2: Whitelist IP addresses
Hầu hết cổng thanh toán đều công bố dải IP mà họ gọi webhook từ đó. Chặn ở tầng edge (WAF, security group, ingress) là một lớp phòng vệ rẻ và hiệu quả:
allow 203.0.113.0/24;
allow 198.51.100.10;
deny all;Điểm mạnh là nó loại bỏ toàn bộ nhiễu từ internet trước khi request chạm vào application, giảm cả bề mặt tấn công lẫn chi phí xử lý.
Nhưng đừng coi đây là cơ chế xác thực chính:
Danh sách IP của provider sẽ đổi, và thường thông báo qua email mà không ai đọc. Đây là nguồn của những sự cố kiểu "webhook đột nhiên ngừng chạy từ 3 giờ sáng".
Nếu bạn tin vào
X-Forwarded-Formà không cấu hìnhtrust proxyđúng, header này có thể bị giả mạo.Nó không bảo vệ bạn khỏi nội dung sai. Nếu chính request từ IP hợp lệ bị chỉnh sửa trên đường đi, hoặc bạn dùng chung một provider với nhiều tenant, IP không phân biệt được gì.
IP allowlist là một bước phòng thủ nhiều lớp theo chiều sâu (defense-in-depth), không phải authentication. Chúng ta cần một thứ gắn chặt với nội dung.
Bước 3: HMAC signature, phần quan trọng nhất
Đây là bước làm thay đổi bản chất vấn đề. Provider và bạn cùng biết một secret. Với mỗi request, provider tính một mã xác thực dựa trên toàn bộ body và secret đó, rồi gửi kèm trong header. Bạn tính lại và so sánh.
Ý tưởng thì đơn giản, nhưng phần lớn lỗi bảo mật webhook trong thực tế nằm ở chi tiết triển khai. Đây là bản làm đúng:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function verifySignature(req, res, next) {
const received = req.get('X-Payment-Signature');
if (!received) return res.sendStatus(401);
// Ký trên raw bytes, không phải trên object đã parse
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
req.event = JSON.parse(req.body.toString('utf8'));
next();
}
app.post(
'/webhooks/payment',
express.raw({ type: 'application/json', limit: '256kb' }),
verifySignature,
handlePaymentWebhook,
);Vài điểm cần nhấn mạnh, vì đây là nơi mọi người vấp:
Phải ký trên raw body. Nếu bạn dùng express.json() rồi JSON.stringify(req.body) để tính HMAC, bạn đang tính trên một chuỗi khác với chuỗi mà provider đã ký: thứ tự key có thể đổi, khoảng trắng mất, Unicode escape khác, số 1.0 thành 1. Kết quả là signature sai một cách ngẫu nhiên và bạn sẽ mất nửa ngày để debug. Hãy giữ raw buffer, verify trước, parse sau. Trong Laravel hãy dùng $request->getContent(); trong Spring hãy dùng ContentCachingRequestWrapper hoặc đọc từ InputStream.
Phải so sánh constant-time. timingSafeEqual trong Node, hash_equals trong PHP, hmac.compare_digest trong Python. Không dùng ==.
Phải kiểm tra độ dài trước khi so sánh. timingSafeEqual throw nếu hai buffer khác độ dài, và một exception không được bắt sẽ trả về 500, khiến provider retry mãi.
Không tiết lộ lý do thất bại. Trả về 401 gọn gàng. Đừng trả về "signature mismatch, expected abc123", vì đó chẳng khác gì hở sườn cho đối thủ ra đòn chí mạng cả.
Secret là secret. Nằm trong secret manager, không nằm trong repo, khác nhau giữa các môi trường. Nếu staging và production dùng chung secret, một lần rò rỉ ở staging là rò rỉ ở production.

Sau bước này, chúng ta đã chứng minh được hai điều: request đến từ người biết secret, và payload chưa bị sửa. Nghe như đã xong. Nhưng chưa.
Bước 4: Chống replay bằng timestamp
Một signature hợp lệ vẫn hợp lệ mãi mãi. Nếu kẻ tấn công bắt được một request thành công (từ log, từ một proxy, từ một máy bị nhiễm), họ có thể gửi lại nguyên văn cả body và signature, và mọi kiểm tra ở bước 3 đều pass.
Cách xử lý là đưa thời gian vào phần được ký. Provider gửi thêm một header timestamp và ký trên chuỗi timestamp + body, còn bạn từ chối những request quá cũ:
const TOLERANCE_SECONDS = 300;
function verifySignature(req, res, next) {
const timestamp = Number(req.get('X-Payment-Timestamp'));
const received = req.get('X-Payment-Signature');
if (!timestamp || !received) return res.sendStatus(401);
const skew = Math.abs(Date.now() / 1000 - timestamp);
if (skew > TOLERANCE_SECONDS) return res.sendStatus(401);
const signedPayload = `${timestamp}.${req.body.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// so sánh constant-time như bước 3
// ...
}Lưu ý là timestamp phải nằm trong phần được ký, nếu không kẻ tấn công chỉ cần sửa header timestamp là xong.
Khung thời gian TOLERANCE_SECONDS là một đánh đổi, hẹp thì an toàn hơn nhưng dễ false negative khi server lệch giờ hoặc provider retry chậm, rộng thì ngược lại. Năm phút là con số phổ biến, và điều kiện tiên quyết là server của bạn phải đồng bộ NTP. Rất nhiều sự cố "signature valid nhưng timestamp reject" chỉ là do đồng hồ trong server nó không đồng bộ với đồng hồ của hệ thống khác.
Timestamp thu hẹp replay window xuống còn vài phút, nhưng chưa đóng hẳn. Để đóng hẳn, ta cần nhớ những event đã thấy:
const key = `webhook:seen:${event.id}`;
const isNew = await redis.set(key, '1', { NX: true, EX: 900 });
if (!isNew) return res.sendStatus(200); // đã xử lý, trả 200 để provider dừng retryChú ý chi tiết nhỏ nhưng quan trọng: với event trùng lặp, hãy trả 200, không phải 409 hay 400. Provider chỉ cần biết "bạn đã nhận rồi".

Bước 5: Idempotency thật sự, không chỉ là cache
Redis với TTL 15 phút chống được replay, nhưng nó không phải là bảo đảm idempotency cho business logic. Provider có thể retry sau một giờ. Team vận hành có thể replay event thủ công từ dashboard sau một ngày để khắc phục sự cố. Nếu handler của bạn cộng tiền vào ví, một lần chạy lặp là một lần sai số dư.
Bảo đảm bền vững nằm ở database, dưới dạng một ràng buộc mà race condition không thể lách qua:
CREATE TABLE processed_webhook_events (
provider TEXT NOT NULL,
event_id TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);Và xử lý trong cùng một database transaction với business logic:
await db.transaction(async (tx) => {
const inserted = await tx.query(
`INSERT INTO processed_webhook_events (provider, event_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING event_id`,
['payment-gw', event.id],
);
if (inserted.rowCount === 0) return; // đã xử lý trước đó
await applyTransactionResult(tx, event);
});Điểm mấu chốt là việc ghi nhận "đã xử lý" và việc thay đổi dữ liệu nghiệp vụ phải cùng commit hoặc cùng rollback. Nếu bạn ghi nhận trước rồi xử lý sau và crash ở giữa, event bị mất vĩnh viễn. Nếu bạn xử lý trước rồi ghi nhận sau và crash ở giữa, event bị xử lý hai lần.
Đây cũng là lý do hai request cùng event_id đến song song (chuyện rất bình thường khi provider retry một cách thô bạo) không gây double-spend, primary key sẽ chặn một trong hai.
Bước 6: Đừng để event cũ ghi đè trạng thái mới
Idempotency xử lý event trùng nhau. Còn một vấn đề khác là các event khác nhau đến sai thứ tự.
Kịch bản thực tế như giao dịch bị failed do timeout ở lần thử đầu, người dùng thử lại và succeeded. Provider gửi hai event, nhưng do retry, event failed đến sau. Endpoint của bạn ghi status = 'failed' và người dùng đã trả tiền nhưng không được mở dịch vụ.
Có hai cơ chế, nên dùng cả hai:
Một, dùng thời điểm của provider để bảo vệ. Mỗi event đều có timestamp riêng của nó (thời điểm sự kiện xảy ra, không phải thời điểm gửi). Chỉ cập nhật nếu event mới hơn cái đã ghi:
UPDATE transactions
SET status = $1,
provider_event_at = $2
WHERE id = $3
AND provider_event_at < $2;Hai, dùng state machine. Định nghĩa rõ trạng thái nào là terminal và chuyển đổi nào là hợp lệ. Một giao dịch đã succeeded không thể tự nhiên trở về pending:
const ALLOWED = {
pending: ['authorized', 'failed', 'canceled'],
authorized:['succeeded', 'failed'],
succeeded: ['refunded'],
failed: [],
refunded: [],
};
if (!ALLOWED[current.status].includes(event.status)) {
logger.warn({ event, current }, 'transition không hợp lệ, bỏ qua');
return res.sendStatus(200);
}State machine còn có một tác dụng phụ rất giá trị là nó biến những bất thường thành log có thể quan sát được, thay vì thành dữ liệu sai âm thầm.
Bước 7: Đừng tin payload, hãy tin Source of Truth
Đến đây hệ thống đã khá vững. Nhưng vẫn còn một câu hỏi khó chịu: nếu secret bị rò rỉ thì sao? Toàn bộ chuỗi phòng vệ ở trên sụp cùng lúc, vì kẻ tấn công ký được request hợp lệ.
Cách xử lý là thay đổi vai trò của webhook. Thay vì coi payload là dữ liệu thẩm quyền, hãy coi webhook chỉ là một tín hiệu rằng có thứ gì đã thay đổi, rồi tự đi hỏi provider về trạng thái thật:
async function handlePaymentWebhook(req, res) {
const { id: eventId, transactionId } = req.event;
// Payload chỉ cho biết cần kiểm tra transaction nào
const authoritative = await gateway.getTransaction(transactionId);
await applyTransactionResult({
eventId,
status: authoritative.status,
amount: authoritative.amount,
currency: authoritative.currency,
});
res.sendStatus(200);
}Mô hình này (thường gọi là thin webhook) đắt hơn một API call, nhưng đổi lại:
Số tiền và trạng thái luôn lấy từ hệ thống của provider qua kênh TLS mà bạn chủ động khởi tạo và xác thực. Kẻ tấn công có secret cũng chỉ kích hoạt được một lần kiểm tra, không tự quyết định được kết quả.
Bạn không phụ thuộc vào việc payload có đầy đủ field hay không, và ít bị ảnh hưởng khi provider đổi schema.
Với luồng thanh toán, mình nghiêng về việc luôn dùng cách này. Riêng phần đối chiếu số tiền thì bắt buộc: luôn so amount và currency với đơn hàng trong hệ thống của bạn trước khi đánh dấu đã trả tiền. Đây là lỗi kinh điển và cũng là lỗi rẻ nhất để phòng.
Bổ sung thêm một job đối chiếu định kỳ (mỗi 15 phút quét các transaction pending quá lâu và hỏi lại provider) sẽ giúp bạn tự phục hồi khi webhook bị mất, bị chặn, hoặc khi endpoint của bạn down. Webhook là at-least-once delivery, nhưng không phải guaranteed delivery.
Bước 8: Tách việc nhận và việc xử lý
Một endpoint webhook tốt nên làm rất ít việc: verify, ghi nhận, trả 200. Mọi thứ nặng nề đẩy sang worker:
async function handlePaymentWebhook(req, res) {
await inbox.insert({ eventId: req.event.id, payload: req.body });
res.sendStatus(200); // trả nhanh, thường dưới 200ms
}Lý do không chỉ là hiệu năng. Provider thường có timeout ngắn (5 tới 10 giây) và sẽ retry khi timeout. Nếu handler của bạn gọi ba service nội bộ, gửi email và tạo invoice PDF, thì chỉ cần một service chậm là bạn nhận thêm một bản sao của cùng event, và một hàng đợi retry ngày càng dài. Đây là cách một sự cố nhỏ trở thành sự cố lớn.
Kèm theo đó là những biện pháp hardening ở tầng vận hành, không hào nhoáng nhưng cần thiết:
Giới hạn kích thước body (ví dụ 256KB) để chặn payload phình to gây cạn RAM.
Rate limit trên endpoint, kể cả khi nó chỉ dành cho provider. Ngưỡng nên đặt cao hơn peak thực tế của provider.
Chỉ HTTPS, TLS 1.2 trở lên, và chỉ nhận
POSTvới đúngContent-Type.Timeout cho mọi call ra ngoài trong handler, để một provider chậm không giữ connection pool của bạn.
Log và alert trên tỉ lệ verify thất bại. Một endpoint webhook lành mạnh gần như không bao giờ trả
401. Khi tỉ lệ này tăng, hoặc secret đã bị rotate mà bạn chưa cập nhật, hoặc có người đang thăm dò. Cả hai đều cần biết ngay.Dead letter queue cho event xử lý thất bại, kèm khả năng replay thủ công. Và vì bạn đã có idempotency ở bước 5, việc replay là an toàn.
Bước 9: Những lớp cuối cùng
Ba thứ nên có khi hệ thống đã đi vào production ổn định:
Rotate secret không downtime. Chấp nhận nhiều secret cùng lúc trong giai đoạn chuyển tiếp, verify với từng cái, pass là được:
const secrets = process.env.WEBHOOK_SECRETS.split(','); // "new,old"
const valid = secrets.some((secret) => {
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return safeCompare(expected, received);
});Nếu không thiết kế sẵn khả năng này, đến lúc cần rotate gấp (ví dụ khi nghi ngờ rò rỉ) bạn sẽ phải chọn giữa downtime và trì hoãn, và cả hai đều tệ.
Asymmetric signature. Một số provider ký bằng private key và publish public key. Ưu điểm lớn: phía bạn không giữ bất kỳ secret nào có thể dùng để tạo signature giả. Nếu provider hỗ trợ, hãy chọn cách này thay cho HMAC. Chuẩn Standard Webhooks cũng đang đi theo hướng cung cấp cả hai lựa chọn với format header thống nhất.
mTLS. Khi làm việc với đối tác doanh nghiệp hoặc trong môi trường tài chính, xác thực hai chiều ở tầng TLS là lớp phòng vệ mạnh nhất, đổi lại là chi phí vận hành certificate. Không phải hệ thống nào cũng cần, nhưng nếu yêu cầu compliance của bạn nhắc tới nó, hãy làm ngay từ đầu thay vì bổ sung sau.

Đánh đổi: Cái gì bắt buộc, cái gì tuỳ bối cảnh
Không phải mọi webhook đều cần cả chín bước. Với endpoint thanh toán thì mình phân loại như sau:
Bắt buộc, không thương lượng:
HMAC hoặc asymmetric signature verify trên raw body, so sánh constant-time.
Timestamp trong phần được ký, cùng cửa sổ dung sai.
Idempotency ở tầng database, cùng transaction với business logic.
Đối chiếu
amountvàcurrencyvới đơn hàng nội bộ.Bảo vệ chống ghi đè bằng trạng thái cũ.
Nên có với hầu hết hệ thống production:
Trả
200nhanh và xử lý bất đồng bộ.Job đối chiếu định kỳ.
Alert trên tỉ lệ verify thất bại.
Khả năng rotate secret không downtime.
Giới hạn body size và rate limit.
Tuỳ bối cảnh:
IP allowlist: tốt nếu provider có dải IP ổn định, nhưng cần quy trình theo dõi thay đổi.
Thin webhook: rất khuyến khích cho thanh toán, có thể quá mức cho webhook thông báo nội bộ ít quan trọng.
mTLS: chỉ khi yêu cầu compliance hoặc hợp đồng đối tác đòi hỏi.
Một nguyên tắc mình hay dùng khi review: hỏi xem cơ chế này chống được kịch bản nào. Nếu không trả lời được, đó là nghi thức chứ không phải bảo mật. Ví dụ, một URL webhook dài và khó đoán không phải là biện pháp bảo mật, nó chỉ làm bạn cảm thấy an toàn hơn.
Checklist trước khi lên production
Copy và Paste vào Agent Skills của dự án ngay đi chớ còn chần chừ gì nữa.
[ ] Verify signature trên raw body, chưa qua parse
[ ] So sánh constant-time, kiểm tra độ dài trước
[ ] Timestamp nằm trong signed payload, cửa sổ 5 phút, NTP đã đồng bộ
[ ] Trả 401 không kèm chi tiết lỗi
[ ] Secret trong secret manager, khác nhau giữa các môi trường
[ ] Hỗ trợ nhiều secret để rotate không downtime
[ ] Bảng processed_webhook_events với primary key (provider, event_id)
[ ] Ghi nhận event và business logic trong cùng một DB transaction
[ ] Event trùng lặp trả 200, không trả 4xx
[ ] Kiểm tra transition hợp lệ theo state machine
[ ] Chặn ghi đè bằng provider_event_at
[ ] So khớp amount và currency với đơn hàng nội bộ
[ ] Trạng thái cuối lấy từ API của provider, không chỉ từ payload
[ ] Trả 200 dưới 1 giây, xử lý nặng đẩy sang queue
[ ] Giới hạn body size, rate limit, chỉ HTTPS, chỉ POST
[ ] Dead letter queue và khả năng replay thủ công
[ ] Job đối chiếu định kỳ cho transaction pending quá lâu
[ ] Alert khi tỉ lệ verify thất bại tăng bất thường
[ ] Test: request không signature, sai signature, replay, out of order, duplicate song songDòng cuối cùng đáng được nhấn mạnh. Bảo mật webhook là loại code mà nếu không có test cụ thể cho từng kịch bản tấn công, bạn sẽ không biết nó đã hỏng cho tới khi có người khai thác. Mỗi bước trong bài này nên tương ứng với ít nhất một test case.
Kết
Nhìn lại, hành trình chúng ta vừa đi qua có một logic khá rõ: xác thực người gửi, rồi bảo đảm toàn vẹn nội dung, rồi giới hạn thời gian hiệu lực, rồi chấp nhận rằng event sẽ đến nhiều lần và sai thứ tự, và cuối cùng là không đặt toàn bộ niềm tin vào một secret duy nhất.
Điều mình muốn bạn mang về không phải đoạn code verify HMAC, mà là cách đặt câu hỏi ở mỗi bước: "nếu thứ này rơi vào tay người khác thì sao?" Endpoint webhook là nơi hệ thống của bạn cho phép một hệ thống bên ngoài thay đổi dữ liệu quan trọng nhất. Nó đáng được đối xử như một API nội bộ đặc quyền, không phải như một cái hộp thư công cộng.
Nếu bạn đang có một endpoint webhook thanh toán trong production, mình gợi ý một việc rất cụ thể để làm hôm nay: mở terminal, curl vào endpoint đó với một payload tự viết, và xem điều gì xảy ra. Kết quả sẽ cho bạn biết cần bắt đầu từ bước nào.
Cảm ơn bạn đã đọc tới đây.