SSRF qua bot Playwright: khi REST API validate kỹ, còn background worker thì... quên
Khi xây hệ thống có background worker, queue, hay cron job, chúng ta thường dồn toàn bộ tâm sức validate ở chỗ "công khai nhất" — REST API ngoài cùng. Logic rất hợp lý: chặn từ entry point, mọi thứ bên trong coi như "đã sạch".
Nhưng có một sự thật trớ trêu: chính tư duy "validate ở entry point là đủ" lại sinh ra một dạng SSRF (Server-Side Request Forgery) kinh điển. Và trong dự án của tôi, kẻ "vô tình tiếp tay" cho lỗ hổng này lại chính là... một con bot Playwright vô tội đang ngồi đợi cron đánh thức.
Tóm tắt nhanh
- Gót chân Achilles: REST endpoint validate URL Google Meet đầy đủ. Nhưng cron auto-join lại đọc URL từ DB và pass thẳng vào Playwright không validate lại lần nào.
- Rủi ro: Attacker submit
http://169.254.169.254/latest/meta-data/iam/security-credentials/→ bot ngoan ngoãn navigate → AWS instance metadata + IAM credential chui ra log debug. - Vì sao xảy ra: Hai code path đi vào cùng một "sink" (Playwright
page.goto) nhưng validation chỉ đặt ở một path. Async pipeline qua DB/queue là điểm mù điển hình. - Bài học cốt lõi: Validate trước sink, không phải tại entry. Trust boundary nằm ngay trước hành động nguy hiểm, không phải ở rìa hệ thống.
Nếu bạn đang code bất kỳ hệ thống nào có background worker đọc data từ DB rồi gọi service ngoài, hãy nán lại đọc tới hết. Pattern này lan rộng hơn bạn nghĩ.
Bối cảnh: Hai con đường, một cái đích, một bộ "vệ sĩ"
Để dễ hình dung, đây là kiến trúc mà tôi đang nói tới:
Path 1: REST API ──────────┐
✅ validate URL │
▼
┌────────────┐
│ Playwright │
│ page.goto │ ◀── SINK
└────────────┘
▲
Path 2: Cron auto-join ────┘
❌ KHÔNG validate
(đọc URL thẳng từ DB)
Hai con đường (path) cùng đi tới một việc duy nhất: gọi page.goto(meetingUrl) trong Playwright.
-
Path 1 — REST API: User POST trực tiếp lên
/api/sessions/[code]/meet-bot. Endpoint này có validation đầy đủ:protocol === 'https:', hostname phải khớp^meet\.google\.com$. Sạch sẽ. -
Path 2 — Cron auto-join: Khi đến giờ
scheduled_start_time, queue-worker quét DB tìm các session cần join, đọcsession.meet_linktừ DB rồi đẩy thẳng cho meet-bot service:
// packages/queue-worker/src/processors/meet-bot-auto-join.ts
meeting_url: session.meet_link, // ❌ thẳng từ DB, không validate
...
meetingUrl: session.meet_link, // ❌ gửi cho meet-bot
Code này nhìn thì "an lành" — chỉ là 2 dòng nối từ DB tới service nội bộ. Vấn đề là nó đang giả định: "URL trong DB đã từng đi qua REST validation rồi nên chắc chắn an toàn." Và đây chính là giả định chí mạng.
Vấn đề: Trust boundary đặt sai chỗ
Hãy đặt câu hỏi đơn giản: session.meet_link trong DB đến từ đâu?
Trong dự án này, có ít nhất 3 đường ghi vào trường đó:
- User tạo session qua web (đi qua một schema validator — Zod, nhưng schema này không strict pattern
meet.google.com). - Import session từ Google Calendar (sync background — URL được Google "cấp" nhưng không có guarantee).
- Edit session sau khi tạo (PATCH endpoint, validation lỏng hơn POST ban đầu).
Chỉ cần một trong các đường đó bỏ sót, hoặc một dev sau này thêm path mới mà quên check — DB sẽ có URL "xấu" nằm chờ cron đánh thức.
Đây là vấn đề kinh điển của tư duy "validate ở rìa hệ thống":
| Tư duy "rìa" (sai) | Tư duy "trước sink" (đúng) |
|---|---|
| "Tôi đã chặn ở REST API rồi" | "Tôi chặn ngay trước khi gọi nguy hiểm" |
| Trust boundary ở edge | Trust boundary ngay trước hành động nguy hiểm |
| Cron/queue/worker coi như "vùng tin tưởng" | Mọi data đi qua DB/queue đều coi là "untrusted" |
| Validation cần ở 1 chỗ | Validation cần ở MỖI chỗ tới gần sink |
DB và queue không phải là vùng tin tưởng. Đó là kênh truyền data — không có khả năng tự validate nội dung. Bất kỳ data nào ra khỏi DB phải được coi như input mới từ một nguồn không xác định.
Demo exploit: AWS IMDS bay thẳng vào log debug
Lý thuyết thì vậy, thực tế thì đáng sợ hơn nhiều. Đây là chuỗi tấn công đầy đủ mà attacker có thể thực hiện:
Bước 1: Attacker đăng ký user thường, tạo session
POST /api/sessions
Content-Type: application/json
Authorization: Bearer {user-token}
{
"code": "evil-session",
"scheduled_start_time": "2026-05-22T19:50:00Z",
"meet_link": "https://meet.google.com/abc-defg-hij"
}
Tới đây hợp lệ. Session được tạo trong DB với URL Google Meet bình thường.
Bước 2: Attacker PATCH lại meet_link qua endpoint update (chỗ validation lỏng hơn)
PATCH /api/sessions/evil-session
Content-Type: application/json
Authorization: Bearer {user-token}
{
"meet_link": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
Endpoint này — vì lý do "không strict pattern như POST ban đầu" — cho phép URL bất kỳ chui vào DB. Validation Zod chỉ check string().url(), không check hostname.
Bước 3: Cron auto-join thức dậy, không biết gì cả
// queue-worker quét DB, thấy session sắp tới giờ
const session = await db.query('SELECT * FROM sessions WHERE ...');
// Đẩy URL cho meet-bot
await meetBotService.startBot({
meetingUrl: session.meet_link, // ← AWS IMDS URL trá hình
});
Bước 4: Playwright ngoan ngoãn page.goto(...)
await page.goto(meetingUrl);
// Page nội dung bây giờ là response từ AWS IMDS
const debugText = await page.evaluate(() => document.body.innerText);
dbg({ type: 'debug', content: debugText }); // ← Log mọi DOM text
Bot Playwright chạy trong container không có metadata service block (vì dev đâu nghĩ tới chuyện chặn IMDS — cần gì khi chỉ join Meet?). Container có IAM role gắn vào instance. AWS IMDS trả về:
{
"Code": "Success",
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJalrXUtnFEMI/...",
"Token": "FwoGZXIvYXdzEN...",
"Expiration": "2026-05-22T22:30:00Z"
}
Bước 5: Credential bay vào log debug
Bot-bridge của hệ thống có hàm dbg() log mọi event ra structured logs. Logs đẩy lên Datadog / CloudWatch — nơi mà tùy config có thể search được bởi nhiều người. Attacker (hoặc insider) chỉ cần grep AccessKeyId là có vé vào AWS account của bạn.
Toàn bộ chuỗi: chỉ cần 1 user thường + 1 request PATCH. Không cần admin, không cần SSH, không cần exploit Chromium. Chỉ cần một logic gap giữa "REST validate" và "cron không validate".
Đây không phải kịch bản trên giấy — hãy nhớ vụ Capital One
Nếu bạn đang nghĩ "chuyện này nghe vẽ vời quá", xin mời nhìn lại lịch sử. Tháng 7/2019, Capital One — một trong những ngân hàng lớn nhất nước Mỹ — bị hack lộ ~100 triệu hồ sơ khách hàng. Vector tấn công? Chính xác là cái mà tôi vừa demo: SSRF kết hợp với AWS IMDS.
Một WAF cấu hình sai cho phép attacker gửi request giả mạo qua nó, request đó hit IMDS, IMDS trả về temporary credentials của IAM role gắn vào instance, attacker dùng credentials đó list/download S3 buckets. Kết quả:
- 100+ triệu hồ sơ khách hàng (số an sinh xã hội, số tài khoản, lịch sử tín dụng).
- $190 triệu tiền phạt regulatory + class action settlement.
- $80 triệu phạt riêng từ OCC.
- Hàng năm trời lao đao trên truyền thông.
Sự khác biệt giữa Capital One và dự án meeting translation của tôi? Gần như bằng không. Họ có WAF làm "request proxy"; tôi có Playwright làm "URL fetcher". Cả hai đều là một component nội bộ ngoan ngoãn forward request mà không suy nghĩ. Cả hai đều có thể bị một input "đáng tin trên giấy tờ" lừa cho navigate tới 169.254.169.254.
Playwright tự động fetch URL kia nguy hiểm không kém gì WAF của Capital One đâu. Khác chỉ là, may cho tôi, một reviewer đã chặn được nó trước khi merge.
Còn chỗ nào đáng sợ hơn 169.254.169.254?
AWS IMDS chỉ là ví dụ điển hình. Cùng pattern khai thác được:
| URL độc hại | Hậu quả |
|---|---|
http://169.254.169.254/... |
AWS IMDS — leak IAM credential, instance role |
http://metadata.google.internal/... |
GCP metadata — service account token |
http://169.254.169.254/metadata/instance?api-version=2021-02-01 (with header) |
Azure IMDS — managed identity token |
http://10.0.0.1:5432/ |
DB internal — scan port, banner grab |
http://localhost:6379/ |
Redis nội bộ — đôi khi cho phép gửi command qua HTTP smuggling |
file:///etc/passwd |
Local file read (nếu Chromium cho phép, hiếm nhưng có) |
http://internal-admin.local/... |
Internal admin panel scan |
Càng đáng sợ khi nhớ rằng Playwright dùng Chromium — một browser engine đầy đủ — không phải curl. Nó execute JavaScript, follow redirect, render DOM. Một số endpoint nội bộ trả HTML với <script> chứa token → token chui vào DOM → vào log.
CI/CD pipeline cũng dính dáng nha
Cảnh báo thêm: không chỉ production. CI runner thường có:
- Service account credentials gắn sẵn.
- Access tới registry/artifacts.
- Network access tới internal services.
Nếu test integration chạy bot Playwright thật và bot navigate URL "test" được hardcode trong fixture — và fixture đó bị chỉnh sửa qua PR review không kỹ — boom, CI bay credential.
Bài học: bất kỳ component nào gọi goto, fetch, curl từ data động đều cần validation trước sink, kể cả trong môi trường test.
Cứu cánh đầu tiên: Validate ngay trước sink
Fix đúng bài chỉ tốn 4 dòng code:
function isValidMeetUrl(raw: string | null | undefined): boolean {
if (!raw) return false;
try {
const url = new URL(raw);
return url.protocol === 'https:' &&
/^meet\.google\.com$/.test(url.hostname);
} catch {
return false;
}
}
async function startBotForSession(session) {
if (!isValidMeetUrl(session.meet_link)) {
console.error(`[AutoJoin] Invalid meet_link for session ${session.code} — skipping`);
return false;
}
// ... rest of the function
}
Quan trọng: logic validate phải là cùng một hàm dùng chung với REST endpoint. Đừng copy-paste — chỉ một bên update mai sau, hệ thống lại lệch pha.
export function isValidMeetUrl(raw: string | null | undefined): boolean {
// ... cùng implementation
}
// Cả REST API và queue worker đều import từ đây.
Nhưng đừng tưởng "thêm validation là xong"
Vài điểm bạn cần khắc cốt ghi tâm:
-
Validate ở mọi sink, không chỉ một: Trong dự án này còn có
bot-auth-refresher.tscũng gọi Playwright. Auth refresher cũng cần validation tương tự cho mọi URL nó navigate. -
Validation pattern phải nghiêm:
string().url()là không đủ. Phải check protocol + hostname. Allow-list, không phải block-list. -
Cẩn thận với URL parser confusion: Đây là cái bẫy mà rất nhiều dev rơi vào. Hãy thử nhìn URL sau:
https://meet.google.com@169.254.169.254/latest/meta-data/Một số validation code "ngây thơ" sẽ nghĩ hostname là
meet.google.comvì.includes('meet.google.com')trả vềtrue. Nhưng theo RFC 3986,meet.google.comở đây là userinfo (phầnuser:password@), hostname thật là169.254.169.254. May là NodeURL(WHATWG) parse đúng —url.hostname === '169.254.169.254'. Nhưng nếu bạn dùngString.includeshay regex tự chế, bạn dính chấu ngay. Luôn dùngURLparser, luôn checkhostname(không phải full string). -
Cẩn thận DNS rebinding: Attacker đặt
evil.com→ resolve về169.254.169.254sau khi qua validation. Phòng vệ: resolve DNS một lần, dùng IP chopage.goto, hoặc block private IP ranges ở network layer. -
Log sanitization: Ngay cả khi
gotothành công với URL hợp lệ,dbg()đừng log full DOM text. Một meeting Meet công khai cũng có thể leak data người khác.
Giải pháp triệt để cho người chơi hệ "không khoan nhượng"
Nếu app bạn handle thực sự nhạy cảm, đừng dừng ở "validate URL". Hãy nghĩ tới defense in depth:
1. Network-level egress block — bức tường thứ hai
Block toàn bộ outbound từ container Playwright trừ Google Meet domains:
# docker-compose.yml hoặc Kubernetes NetworkPolicy
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.0.0/16 # Cloud metadata
- 10.0.0.0/8 # Internal RFC1918
- 172.16.0.0/12
- 192.168.0.0/16
- 127.0.0.0/8
ports:
- protocol: TCP
port: 443
- to:
- dnsName: meet.google.com
- dnsName: "*.google.com"
Kể cả URL chui qua validation, network sẽ chặn. Hai lớp luôn an tâm hơn một.
2. Disable IMDS hoặc dùng IMDSv2
Trên AWS, bắt buộc IMDSv2 (token-based) — IMDSv1 vốn là "GET là ra credential". IMDSv2 yêu cầu PUT request trước để lấy token, mà Playwright goto chỉ GET → tự khắc chống được:
aws ec2 modify-instance-metadata-options \
--instance-id i-xxx \
--http-tokens required \
--http-put-response-hop-limit 1
Trên GCP/Azure, dùng workload identity thay vì legacy metadata.
3. Run Playwright dưới user/network namespace tách biệt
Nếu paranoid hơn nữa: chạy Playwright trong namespace network riêng, chỉ có route ra Google Meet, không thấy IMDS, không thấy private network.
4. Audit log mọi URL Playwright navigate
Log riêng từng URL navigate (kèm session ID, user ID, source path) vào audit table. Khi có incident, trace lại được ai/khi nào/qua path nào.
Chốt nhanh: bạn nên làm gì nếu phát hiện pattern này trong code mình?
| Tình huống | Action |
|---|---|
| Có 1 sink (page.goto, fetch, exec) gọi từ 2+ path | Validate sát sink, dùng chung helper |
| Worker đọc URL/path từ DB rồi gọi service ngoài | Re-validate, coi DB như untrusted input |
| Production AWS với IMDSv1 | Bật IMDSv2 ngay hôm nay |
| Container Playwright/headless browser | Network policy block private IP ranges |
| Log debug có DOM text/response body | Sanitize trước khi log, hoặc bỏ luôn |
✅ Checklist tự rà trước khi merge
Nếu bạn đang code hệ thống có background worker / queue / cron / scheduled job, dừng tay 5 phút và check:
- [ ] Mọi sink "nguy hiểm" (page.goto, fetch, exec, file read) đều có validation ngay trước sink, không phải chỉ ở entry point.
- [ ] Validation logic được share giữa các path (1 helper, nhiều caller) — không copy-paste.
- [ ] Validation dùng allow-list cho protocol + hostname, không phải
string().url()đơn thuần. - [ ] Hostname check dùng
URLparser + sourl.hostnamechính xác, không dùngString.includeshay regex tự chế. - [ ] DNS rebinding được cân nhắc — resolve một lần, dùng IP, hoặc block private ranges.
- [ ] Container chạy headless browser có network policy chặn metadata/private ranges.
- [ ] IMDSv2 được bật (AWS) hoặc workload identity (GCP/Azure).
- [ ] Log debug không dump full DOM/response body của trang đã navigate.
- [ ] CI fixture và test data không chứa URL bất kỳ — chỉ URL hợp lệ.
- [ ] Code review checklist của team bao gồm rule: "data từ DB/queue tới sink phải re-validate".
Lời kết: bài học cho mọi hệ thống có "vùng tin tưởng"
Vụ này dạy tôi một điều: "trust boundary" không phải là một vòng tròn quanh hệ thống — nó là vô số đường kẻ ngay trước mỗi sink nguy hiểm.
Khi bạn nghĩ "data trong DB đã sạch", thực ra bạn đang giả định cả lịch sử mọi code path từng ghi vào DB đó luôn đúng đắn — kể cả code path do dev nào đó sẽ viết 6 tháng sau. Đó là một giả định không đáng tin.
Hãy coi mỗi sink nguy hiểm như một biên giới riêng. Mỗi lần data đi qua biên giới, kiểm tra hộ chiếu một lần nữa. Chỗ kiểm tra càng gần biên giới càng tốt — đừng tin vào dấu mộc đóng từ 3 tháng trước ở một biên giới khác.
Và nếu bạn đang đọc codebase mà thấy pattern "REST validate kỹ nhưng worker đọc DB rồi gọi service không validate" — mở PR sửa ngay hôm nay. Đừng đợi log Datadog báo có ai đang curl 169.254.169.254 từ container production.
Vài dòng validate không tốn gì. Nhưng leak một bộ AWS IAM credential thì... thôi đừng để nó xảy ra.
Tham khảo
- OWASP — Server-Side Request Forgery (SSRF)
- AWS — Transitioning to IMDSv2
- Capital One breach post-mortem (2019) — SSRF + IMDS in production — $190M settlement, 100M khách hàng bị lộ data
- GitLab CVE-2018-19571 — SSRF via webhook
- PortSwigger — SSRF attacks and how to prevent them
- Google Cloud — Workload Identity (avoid metadata legacy)
- RFC 3986 — URI Generic Syntax (hiểu tại sao
user@hostlừa được)
All rights reserved