The threat model
DuckDB is not a sandbox. A singleselect can read arbitrary files; load_extension can pull a
network extension; export and COPY can write files. The plugin’s job is to stand between the
untrusted webview and that reach, and it does so with two independent gates, both checked
before the backend touches DuckDB:
Capabilities & permissions
Which commands the webview may invoke at all. Granted per-window in capability files.
Scope + Policy
Which values those commands may use — connections, paths, statements, extensions. A
declarative per-capability scope plus a mandatory init-time Rust
Policy.Defense in depth
Neither gate alone is sufficient; they cover different axes:- Permissions stop a command from being callable, but say nothing about its arguments.
- Scope (capability-declared) narrows connection strings and paths per window, declaratively.
Policy(Rust, init-time) is the mandatory floor — it applies to every operation regardless of capabilities, and covers read-only mode, connection allow-lists, path allow-lists, the risky-statement filter, and network-extension gating.
The select caveat — read this
Treat select (and query_arrow and stream, which also
forward arbitrary read SQL) as filesystem-read-capable. For an untrusted webview, defend in
depth:
read_only(true)plus anallow_connection(…)allow-list, and/or- a capability scope that pins
connection, - keep
allow_network(false)so network extensions cannot load.
A fully sandboxed read-only mode (disabling DuckDB external file access) is a planned follow-up.
Until then, the layered policy above is the recommended containment.
How denials surface
Both gates reject before the backend runs. A policy or scope denial surfaces to the webview as the plugin error stringdenied by policy: …, which rejects the invoke promise. A capability that
doesn’t grant a command fails at the Tauri IPC layer before the plugin is even reached.
Safe-defaults checklist
1
Start from the read-only default
Begin with
better-duck-tauri:default (load + close + select); add allow-execute only
where writes are truly needed.2
Run read-only where possible
read_only(true) for any connection the webview only reads.3
Pin connections
allow_connection(…) plus a scope connection entry to pin which databases open.4
Pin paths
allow_path(…) plus a scope path entry for import/export directories.5
Block risky statements
deny_risky_statements(true) to reject ATTACH / INSTALL / COPY / … via select / execute.6
Keep the network closed
Leave
allow_network(false) unless a feature genuinely needs httpfs / aws / azure.See also
- Capabilities & permissions — the first gate, with exact identifiers.
- Scope & Policy — the second gate, in full.