Tuần này trên Hacker News có một paper khá gắt: các công ty AI đang để lộ dữ liệu người dùng cho bên quảng cáo, qua tracking pixel, analytics script và những URL chứa nội dung hội thoại. Đọc xong mình giật mình. Lý do không phải chuyện của OpenAI hay Google, mà là mình nhận ra chính team mình cũng đang làm y hệt. Chatbot CSKH gửi nguyên tin nhắn khách hàng (có số điện thoại, số CCCD) lên LLM API. Trang checkout gắn Meta Pixel, mà URL lại có ?email=.... Log production thì ghi nguyên request body.
Bài này không bàn đạo đức. Mình chia sẻ cách mình audit và chặn rò rỉ PII (Personally Identifiable Information) trong một app Node.js + Python thực tế. Từ 1/1/2026, Luật Bảo vệ dữ liệu cá nhân đã có hiệu lực ở Việt Nam, nên đây không còn là chuyện "làm cho đẹp" nữa.
1. Dữ liệu rò rỉ qua những đường nào?
Trước khi fix, cần vẽ ra dữ liệu người dùng đang đi đâu. Với phần lớn app mình từng làm, có 4 "lỗ" chính:
flowchart LR
U[User] --> FE[Frontend]
FE -->|URL, event params| AD[Ad pixel / Analytics]
FE --> BE[Backend API]
BE -->|raw prompt| LLM[LLM API]
BE -->|request body| LOG[Log / APM]
BE -->|webhook payload| TP[3rd-party SaaS]
LOG -->|ship| OBS[Log vendor]
Điểm chung là dữ liệu ra khỏi hệ thống của bạn, sang server của bên thứ ba, và bạn không kiểm soát được họ lưu bao lâu, train model hay chia sẻ cho ai. Nguyên tắc mình áp dụng rất đơn giản: bên thứ ba chỉ nhận đúng lượng dữ liệu tối thiểu nó cần để làm việc. LLM cần hiểu ý định của khách, không cần biết số CCCD thật của khách.
2. Redact PII trước khi gửi lên LLM API
Đây là chỗ rò rỉ nhiều nhất năm 2026, vì team nào cũng đang nhét LLM vào sản phẩm. Cách mình làm là reversible redaction: thay PII bằng token trước khi gửi, rồi thay ngược lại khi nhận response.
sequenceDiagram
participant C as Client
participant B as Backend
participant R as Redactor
participant L as LLM API
C->>B: "SĐT mình 0912345678, đổi địa chỉ giúp"
B->>R: redact(text)
R-->>B: "SĐT mình <PHONE_0>, đổi địa chỉ giúp" + mapping
B->>L: prompt đã redact
L-->>B: "Đã ghi nhận SĐT <PHONE_0>..."
B->>R: restore(response)
B-->>C: response với SĐT thật
Bản tối giản bằng Python 3.12, chỉ dùng re, không cần thư viện ngoài:
import re
from dataclasses import dataclass, field
# Thứ tự quan trọng: pattern cụ thể chạy trước pattern rộng
PATTERNS = {
'EMAIL': re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'),
'CCCD': re.compile(r'(?<!\d)0\d{11}(?!\d)'), # CCCD 12 số
'PHONE': re.compile(r'(?<!\d)(?:\+84|0)[35789]\d{8}(?!\d)'), # di động VN
'CARD': re.compile(r'(?<!\d)(?:\d[ -]?){13,19}(?!\d)'), # số thẻ
}
@dataclass
class Redactor:
mapping: dict[str, str] = field(default_factory=dict)
def redact(self, text: str) -> str:
for label, pattern in PATTERNS.items():
def _sub(m, label=label):
token = f'<{label}_{len(self.mapping)}>'
self.mapping[token] = m.group(0)
return token
text = pattern.sub(_sub, text)
return text
def restore(self, text: str) -> str:
for token, value in self.mapping.items():
text = text.replace(token, value)
return text
r = Redactor() # mỗi request tạo 1 instance, KHÔNG dùng chung
msg = 'Mình là Lan, SĐT 0912345678, email lan.ng@gmail.com, CCCD 001099012345'
print(r.redact(msg))
# Mình là Lan, SĐT <PHONE_2>, email <EMAIL_0>, CCCD <CCCD_1>
Vài bài học xương máu khi đưa cái này lên production:
- Mapping chỉ sống trong memory của request. Đừng cache mapping vào Redis mà không có TTL. Làm vậy là bạn vừa tạo ra một kho PII mới.
-
Regex không bắt được tên người và địa chỉ. Muốn bắt những thứ này thì phải thêm NER. Microsoft Presidio (
presidio-analyzer2.2.x) cho phép viết custom recognizer. Với tiếng Việt, bạn có thể ghép model NER củaundertheseavào làm recognizer. -
Viết system prompt dặn LLM giữ nguyên token, ví dụ "Giữ nguyên các placeholder dạng , không sửa, không dịch". Không có dòng này, model thỉnh thoảng sẽ "sáng tạo" ra
<Phone_2>và bước restore sẽ fail. - Viết unit test với dữ liệu thật đã được anonymize. Mình từng gặp regex số thẻ ăn nhầm mã đơn hàng 16 chữ số.
3. Kiểm soát tracking script ở frontend
Lỗi kinh điển là đưa PII lên URL: /checkout/success?email=lan@gmail.com&phone=09.... Pixel quảng cáo nào cũng tự gửi document.location.href về server của nó, thế là email khách hàng nằm trong hệ thống ad. Paper trên HN chỉ ra đúng pattern này ở nhiều sản phẩm AI: nội dung chat hoặc ID nhạy cảm nằm trong URL, rồi bị pixel gửi đi.
Cách fix thứ nhất: không bao giờ để PII trong URL. Chuyển sang POST body hoặc lưu ở server session. Nếu codebase cũ quá lớn chưa refactor kịp, thì scrub URL trước khi đẩy event:
const PII_KEYS = ['email', 'phone', 'name', 'address', 'token', 'cccd'];
export function scrubUrl(rawUrl) {
const u = new URL(rawUrl, location.origin);
for (const key of [...u.searchParams.keys()]) {
if (PII_KEYS.some((k) => key.toLowerCase().includes(k))) {
u.searchParams.set(key, '[redacted]');
}
}
return u.toString();
}
// Dùng với GTM dataLayer: override page_location trước khi tag bắn
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_view',
page_location: scrubUrl(location.href),
page_referrer: document.referrer ? scrubUrl(document.referrer) : '',
});
Nhiều người nghĩ hash email bằng SHA-256 rồi gửi đi là "ẩn danh". Sai. Hash của email vẫn là một định danh ổn định, và bên ad match được ngay vì họ cũng có danh sách email đã hash. Hash chỉ là pseudonymization, không phải anonymization. Chỉ gửi khi bạn có consent rõ ràng.
Cách fix thứ hai là dùng Content-Security-Policy để whitelist domain mà browser được phép gửi dữ liệu tới. Nên bắt đầu bằng chế độ Report-Only để không làm vỡ site:
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://www.googletagmanager.com; connect-src 'self' https://api.example.vn; report-uri /csp-report" always;
Chạy 1-2 tuần, đọc report, bạn sẽ bất ngờ về số domain lạ mà các script "vô hại" đang gọi tới. Sau đó mới chuyển sang enforce.
4. Audit log và traffic đi ra (egress)
Log là nơi PII nằm im lâu nhất, và nó còn được ship sang log vendor. Mỗi sprint mình chạy một lượt quét nhanh bằng ripgrep (14.x):
# Quét số điện thoại VN và email trong log 7 ngày gần nhất
find /var/log/myapp -name '*.log' -mtime -7 -print0 \
| xargs -0 rg -c --no-heading \
-e '(\+84|0)[35789][0-9]{8}' \
-e '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[a-z]{2,}' \
| sort -t: -k2 -nr | head -20
# Server đang mở kết nối HTTPS ra những IP nào?
ss -tnp state established '( dport = :443 )' \
| awk 'NR>1 {split($4,a,":"); print a[1]}' | sort | uniq -c | sort -nr
Nếu lệnh đầu ra kết quả khác 0 thì fix ngay ở tầng logger thay vì xóa log thủ công. Với Node.js, pino (9.x) có option redact: ['req.body.phone', 'req.body.email', 'req.headers.authorization']. Với Python structlog, viết một processor lọc key nhạy cảm. Lệnh thứ hai giúp phát hiện những SDK "phone home" mà bạn không biết. Mình từng tìm ra một thư viện PDF gửi telemetry kèm tên file, mà tên file lại chứa họ tên khách hàng.
Kết luận
Bạn không kiểm soát được việc các công ty AI hay ad network làm gì với dữ liệu. Nhưng bạn kiểm soát được việc có gửi dữ liệu cho họ hay không. Checklist mình đề xuất làm ngay trong tuần này:
- Vẽ data flow: liệt kê mọi bên thứ ba nhận dữ liệu từ app (LLM, analytics, pixel, log vendor, webhook).
- Thêm lớp redact trước mọi lời gọi LLM API. Bắt đầu bằng regex cho SĐT, email, CCCD, số thẻ, rồi mở rộng sang NER sau.
-
Xóa PII khỏi URL và scrub
page_locationtrước khi tracking tag bắn. - Bật CSP Report-Only để biết browser đang gửi dữ liệu đi đâu.
-
Cấu hình redact ở logger và chạy lệnh
rgquét log định kỳ trong CI hoặc cron. - Nhớ rằng hash không phải là ẩn danh.
Privacy thường bị coi là việc của team legal. Thực tế, từng dòng code gửi request ra ngoài đều là một quyết định về privacy, và người quyết định là developer.
Top comments (0)