Auditing a Tauri Plugin for Security
What it is: A repeatable methodology for deciding whether a third-party Tauri plugin is safe to depend on, vendor, fork, or replace with a native re-implementation. Status: Living checklist. Updated whenever a real audit surfaces a new question. Most relevant to Locara: Locara is capability-gated and security-positioned. Every plugin we depend on enlarges Locara’s trust surface. We need a mechanical, evidence-driven process to evaluate them — not vibes.
Why this note exists
Tauri’s whole pitch is that the IPC boundary between the webview and Rust is the only trust boundary, gated by a declarative capability allowlist. Plugins extend that boundary. A misbehaving plugin can:
- Open new ports / IPC surfaces the host app didn’t ask for.
- Add or override capability allowlist entries at runtime, bypassing
tauri.conf.json. - Inject JavaScript into every webview that ships with the plugin.
- Leak the kill-switch — e.g. a plugin meant to be debug-only that doesn’t enforce that in code.
- Pull in transitive crates that themselves shift the trust surface.
For a security-focused framework like Locara — fully-local, sandbox-leaning, capability-first — every dependency has to clear the same bar we hold ourselves to. The methodology below is what we run before adding a plugin (or vendoring one).
The audit checklist
Run all twelve before approving. Skipping = unverified.
1. License compatibility
- Permissive (MIT, Apache-2, BSD, MIT-OR-Apache-2): vendor-OK.
- Weak copyleft (MPL, LGPL): vendor with attention to redistribution; usually OK for static-link exceptions in Rust.
- Strong copyleft (GPL, AGPL): do not vendor. Quarantine to a separate process if you must use it at all.
- No license: assume “all rights reserved” — treat as non-vendorable until clarified.
2. Code size + reading time
tokei or cloc the plugin. Anything under ~2 kLOC of Rust is auditable in one sitting. Anything bigger needs to be sliced by responsibility (server, IPC, webview shim, tests).
If you can’t read all of it, you can’t approve it.
3. Dependency graph
cargo tree --no-default-features --edges normal
For each transitive crate:
- Is it actively maintained?
crates.iolast-published date. - Is the version yanked or has a known advisory?
cargo audit. - Does it bring native code (sys-crates, unsafe)? Higher scrutiny.
- Bus factor: one author = elevated supply-chain risk.
Anything unmaintained-2y+ or yanked = blocking finding.
4. Network surface enumeration
Grep for every TCP/UDP bind, every HTTP server, every WebSocket. For each:
- Bind address.
127.0.0.1(loopback only, OK),0.0.0.0(LAN-wide, almost always wrong), specific iface (rare, OK). - Authentication. Bearer token? Mutual-TLS? Shared secret? Or completely open?
- CORS / Origin policy. Does the server validate the
OriginandHostheaders? (Defense against DNS rebinding from a browser tab.) - Rate limiting. Per-IP, per-token, none?
- TLS. Localhost is OK without TLS if there’s auth on the wire.
A loopback-bound unauthenticated HTTP server is a local-RCE primitive. Any local process — including malicious VS Code extensions, misbehaving npm packages, browser tabs via DNS rebinding — can talk to it.
5. Filesystem + process surface
- Does it spawn subprocesses?
Command::new,tokio::process::Command. What can the caller specify? - Does it read/write outside its own data dir? Especially
~/Library,~/.ssh,~/Documents. - Does it touch
file:URLs in webviews? Does it tighten the WebView’sapp.security.assetProtocol?
The classic Tauri plugin failure mode: a webview-eval surface plus webview file:// access = arbitrary file read.
6. The kill-switch — is it actually enforced?
Plugins that should only run in debug builds (test harnesses, dev-only consoles, automation surfaces) need a compile-time check. README claims don’t count.
Look for:
#[cfg(not(debug_assertions))]
compile_error!("this crate must not be linked into release builds");
Or feature-gated entry points where the feature is off by default. Or an explicit env-var the plugin checks at runtime before opening the surface.
If the plugin’s only “kill-switch” is the README telling consumers to wrap it in #[cfg(debug_assertions)] at the call site, the plugin offers zero defense in depth. That’s a finding.
7. Capability ACL bypasses
Tauri 2’s capability model is the framework’s primary trust boundary. A plugin that calls app.add_capability(...) at runtime is bypassing the consumer’s tauri.conf.json.
Watch for:
.local(true).window("*")
.remote("http://*".into())
.remote("https://*".into())
.permission(...)
Each of these is a relaxation. window("*") = any window. remote("https://*") = any HTTPS origin loaded into the webview can call this command. The combination is extremely permissive.
Acceptable pattern: ship a permissions/<scope>.toml and let the consumer opt in via their own ACL. Not acceptable: silent runtime mutation of the allowlist.
8. JS injection surface
Plugins often inject JS via js_init_script or WebviewWindow::eval. Audit the injected script for:
- Globals it adds (window-scoped pollution).
- Whether it can be called by page-side code, or only by the plugin.
- Whether the channel back to Rust is parameterised (safe) or string-concatenated into eval (unsafe).
- Whether eval’d code is logged anywhere (audit trail).
A plugin that ships an unauthenticated eval primitive is a remote-code-execution primitive — design the audit log accordingly.
9. Mutex / panic discipline
grep -n "expect(\|unwrap(\|panic!(\|.lock().expect" across the plugin source.
Heavy expect() on locks means a panic in one task poisons the mutex and propagates to every other task that touches it. For a long-running plugin (server, watcher, IPC channel), this is a reliability risk that tends to manifest as “tests pass once then deadlock the second time.”
Look for the issue tracker confirming this — if there’s a PoisonError issue open, the bug is real and waiting for you.
10. Logging / audit trail
Any plugin that exposes RCE-shape primitives needs structured logs:
- Every request: timestamp, route, caller PID (
SO_PEERCREDon Unix sockets), body hash. - Append-only, on disk.
- Documented retention.
If the plugin has no audit log, you’ll need to add one in your wrapping layer or your re-implementation.
11. Maintenance signal
- Last commit. Anything stale (>3 months) on a small bus-factor=1 project = expect to fork-and-maintain.
- Open issues count + age. Look for poison/lock/security tags.
- Number of contributors. One = bus factor 1. Three+ with consistent commits = healthier.
- Stars/forks aren’t the signal. PRs with reviewers are.
12. Re-implementation cost
The escape hatch when the plugin fails the audit. Estimate:
- Lines of Rust required for the minimum useful subset.
- Crates already in your tree (don’t add new top-level deps unless necessary).
- Half-day, day, week, month?
If the plugin is <2 kLOC and you only need ~20% of its surface, vendoring or rewriting is usually cheaper than carrying a permanent supply-chain risk.
Threat model — the questions to answer for every plugin
Phrase findings against four attackers:
- Same-machine, different-process (a misbehaving npm package, a VS Code extension, malware running as the user’s UID). What can it do via this plugin? This is the most common compromise path on a developer machine.
- Same-LAN, browser tab (open Wi-Fi, malicious page in any browser on the same machine). Mitigated by browser CORS for plain
fetch, but DNS rebinding bypasses naiveHost-header trust. What does the plugin do to defend? - Same-LAN, native client (something on the network bound to a LAN-routable IP). Loopback-only binds defeat this entirely;
0.0.0.0binds expose it. - Production-build accident — a debug-only plugin that ends up in a release build. What’s the blast radius?
Every finding gets stamped with which attacker class it enables.
Output format for an audit
# <plugin-name> — Security Audit (YYYY-MM-DD)
## TL;DR
Three lines. Verdict: vendor / fork / re-implement / reject.
## Plugin facts
| field | value |
|---|---|
| URL | ... |
| License | ... |
| Last commit | ... |
| LOC | ... |
| Direct deps | ... |
| Bus factor | ... |
## Threat model
What an attacker on each of the four classes above can do.
## Findings
Numbered. Each has:
- File:line citation
- Attacker class enabled
- Severity (critical / high / medium / low / info)
- Mitigation if vendoring / re-implementing
## Re-implementation sketch (if rejecting)
File paths, crate choices, IPC shape, lifecycle, capability integration, audit-log
plan. The minimum-useful subset, not the full surface.
## URLs to read
Max 8, ranked by signal-to-noise.
This is the shape of every plugin audit Locara records. Each one lives next to this methodology in notes/.
How to actually run an audit (mechanical steps)
# 1. Pull source, mark the commit you audited.
git clone <plugin-url> /tmp/audit-<plugin>
cd /tmp/audit-<plugin>
git rev-parse HEAD > .audited-commit
# 2. Size + dependency surface.
tokei
cargo tree --edges normal --no-default-features
cargo audit
# 3. Network bind + RCE surface.
rg -n 'TcpListener::bind|UnixListener::bind|axum::serve|warp::serve|hyper::Server' src/
rg -n 'WebviewWindow::eval|window\.eval|invoke_handler!|js_init_script' src/
# 4. ACL + cfg checks.
rg -n 'add_capability|CapabilityBuilder|cfg\(debug_assertions\)|compile_error!' src/
# 5. Mutex + panic discipline.
rg -n '\.lock\(\)\.expect|\.unwrap\(\)|panic!\(' src/ | wc -l
# 6. Issue tracker scan.
gh issue list --repo <owner>/<repo> --state open --search 'poison OR security OR auth OR escape'
# 7. Last-commit signal.
git log -1 --format='%cs %s'
If any of these surface a finding, write it up in the audit-output template before deciding.
Specific learnings for Locara
- Every plugin in our
Cargo.tomlgets an audit note innotes/. No exceptions for “official” Tauri plugins — they get the same checklist. #[cfg(debug_assertions)]is not a security mechanism unless enforced in code withcompile_error!. A README claim is not enforcement. We enforce in our own crates and we expect the same of dependencies.- Runtime
add_capabilitycalls are red flags. Locara’s whole point is that the manifest declares everything and the runtime enforces. A plugin that mutates the allowlist at runtime breaks that promise. - Loopback ≠ secure. Many local-dev tools bind to
127.0.0.1and call it done. Same-machine compromise vectors (other users, malware, dev-tool extensions, browser DNS-rebinding) all defeat naive loopback. Locara’s automation surface uses Unix sockets with mode 0600 + bearer tokens. - Audit logs are mandatory for RCE-shape primitives. If a plugin can
evalorinvokearbitrary commands, every call must be logged with caller-PID and body-hash. We add this in the re-implementation if the upstream lacks it. - The minimum useful subset is usually <500 LOC. WebDriver protocols are huge; the agent-driven testing primitives we need (click, type, eval, screenshot, invoke) are tiny. Don’t pay for surface area you won’t use.
- Build a
crates/locara-<surface>for each capability, not a single mega-plugin. Each crate has its own audit note, its own enable-flag, its own audit log. Composable trust. - The bar for vendoring something that touches the IPC boundary is “we’d be willing to maintain this ourselves indefinitely.” If the answer is no, re-implement the subset we actually need.
References
- Tauri 2 security model — capability allowlists, runtime authority, what the framework guarantees.
- Tauri plugin development —
Builder,setup,invoke_handlerlifecycle. - Tauri WebDriver story — official
tauri-driver(Linux/Windows; macOS is[Todo]). - Cargo audit — RustSec advisory database integration.
- Cargo deny — license + advisory + dependency-graph linting.
notes/deno-permissions.md— capability-default-deny model that informs Locara’s stance.notes/mac-app-store-sandbox.md— kernel-level enforcement Locara stacks on top of.notes/wasmtime-wasi.md— sandboxing model for tool execution.