0

Đừng truyền JWT qua argv: cái bẫy bảo mật mà tutorial Tauri/Electron không nói cho bạn

Khi làm việc với các framework desktop như Tauri hay Electron, việc gọi một process con (sidecar) gần như là chuyện cơm bữa. Và nếu giống tôi, khả năng cao bạn đã từng làm theo các tutorial trên mạng: truyền thẳng token xác thực qua command-line arguments cho tiện.

Nhưng có một sự thật giật mình: chỉ một dòng code tưởng chừng vô hại đó có thể biến JWT của người dùng thành tài sản công cộng ngay trên chính máy tính của họ.

Tóm tắt nhanh (TL;DR) cho những ai đang vội

  • Gót chân Achilles: Trên Linux, file /proc/<pid>/cmdline mặc định là world-readable. Bất kỳ user nào trên cùng máy đều có thể đọc được toàn bộ arguments (argv) của process bạn đang chạy.
  • Rủi ro: Truyền JWT/API key qua arguments đồng nghĩa với việc phơi bày token cho bất kỳ ai (hoặc mã độc nào) chạy lệnh ps aux.
  • Cách khắc phục nhanh: Dùng Environment variables an toàn hơn đáng kể (file /proc/<pid>/environ có quyền 400 — chỉ chủ sở hữu mới đọc được), dù đây vẫn chưa phải là giải pháp hoàn hảo nhất.
  • Giải pháp triệt để: Sử dụng stdin pipe, OS keychain, hoặc kết hợp short-lived tokens để bảo mật tối đa.

Nếu bạn đang phát triển desktop app và có sử dụng pattern "spawn process con", hãy nán lại vài phút để đọc bài viết này. Nó có thể cứu bạn khỏi một lỗ hổng bảo mật ngớ ngẩn.


Bối cảnh: Câu chuyện bắt đầu từ đâu?

Mọi chuyện bắt đầu khi tôi xây desktop client cho một ứng dụng meeting translation. Kiến trúc khá quen thuộc:

┌─────────────────┐
│  Tauri main     │  ◄── User đăng nhập, có Supabase access token (JWT)
│  (Rust)         │
└────────┬────────┘
         │ spawn
         ▼
┌─────────────────┐
│  Sidecar        │  ◄── Process con: capture audio → gọi Soniox → POST
│  (Node/binary)  │      về web API
└─────────────────┘

Sidecar cần gọi web API (POST /api/sessions/[code]/transcripts/ingest), nên đương nhiên cần JWT của user để xác thực. Và câu hỏi đặt ra rất hồn nhiên: làm thế nào để Tauri main đẩy JWT xuống cho sidecar?

Cách đầu tiên mà ai cũng nghĩ đến — và 90% tutorial trên mạng cũng làm vậy:

// ❌ ĐỪNG LÀM THẾ NÀY
Command::new(sidecar_path)
    .arg("--token")
    .arg(&access_token)
    .spawn()?;

Nhìn thì sạch sẽ. Đọc thì dễ hiểu. SDK của không ít hãng còn ghi đúng pattern này trong "Quick Start". Vấn đề là: đoạn code trông vô tội kia chính là một lỗ hổng bảo mật mở toang.


Vấn đề: argv không phải là chỗ kín đáo như bạn nghĩ

Trên Linux, mỗi process đều có một "hồ sơ công khai" tại /proc/<pid>/. Và trong cái hồ sơ ấy, file cmdline ghi nguyên si toàn bộ command-line arguments mà bạn đã pass vào — bao gồm cả token vàng quý báu kia.

Hãy nhìn kỹ permission của file này:

$ ls -l /proc/$$/cmdline
-r--r--r-- 1 user user 0 May 22 19:00 /proc/12345/cmdline

-r--r--r-- — dịch ra tiếng Việt là: ai cũng đọc được. Không cần root. Không cần cùng UID. Không cần kỹ năng gì đặc biệt. Cứ user nào ngồi cùng máy, là xem được tuốt.

Không tin? Demo trực tiếp cho bạn xem:

# Terminal 1: chạy 1 process "giả vờ" nhận token qua argv
$ sleep 999 --token=eyJhbGciOiJIUzI1NiIs...

# Terminal 2: từ user khác, hoặc cùng user
$ ps aux | grep sleep
nguyen   12345  0.0  0.0   5476  1024 pts/1  S+  19:00  0:00 sleep 999 --token=eyJhbGciOiJIUzI1NiIs...

# Hoặc đọc thẳng:
$ tr '\0' ' ' < /proc/12345/cmdline
sleep 999 --token=eyJhbGciOiJIUzI1NiIs...

Trên máy multi-user (server lab, máy chia sẻ trong công ty, CI runner dùng chung) thì khỏi nói — ai cũng có thể "ghé thăm" token của bạn. Còn trên máy cá nhân thì sao, có an toàn không? Đáng tiếc là vẫn rất nguy hiểm:

  • Malware chạy dưới user khác (đa số distro Linux có sẵn các user hệ thống như www-data, nobody) đọc thoải mái.
  • Audit log và monitoring tool (Datadog, Sentry, custom telemetry) thường log full process tree → token tự khắc chui vào log mà bạn không hay biết.
  • Crash dump và file core lúc app crash đôi khi mang theo cả argv.
  • Shell history, screen recording, screenshot khi debug — chỉ cần một cú ps aux rồi share màn hình cho đồng nghiệp là token lên truyền hình trực tiếp.

CI/CD — chiến trường khốc liệt nhất mà ít ai để ý

Theo kinh nghiệm tôi review code, đây mới là chỗ "đau" nhất. Trên GitHub Actions, GitLab CI, CircleCI..., dev hay bật set -x cho dễ debug, hoặc chạy ps aux khi pipeline fail, hoặc bật cờ ACTIONS_STEP_DEBUG=true để truy ra lỗi. Hệ quả? Toàn bộ command-line của mọi process bị in thẳng ra log build. Và log build thì:

  • Mọi member của repo đều xem được (với open-source, thậm chí cả internet xem được).
  • Lưu trữ rất lâu (GitHub mặc định giữ 90 ngày, GitLab tùy config).
  • Search được trong UI — chỉ cần gõ "token" là ra cả rổ.

GitHub Actions có chức năng masking cho secrets.*, nghe thì yên tâm. Nhưng nó chỉ mask đúng chuỗi khớp. Nếu token bị decode/transform một bước trước khi pass vào argv (ví dụ base64 decode rồi truyền tiếp), masking bó tay. Token chui ra log nguyên xi.

Bài học rút ruột: secret tuyệt đối không được phép xuất hiện ở argv trong môi trường CI/CD — kể cả khi bạn đinh ninh là "log đã có mask rồi".

Và đây không phải lý thuyết. Đây là CVE-class issue từng được report ở vô số dự án lớn — Docker, Kubernetes, npm CLI... đều từng dính.

Các OS khác có khá hơn không? Đừng vội mừng

OS Cách đọc argv của process khác Ai đọc được?
Linux /proc/<pid>/cmdline Tất cả mọi user (world-readable)
macOS ps -ef, sysctl kern.proc.args Cùng user; root xem hết
Windows Process Explorer (Sysinternals), Get-WmiObject Win32_Process, ETW Cùng user; admin xem hết

Đặc biệt cần lưu ý với Windows: Process Explorer của Sysinternals là tool gần như mọi dev Windows đều cài sẵn trong máy. Chỉ cần mở lên, double-click vào bất kỳ process nào — tab Image sẽ hiện thẳng Command line, tab Environment thì liệt kê đầy đủ env vars. Không cần quyền admin, không cần thủ thuật nào cả. Điều đó cũng có nghĩa: bất kỳ chương trình nào (kể cả phần mềm bên thứ ba mà bạn cài bừa) chạy dưới user của bạn đều có thể đọc argv và env của desktop app dễ như Process Explorer đang làm.

Linux đứng top tệ nhất vì world-readable, nhưng kết luận chung không thay đổi: argv không phải là chỗ để chứa secret. Không bao giờ.


Cứu cánh đầu tiên: Environment variable

Đây là cách tôi đã chuyển sang dùng — chỉ đổi đúng một dòng:

// ✅ Tốt hơn rất nhiều
Command::new(sidecar_path)
    .env("MT_AUTH_TOKEN", &access_token)
    .spawn()?;

Sidecar đọc lại:

const token = process.env.MT_AUTH_TOKEN;
if (!token) throw new Error("Missing MT_AUTH_TOKEN");

Vì sao env an toàn hơn argv hẳn một bậc? Hãy nhìn permission của /proc/<pid>/environ:

$ ls -l /proc/$$/environ
-r-------- 1 user user 0 May 22 19:00 /proc/12345/environ

-r-------- = chỉ owner (và root) đọc được. Cánh cửa "world-readable" đã đóng lại.

Đây cũng là lý do convention "secret qua env" được áp dụng khắp nơi: Docker, Kubernetes, systemd, 12-factor app... Không hoàn hảo, nhưng tốt hơn argv rõ rệt.

Nhưng đừng tưởng env là "viên đạn bạc"

Vài điểm bạn cần khắc cốt ghi tâm:

  1. Cùng UID vẫn đọc được: Mọi process khác chạy dưới user của bạn đều thoải mái đọc /proc/<pid>/environ. Nếu máy bị nhiễm malware userland, env không cứu nổi.
  2. Env kế thừa lặng lẽ: Nếu sidecar lại spawn thêm process con mà không lọc env, token cứ thế leak xuống cả cây con cháu.
  3. Log và telemetry "vô ý": Sentry, Bugsnag và nhiều crash reporter khác mặc định dump nguyên process.env vào báo cáo. Không filter explicit là token bay thẳng lên dashboard.
  4. Shell history "vô tình": Một cú env | grep TOKEN khi debug, rồi đồng đội ngồi cạnh chụp màn hình — kịch bản nghe buồn cười nhưng đã xảy ra trong thực tế.

Giải pháp triệt để cho người chơi hệ "không khoan nhượng"

Nếu app của bạn đụng tới secret thật sự nhạy cảm — payment data, hồ sơ y tế, auth token dài hạn — thì hãy cân nhắc các pattern dưới đây.

1. Stdin pipe — kín như bưng

Token được ghi thẳng vào stdin của sidecar ngay sau khi spawn. Không chạm argv. Không chạm env. Hoàn toàn không xuất hiện trong /proc/*:

let mut child = Command::new(sidecar_path)
    .stdin(Stdio::piped())
    .spawn()?;

if let Some(stdin) = child.stdin.as_mut() {
    stdin.write_all(access_token.as_bytes())?;
    stdin.write_all(b"\n")?;
}

Sidecar chỉ cần đọc một dòng từ stdin lúc startup là xong. Token sống đúng trong RAM của hai process — không tài liệu hóa, không thuộc về bất kỳ file system path nào.

Tradeoff: phải design protocol stdin cho rõ — đọc 1 dòng cho token, rồi tới đâu là stream payload chính? Nếu sidecar đã sẵn dùng stdin cho việc khác, bạn phải multiplex hoặc đổi luồng.

2. OS keychain — gửi vàng cho ngân hàng của hệ điều hành

  • macOS: Keychain (security CLI hoặc framework)
  • Windows: DPAPI / Credential Manager
  • Linux: Secret Service (gnome-keyring, kwallet)

Tauri đã có plugin sẵn — tauri-plugin-stronghold hoặc dùng keyring crate.

Cách làm: Tauri main lưu token vào keychain với một service name định trước, sidecar đọc lại bằng cùng tên. OS lo phần isolation hộ bạn. Không cần truyền qua kênh nào cả.

Tradeoff: phải xử lý prompt unlock của keychain (Linux khá phiền), code phức tạp hơn vài bậc, và unit test gần như bất khả thi nếu không mock.

3. Short-lived tokens — lớp phòng thủ thứ hai

Dù chọn phương án nào ở trên, hãy giảm blast radius bằng cách:

  • Cho JWT một TTL ngắn — 5 đến 15 phút là vừa.
  • Sidecar giữ refresh token (lưu trong keychain) và tự renew khi cần.
  • Trong tình huống xấu nhất — token bị leak — kẻ tấn công cũng chỉ có vài phút "thời gian vàng" để khai thác.

Đây không thay thế cho việc bảo vệ kênh truyền, mà là tấm lưới an toàn dưới đáy. Có hai lớp luôn an tâm hơn một lớp.


Chốt nhanh: bạn nên chọn gì?

Tình huống Chọn
Quick fix cho prototype, chắc chắn máy single-user Env var
Production desktop app, có thể chạy trên máy multi-user Env var + short-lived token + log filtering
Token dài hạn, secret cực nhạy cảm OS keychain
Sidecar còn stdin sạch chưa dùng vào việc khác Stdin pipe
Mọi trường hợp còn lại KHÔNG bao giờ dùng argv

✅ Checklist tự rà trước khi merge

Nếu bạn đang code Tauri/Electron/bất cứ desktop app nào có sidecar, dừng tay 3 phút và check ngay danh sách này:

  • [ ] Không có arg("--token", ...) hay arg(&secret) ở mọi chỗ spawn process con.
  • [ ] Mọi secret đã được chuyển qua env hoặc kênh an toàn hơn.
  • [ ] Crash reporter (Sentry/Bugsnag) đã filter env vars chứa token (regex *TOKEN*, *KEY*, *SECRET*).
  • [ ] CI/CD pipeline không log đầy đủ ps aux hay command line khi debug.
  • [ ] Có dòng cảnh báo trong docs nội bộ: "không bao giờ nhét secret vào CLI args".
  • [ ] Rule này đã được đưa vào checklist code review của team.

Lời kết: chuyện nhỏ, hệ quả không nhỏ

Bug này nhỏ về mặt code — chỉ đổi đúng một dòng từ .arg() sang .env(). Nhưng tác động về security posture thì hoàn toàn không nhỏ. Buồn là phần lớn tutorial về Tauri/Electron sidecar mà tôi đào lên trong quá trình research đều minh họa bằng argv — vì nó gọn gàng, dễ hiểu, dễ demo. Cực hiếm bài nhắc về /proc/<pid>/cmdline.

Nếu bạn từng viết blog hay tutorial về desktop app, làm ơn cập nhật lại ví dụ của mình. Nếu bạn đang nhìn thấy pattern này trong codebase của team, mở PR sửa ngay hôm nay — đừng đợi.

Một process chỉ tốn vài chục mili-giây để spawn. Nhưng một token bị leak có thể tốn rất, rất nhiều thứ khác — và phần lớn trong đó không quy đổi ra tiền được.


Tham khảo



All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí