20. Tauri security, capabilities, CSP and plugins
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Full example: l16-tauri/src-tauri/capabilities/, src-tauri/src/lib.rs and frontend/src/main.ts.
The explorer should export the favourite chords to a text file that the user picks. A web page cannot do that: it has no file dialog that returns a path and no access to the disk. Tauri’s plugins add both, and Tauri’s security model decides whether this page, in this window, may use them.
What you already know
Section titled “What you already know”| Idea | Tauri | Electron | Windows / Android | Browser |
|---|---|---|---|---|
| The page is not trusted | the webview calls only what a capability grants | context isolation + a preload that exposes chosen functions | same-origin policy | |
| Declared privileges | capabilities listing permissions | the preload’s contextBridge API |
MSIX capabilities, Android permissions | |
| Which resources | scopes (paths, URLs, programs) | your own checks in the main process | the user’s consent, per resource type | |
| What the page may load | the CSP from tauri.conf.json |
a CSP you set yourself | Content-Security-Policy |
The closest analogy is Electron with context isolation done right: the page never gets Node.js, only a list of functions. In Tauri that list is not code you write in a preload, it is configuration that the build checks.
Why deny by default
Section titled “Why deny by default”Tauri’s security documentation draws the line between two parts of the app. The Rust core is trusted: it is your code, compiled into the binary. The webview is not: it runs HTML and JavaScript, and a cross-site scripting bug, a compromised npm dependency or a remote page loaded by mistake would run with whatever the page may call. So what the page may call starts empty, and every plugin command is refused until a capability grants it.
Capabilities reduce the impact of a compromised frontend. They do not protect against insecure Rust code: a command you write can still delete any file you let it delete.
Plugins
Section titled “Plugins”A plugin is a crate plus, usually, an npm package with its TypeScript API. The course adds three official ones: dialog, fs and store (from src-tauri/src/lib.rs, lines 43-47):
let builder = setup(tauri::Builder::default()) // Lesson 20: plugins. Their commands still need permissions in a capability .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::new().build());The export uses the first two from TypeScript (from frontend/src/main.ts, lines 87-104):
async function exportFavorites() { const status = element("export-status"); try { const path = await save({ defaultPath: "favorites.txt", filters: [{ name: "Text", extensions: ["txt"] }], }); if (path === null) { status.textContent = "export cancelled"; return; } const symbols = await api.favorites(); await writeTextFile(path, symbols.join("\n") + "\n"); status.textContent = `${symbols.length} favorites written to ${path}`; } catch (error) { status.textContent = api.describe(error); }}save and writeTextFile are invoke calls to the commands plugin:dialog|save and plugin:fs|write_text_file, exactly like lesson 17’s commands, so the same IPC layer checks them.
Refused
Section titled “Refused”With the plugins registered and the capability of lesson 16 unchanged (core:default only), clicking Export… under tauri dev shows:
dialog.save not allowed. Permissions associated with this command: dialog:allow-save, dialog:defaultThe message is a string, not the course’s CommandError: the call never reached the plugin. It also lists the permissions that would allow the command. Calling the other two plugins from the page, through the DevTools protocol, gives the same kind of refusal:
fs write: rejected (string) "fs.write_text_file not allowed. Permissions associated with this command: fs:allow-app-write, fs:allow-app-write-recursive, fs:allow-appcache-write, fs:allow-appcache-write-recursive, fs:allow-appconfig-write, fs:allow-appconfig-write-recursive, fs:allow-appdata-write, fs:allow-appdata-write-recursive, fs:allow-applocaldata-write, fs:allow-applocaldata-write-recursive, fs:allow-applog-write, fs:allow-applog-write-recursive, fs:allow-audio-write, fs:allow-audio-write-recursive, fs:allow-cache-write, fs:allow-cache-write-recursive, fs:allow-config-write, fs:allow-config-write-recursive, fs:allow-data-write, fs:allow-data-write-recursive, fs:allow-desktop-write, fs:allow-desktop-write-recursive, fs:allow-document-write, fs:allow-document-write-recursive, fs:allow-download-write, fs:allow-download-write-recursive, fs:allow-exe-write, fs:allow-exe-write-recursive, fs:allow-font-write, fs:allow-font-write-recursive, fs:allow-home-write, fs:allow-home-write-recursive, fs:allow-localdata-write, fs:allow-localdata-write-recursive, fs:allow-log-write, fs:allow-log-write-recursive, fs:allow-picture-write, fs:allow-picture-write-recursive, fs:allow-public-write, fs:allow-public-write-recursive, fs:allow-resource-write, fs:allow-resource-write-recursive, fs:allow-runtime-write, fs:allow-runtime-write-recursive, fs:allow-temp-write, fs:allow-temp-write-recursive, fs:allow-template-write, fs:allow-template-write-recursive, fs:allow-video-write, fs:allow-video-write-recursive, fs:allow-write-text-file, fs:write-all, fs:write-files"store load: rejected (string) "store.load not allowed. Permissions associated with this command: store:allow-load, store:default"The fs list shows how permissions are built: one allow-<command> per command, and sets that combine a command with a scope, such as fs:allow-appdata-write (writes, but only under the app’s data folder).
A release build refuses with a shorter message, without the list:
plugin:store|load: rejected "Command plugin:store|load not allowed by ACL"plugin:fs|read_text_file: rejected "Command plugin:fs|read_text_file not allowed by ACL"A capability for the export
Section titled “A capability for the export”A capability is a JSON (or TOML) file in src-tauri/capabilities/: which windows, which permissions (from src-tauri/capabilities/export.json, lines 1-7):
{ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "export", "description": "Lesson 20: export the favourites to a file the user picks", "windows": ["main"], "permissions": ["dialog:allow-save", "fs:allow-write-text-file"]}Every file in that folder is enabled unless tauri.conf.json lists capabilities by identifier, and a window in several capabilities gets all their permissions: the main window now has core:default from default.json plus these two. The $schema is generated at build time from the plugins actually registered, so the editor completes permission names and flags a typo. Adding the file under tauri dev recompiled the app (File src-tauri\capabilities\export.json changed. Rebuilding application...): capabilities are embedded in the binary, not read at run time.
The dialog opens now, and cancelling it gives export cancelled. Choosing a file writes it:
1 favorites written to C:\Users\spare\AppData\Local\Temp\claude\C--Users-spare-source-repos-learn\515605f1-4f43-4be8-b806-602d45816b1f\scratchpad\exportdir\favorites.txtdialog:allow-save and fs:allow-write-text-file were the two narrowest permissions. The fs:default set that tauri add fs would have put in the capability does not include writing at all, and granting fs:write-all would allow every write command. When you add a plugin with tauri add, open the capability afterwards and read what it granted.
Scopes
Section titled “Scopes”fs:allow-write-text-file allows the command, not any path. Called directly with a path the user did not pick:
rejected (string) "forbidden path: C:\Users\spare\AppData\Local\Temp\claude\C--Users-spare-source-repos-learn\515605f1-4f43-4be8-b806-602d45816b1f\scratchpad\exportdir\favorites.txt, maybe it is not allowed on the scope for `allow-write-text-file` permission in your capability file"So how did the export succeed? The dialog plugin adds the path the user chose to the fs scope before returning it (from plugins/dialog/src/commands.rs at dialog-v2.7.3, lines 248-256):
let path = dialog_builder.blocking_save_file(); if let Some(p) = &path { if let Ok(path) = p.clone().into_path() { if let Some(s) = window.try_fs_scope() { s.allow_file(&path)?; } tauri_scope.allow_file(&path)?; } }After that export, from the same page, the chosen file and its neighbour:
favorites.txt: writtenother.txt: forbidden path: C:\Users\spare\AppData\Local\Temp\claude\C--Users-spare-source-repos-learn\515605f1-4f43-4be8-b806-602d45816b1f\scratchpad\exportdir\other.txt, maybe it is not allowed on the scope for `allow-write-text-file` permission in your capability fileThe user’s choice is the grant, for that one file. The scope lives in memory; the persisted-scope plugin keeps it across restarts (to verify, not used here). A static scope is written in the capability instead, with an object in place of the string:
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$DOCUMENT/chords/*" }]}$DOCUMENT, $APPDATA, $HOME and the other variables are resolved per operating system, and deny entries win over allow ones (to verify: this capability is not the course’s).
Rust is not restricted
Section titled “Rust is not restricted”The favourites now survive a restart, and no capability grants the store. The Rust side opens it at startup and saves each change announced by lesson 18’s event (from src-tauri/src/lib.rs, lines 62-74):
fn restore_favorites<R: Runtime>(app: &AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> { let store = app.store(STORE_FILE)?; if let Some(saved) = store.get(FAVORITES_KEY) { let symbols: Vec<String> = serde_json::from_value(saved).unwrap_or_default(); app.state::<state::Favorites>().replace(symbols); } app.listen("favorites-changed", move |event| { if let Ok(symbols) = serde_json::from_str::<serde_json::Value>(event.payload()) { store.set(FAVORITES_KEY, symbols); } }); Ok(())}After adding Am7 to the favourites, %APPDATA%\dev.learn.chord-explorer\favorites.json contained:
{ "favorites": [ "Am7" ]}while store.load from the page was still refused. Permissions filter IPC calls from the webview; Rust code calls the plugin’s Rust API directly. That is the design to aim for: keep the page’s permissions small and put privileged work behind your own commands, which validate their arguments.
Your own commands, however, are not denied by default: every command in generate_handler! may be called by every window. To make them need a permission too, list them in AppManifest::commands in build.rs, then grant allow-<command> in a capability (to verify: the course’s app does not do it).
shell and opener
Section titled “shell and opener”Two plugins look alike and differ by an order of magnitude in risk:
- opener (2.5.5) opens a URL in the default browser, or a file with its default application. This is what
shell.opendid in Tauri 1. - shell (2.3.6) runs programs. Its permission takes a scope per program, with a validator for each argument:
{ "identifier": "shell:allow-execute", "allow": [ { "name": "exec-sh", "cmd": "sh", "args": ["-c", { "validator": "\\S+" }], "sidecar": false } ]}That example from the Tauri documentation shows the danger rather than a recommendation: sh -c with any non-blank argument lets the page run anything. If the page needs to run a program, grant that program with fixed or tightly validated arguments, or better, write a Rust command that runs it.
The Content Security Policy
Section titled “The Content Security Policy”Lesson 16’s template shipped "csp": null. The course’s tauri.conf.json sets one (from src-tauri/tauri.conf.json, lines 18-20):
"security": { "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost" }default-src 'self': scripts, styles, images and fonts come from the app’s own assets only;connect-src ipc: http://ipc.localhost:fetchmay reach Tauri’s IPC and nothing else. Without this line,invokeitself would be blocked, since the IPC travels over those two URLs.
Tauri adds this policy to the assets it serves, and at build time adds the hashes and nonces of the bundled scripts and styles to it. In the release build of the explorer, a fetch to a remote API logged, in the DevTools console:
[security error] Connecting to 'https://api.github.com/zen' violates the following Content Security Policy directive: "connect-src ipc: http://ipc.localhost". The action has been blocked.[javascript error] Fetch API cannot load https://api.github.com/zen. Refused to connect because it violates the document's Content Security Policy.Under tauri dev with Vite, the same fetch from the page at http://localhost:5173 returned 200. The page is served by Vite there, not by Tauri, so the policy was not applied. Test the CSP on a build, not only in development.
What a CSP buys in a desktop app: if an attacker manages to inject a script, it cannot load more code from elsewhere and cannot send data out with fetch. Loading remote scripts from a CDN defeats it. Bundle them instead.
Key takeaways
Section titled “Key takeaways”- The webview is treated as untrusted: every plugin command is refused until a capability grants a permission for that window.
- A capability lists permissions; a permission allows commands and may carry a scope. Prefer
allow-<command>overdefaultsets and*-all, and read whattauri addwrote. - Scopes restrict the resources:
fs:allow-write-text-filewrites nowhere by itself, and the dialog grants exactly the file the user picked. - Capabilities filter what the page calls, not what Rust does: the store used from Rust needed nothing, and your own commands are callable by default.
- The CSP from
tauri.conf.jsonapplies to the built app, not to a dev server’s page; keepconnect-src ipc: http://ipc.localhostin it.
Exercises
Section titled “Exercises”- The explorer should also import favourites from a text file the user picks. Which two permissions does the capability need, and what should it not contain?
Solution
dialog:allow-open to show the open dialog, and fs:allow-read-text-file to read the file. open adds the chosen path to the fs scope the same way save does (lines 195-212 of the same commands.rs), so no static scope is needed.
It should not contain fs:default, which grants reading the app’s own folders, useless here, nor fs:read-all. A tighter design has no fs permission at all: the page gets the path from the dialog and calls a Rust command import_favorites(path), which reads the file, validates each symbol with theory, and updates the state.
- An npm dependency of the frontend is compromised and runs its own script in the explorer’s page. With the course’s capabilities, list what it can do and what it cannot.
Solution
It can:
- call every command of the app (
generate_handler!exposes them to every window), for example fill the favourites with garbage, which Rust then saves in the store; - open the save dialog and, if the user picks a file, write any content into that file;
- use
core:default(events, window information, …).
It cannot:
- read or write any other file: no read permission, and the write scope contains only paths the user chose;
- run a program or open a URL: shell and opener are not even registered;
- send what it finds to a server with
fetch, or load more code from a URL, in the built app: the CSP blocks them. Navigating the window to another site is a different matter, the CSP does not govern navigation (to verify).
- A designer wants a font from Google Fonts in the built app. Which CSP directives must change, and what would you suggest instead?
Solution
The stylesheet comes from https://fonts.googleapis.com and the font files from https://fonts.gstatic.com, so style-src 'self' https://fonts.googleapis.com and font-src 'self' https://fonts.gstatic.com, keeping default-src 'self' and the connect-src for the IPC. Tauri’s own example in its CSP page uses exactly those two hosts.
Better: download the font files, put them in frontend/, and let Vite bundle them. The CSP stays 'self', the app works offline, and nothing about the user is sent to a third party when the app starts.
Sources
Section titled “Sources”- Tauri 2 — Security, Capabilities, Permissions, Command scopes, Content Security Policy
- Tauri 2 — Dialog, File System, Store, Opener, Shell
- tauri-apps/plugins-workspace: the plugins’ source and their
permissions/folders - Electron — Context Isolation and Security checklist
- MDN — Content Security Policy