How Plaintext Token Storage Happens in TypeScript/Tauri and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Cleartext Storage of Sensitive Information |
| CWE | CWE-312 |
| Severity | Critical |
| File | src/config/settings.ts |
| Affected Key | cloudArticleToken (GitHub API token, repo scope) |
| Fix | Filter sensitive keys from disk-serialization in getAllSettings() |
Introduction
The src/config/settings.ts file is the central hub for persisting user preferences in this Tauri desktop application — it handles everything from UI preferences to API credentials. But a flaw in the getAllSettings() function created a serious security risk: every time settings were flushed to disk via syncToDisk(), the GitHub API token stored under the key cloudArticleToken was written out in plaintext JSON right alongside harmless preferences like font size or theme color.
GitHub tokens with repo scope are among the most powerful credentials a developer can hold. They grant read and write access to every private repository the user owns. Storing them in a cleartext settings file — indistinguishable from any other application data — means that any process, script, or piece of malware that can read the filesystem can silently harvest a fully-privileged GitHub token without the user ever knowing.
The Vulnerability Explained
What Was Happening
The getAllSettings() function in src/config/settings.ts iterated over every key in DEFAULT_SETTINGS and included its value in the object that gets written to disk:
// BEFORE — vulnerable code
export function getAllSettings(): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const key of Object.keys(DEFAULT_SETTINGS)) {
result[key] = getSetting(key) // ← no distinction between sensitive and non-sensitive keys
}
return result
}
This function is called by syncToDisk(), which serializes the result to a local JSON file. The cloudArticleToken key — which holds a GitHub OAuth token with repo scope — was included in that serialization without any filtering or encryption.
The resulting file on disk would look something like this:
{
"theme": "dark",
"language": "zh-CN",
"cloudArticleToken": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"fontSize": 14
}
A GitHub Personal Access Token (PAT) sitting in a JSON file, right next to display preferences.
Why This Is Particularly Dangerous
Scope amplification: A repo-scoped GitHub token isn't just a read credential. It can clone private repositories, push malicious commits, create webhooks to exfiltrate future code, and access GitHub Actions secrets. Stealing one token can compromise an entire organization's codebase.
Passive exfiltration: Unlike an active network attack, reading a local file requires no special privileges beyond normal user-level filesystem access. Any other application running as the same OS user — including browser extensions, npm scripts in a postinstall hook, or malware — can read this file without triggering security alerts.
No expiry signal: The user has no indication the token was read. There's no authentication log entry, no GitHub audit event, nothing. The attacker can use the token indefinitely until it's manually revoked.
A Concrete Attack Scenario
Consider this attack chain:
- A developer installs a malicious npm package that includes a
postinstallscript. - The script runs as the developer's OS user and enumerates common Tauri application data directories (e.g.,
~/.local/share/<appname>/on Linux,%APPDATA%\<appname>\on Windows). - It finds the settings JSON file, reads
cloudArticleToken, and exfiltrates it to an attacker-controlled server over HTTPS. - The attacker now has full
repoaccess to all of the developer's private GitHub repositories.
The entire attack takes milliseconds and leaves no trace in application logs.
The Fix
What Changed
The fix is surgical and elegant. A single SENSITIVE_KEYS set is introduced, and getAllSettings() is updated to skip any key present in that set:
// AFTER — fixed code
const SENSITIVE_KEYS: Set<string> = new Set(['cloudArticleToken'])
/** 导出当前所有设置(用于写入磁盘 JSON) */
export function getAllSettings(): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (!SENSITIVE_KEYS.has(key)) result[key] = getSetting(key) // ← sensitive keys excluded
}
return result
}
Before vs. After
Before:
for (const key of Object.keys(DEFAULT_SETTINGS)) {
result[key] = getSetting(key)
}
After:
const SENSITIVE_KEYS: Set<string> = new Set(['cloudArticleToken'])
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (!SENSITIVE_KEYS.has(key)) result[key] = getSetting(key)
}
Why This Fix Works
The getAllSettings() function is the only path through which in-memory settings reach syncToDisk(). By filtering cloudArticleToken out of the returned object before it ever reaches the serialization layer, the token is guaranteed never to appear in the on-disk JSON file — regardless of how syncToDisk() is called or how frequently settings are flushed.
The fix also establishes a clear, maintainable pattern: the SENSITIVE_KEYS set is a single place to register credentials. When a new sensitive setting is added in the future (an API key for a different service, for example), developers only need to add it to this set rather than hunting for every serialization call site.
What This Fix Does Not Change
The fix is deliberately scoped. It does not affect:
- How cloudArticleToken is read back into memory via getSetting()
- How setSetting() stores the token in the in-memory settings store
- Any non-sensitive settings, which continue to be persisted normally
Valid application behavior is fully preserved; only the credential leak path is closed.
Prevention & Best Practices
1. Use the OS Keychain for Long-Lived Secrets
The gold standard for storing credentials in a desktop application is the operating system's secure credential store:
- macOS: Keychain Services
- Windows: Windows Credential Manager (via DPAPI)
- Linux: libsecret / GNOME Keyring
Tauri exposes these through the tauri-plugin-stronghold and community crates. Secrets stored in the OS keychain are encrypted at rest and access-controlled by the OS, not just filesystem permissions.
2. Never Serialize Credentials Alongside Non-Sensitive Settings
Keep a strict architectural separation between "preferences" (safe to persist as JSON) and "credentials" (must go through a secure store). The SENSITIVE_KEYS pattern introduced in this fix is a good intermediate step, but the long-term goal should be to route credentials through a dedicated, encrypted channel entirely.
3. Audit All Disk-Write Paths
In any settings or configuration system, identify every function that writes data to disk and ensure each one either:
- Explicitly excludes sensitive keys (as this fix does), or
- Encrypts the entire file using a key derived from a hardware-bound or user-provided secret
4. Apply the Principle of Least Privilege to Token Scopes
If the application only needs to read public repository data, request a token with public_repo scope rather than the full repo scope. A stolen narrow-scope token causes significantly less damage.
5. Leverage PBKDF2 Already in Your Dependency Tree
The project's src-tauri/Cargo.lock already includes PBKDF2 (line 3809). This means the infrastructure for key derivation is already present. A future improvement would be to derive an encryption key from a user-provided passphrase or hardware identifier using PBKDF2, then encrypt stored credentials with that key before any disk write.
OWASP & CWE Alignment
- CWE-312: Cleartext Storage of Sensitive Information
- CWE-313: Cleartext Storage in a File or on Disk
- CWE-522: Insufficiently Protected Credentials
- OWASP A02:2021: Cryptographic Failures
- OWASP ASVS v4.0, Section 6.1: Data Classification — credentials must be stored with appropriate cryptographic protection
Key Takeaways
getAllSettings()was the single dangerous serialization sink — all sensitive credential leakage flowed through this one function, making it the right and minimal place to apply the fix.- GitHub tokens with
reposcope are organization-level blast radius — a single leaked token can expose every private repository a developer has access to, not just their own projects. - A
SENSITIVE_KEYSexclusion set is a scalable pattern — it creates a single, auditable registry of credentials that must never reach disk, making future additions safe and reviewable. - Tauri's
getSetting/setSettingAPI does not encrypt by default — developers building Tauri apps must explicitly architect credential storage to avoid this class of vulnerability; the framework does not protect you automatically. - PBKDF2 was already available in
src-tauri/Cargo.lock— the cryptographic primitives needed for proper at-rest encryption were present but unused, illustrating that having a dependency is not the same as using it securely.
How Orbis AppSec Detected This
- Source: The
setSetting('cloudArticleToken', value)call, which places a GitHub API token into the in-memory settings store. - Sink:
getAllSettings()insrc/config/settings.ts, which unconditionally includes all settings keys in the object passed tosyncToDisk(), writing them to a plaintext JSON file on disk. - Missing control: No exclusion, redaction, or encryption was applied to the
cloudArticleTokenkey before the settings object reached the disk-write path. - CWE: CWE-312 — Cleartext Storage of Sensitive Information.
- Fix: A
SENSITIVE_KEYS: Set<string>containing'cloudArticleToken'was introduced, andgetAllSettings()was updated to skip any key present in that set before building the disk-serialization object.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The vulnerability fixed here is a textbook example of how a single missing guard in a serialization function can expose the most sensitive credentials in an application. The getAllSettings() function in src/config/settings.ts had no mechanism to distinguish between a harmless UI preference and a fully-privileged GitHub API token — so it wrote both to disk with equal indifference.
The fix is minimal, targeted, and immediately effective: a SENSITIVE_KEYS set and a one-line conditional in the loop body. But the deeper lesson is architectural. In any application that handles credentials, every path that leads to persistent storage must be treated as a potential leak point and audited explicitly. The presence of PBKDF2 in the project's Rust dependencies shows that the building blocks for proper encryption were always available — the gap was in applying them.
For developers building Tauri applications or any desktop app that handles OAuth tokens or API keys: treat your local settings file as a public document. If you wouldn't want to commit a value to a public GitHub repository, it should never appear in a plaintext settings file.
References
- CWE-312: Cleartext Storage of Sensitive Information
- CWE-313: Cleartext Storage in a File or on Disk
- CWE-522: Insufficiently Protected Credentials
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Secrets Management Cheat Sheet
- Tauri Plugin Stronghold (secure credential storage)
- Semgrep rules for credential exposure
- harden: github api tokens with 'repo' scope are stored ... in...