Skip to content

Put the app's commands behind the capability ACL, trim plugin grants, keep the window on the app's pages - #123

Open
Sadykhzadeh wants to merge 7 commits into
mainfrom
security/app-command-acl
Open

Sadykhzadeh wants to merge 7 commits into
mainfrom
security/app-command-acl

Conversation

@Sadykhzadeh

@Sadykhzadeh Sadykhzadeh commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Refs #30. Refs #12.

What this does

#30 left a choice between (a) backend-attested terminal creation and (b) reducing webview→backend privilege generally. This takes (b). (a) does not close the primitive: keystrokes reach every session through session_write, called from the webview where xterm lives, and that command resolves a session by id alone — local or SSH. A creation gate leaves script in the page able to type into the local terminal the user already has open (local terminals are the default tab), or into an SSH session, which is worse. All (a) adds is closing the moment when no local terminal exists, and it costs a UX change to the default tab and Cmd/Ctrl+N.

1. App commands go through the capability ACL

Tauri checks an app command from the app's own pages against the capability files only when the app ships an ACL manifest. Clavyn had none, so capabilities/default.json gated plugin commands only, and every command in generate_handler! answered any window on a local page. (Remote pages were already refused: the runtime puts every non-local origin through the ACL.)

permissions/app-commands.toml declares one allow-<command> per registered command, and the capability grants each to the main window on local pages. Another window, another webview or a remote page is refused before the handler runs. The permissions are written out rather than generated: tauri-build writes generated ones into the source tree, and an ignored leftover gives the build of any older commit a manifest with no grants.

  • tests/app_command_acl.rs reads main.rs, the permissions file and the capability as shipped and fails when they disagree, when the capability widens past main or adds remote, or when plugin grants change. A command added to generate_handler! without a permission and a grant fails this test.
  • src/command_acl_tests.rs builds the shipped context on the mock runtime and invokes through on_message: main window + local page is served; another window and a remote page get the ACL error; the same call without a manifest is served from any local window.

2. Plugin grants trimmed to what the frontend calls

core:default (menus, path, app, window, image, …) becomes event listen/unlisten plus the debug-only devtools hotkey. os:default is dropped; nothing imports @tauri-apps/plugin-os. shell:allow-open stays, already scoped to the two GitHub URLs.

3. The main window stays on the app's origin

A plugin on_navigation hook cancels top-level navigations that leave devUrl (dev) or the custom protocol (packaged: http(s)://tauri.localhost on WebView2, tauri://localhost elsewhere). A remote page in the main window cannot invoke commands, but it would be drawn in Clavyn's frame and could pose as the vault unlock or password prompt. Refused URLs are logged by origin and not handed to the OS browser, which would bypass the shell scope.

4. script-src 'self' declared

Scripts no longer inherit whatever default-src becomes. Tauri appends its bundle hashes to the declared directive and keeps 'self', so the header a packaged build sends is unchanged (verified: identical before and after).

What this does not close

Script running in the bundled app's own page still reaches session_write, create_local_terminal and every other command the UI uses. That is inherent to a terminal whose input handling lives in the webview. The barrier for it remains what #30 describes: a CSP with no 'unsafe-eval' and no remote script sources, and no v-html/innerHTML/eval/new Function or link addon in desktop/src. I think #30 can close with that recorded as accepted risk; further trust-boundary work belongs under #12.

list_sessions is registered and granted but the frontend never calls it; dropping it would stop handing page script the live session ids. Left for a separate change.

Test plan

  • cargo check --workspace
  • cargo test -p clavyn-core, cargo test -p clavyn-desktop (new: 4 file tests, 2 IPC tests, 3 navigation tests, updated CSP test)
  • cd desktop && npx vue-tsc --noEmit, npm test (383 passed)
  • node --test scripts/verify/check-comments.test.mjs, node scripts/verify/check-comments.mjs
  • Packaged-style build on Windows, driven over CDP: hosts, vault status, settings, event listen, and a local terminal opened with Ctrl+N and typed into all work. path, menu, app, os, window plugin calls refused. Navigation to a remote URL blocked and logged. A build without the guard, navigated to a non-app origin, gets list_hosts not allowed on window "main" … allowed on: [windows: "main", URL: local].
  • tauri dev: same checks against devUrl.
  • cd desktop/e2e && UI_RECORD=0 npm test — not run
  • macOS / Linux (tauri://localhost branch of the navigation guard) — unit tests only
  • Vault flows and SSH/SFTP at runtime — covered by the per-command grant test only

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Tauri checks a command invoked from the app's own pages against the
capability files only when it is a plugin command or the app ships an ACL
manifest. Clavyn shipped none, so capabilities/default.json gated plugin
commands alone, and every command in generate_handler! -- the local
shell, session_write, the vault, SFTP -- answered any window on a local
page without consulting it. Remote pages were already refused: the
runtime puts every non-local origin through the ACL whatever the
manifest says.

permissions/app-commands.toml declares one allow-<command> permission
per registered command, which gives the app a manifest, and the
capability grants each one to the main window on local pages. Nothing
else is granted them: another window, another webview or a remote page
gets a permission error before the handler runs.

The permissions are written out rather than generated by build.rs from a
command list. tauri-build writes generated ones into the source tree,
and an ignored leftover of them is picked up by the build of any commit
without this change, giving that build a manifest with no grants, where
every command is refused.

The command list now lives in three places, and a command registered but
not granted fails only when it is called. tests/app_command_acl.rs reads
main.rs, the permissions file and the capability as they ship and fails
when they disagree, and when the capability widens past the main window
or adds a remote context. command_acl_tests.rs builds the shipped context
on the mock runtime and invokes through on_message: the main window on a
local page is served, another window and a remote page are refused, and
the same call without a manifest is served from any local window.
core:default bundles the default set of every core plugin: menu, path,
app, image, window, webview, resources and tray. The frontend uses one of
them, listen and unlisten from the event plugin. The rest let a script in
the page replace the app and window menus with items of its choosing and
resolve the user's home and data directories, among others.

os:default granted the os plugin's locale command. Nothing in the
frontend imports @tauri-apps/plugin-os.

The capability now grants event listen and unlisten, the debug-build
devtools hotkey Tauri injects (inert in release builds, which do not
register the command), and shell open, which tauri.conf.json already
scopes to the two GitHub URLs the UI opens. A test pins that list, so
widening it is a visible change to the test as well as to the
capability.
The webview followed any top-level navigation: a script setting
location, or a link dropped onto the window (the native drop handler is
off, so the webview's own drop behaviour applies). A remote page loaded
that way cannot invoke the app's commands, which are granted to local
pages only, but it is drawn inside Clavyn's window, where it can pass
for the vault unlock screen or a host password prompt.

The app never navigates its window -- it is a single page, and its
external links go through shell open -- so a plugin now cancels every
navigation that leaves the app's origin: devUrl in a dev build, and the
custom protocol (tauri.localhost on WebView2, tauri://localhost
elsewhere) in a packaged one. A refused URL is logged by origin and not
passed to the system browser, which would give page script an open
outside the shell plugin's scope.
Scripts were governed by default-src alone, so any source later added to
default-src for images, fonts or a new connection would also have become
a place scripts load from. script-src now says 'self' itself.

The policy test asserted the directive's absence on the grounds that
Tauri would overwrite a declared one. It does not: it appends a hash of
every bundled script file, and any inline-script hashes and nonces, to
the declared directive, keeps 'self', and creates the directive only when
it is missing. A packaged build sends the same script-src either way:
'self' followed by the bundle's hashes. The test now pins the declared
value, without 'unsafe-eval' or 'wasm-unsafe-eval'; the bundle uses
neither eval nor WebAssembly.
generate_context! embeds Info.plist on macOS under a fixed symbol, and the
desktop binary's own context already defines it, so the test build failed
to link on macOS with _EMBED_INFO_PLIST defined twice. test = true skips
only that embed; the capabilities and ACL under test are the same.

@computerbox124 computerbox124 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review: 2 findings, inline.

  1. Medium: the navigation guard covers top-level navigations only. window.open / target=_blank popups bypass it, and on WebView2 can render a remote page that poses as an app screen (navigation.rs:22).
  2. Low: os:default is dropped, but tauri_plugin_os::init() and the crate are still there (capabilities/default.json, main.rs:128).

The ACL manifest, its grant test and the CSP change look sound. The comment-hygiene check passes on this branch.


Generated by Claude Code


pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("navigation-guard")
.on_navigation(|webview, url| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: on_navigation doesn't see new-window requests.

window.open(url) and target=_blank links produce new-window requests. They aren't top-level navigations of this webview, so this hook is never asked about them. On WebView2, unless something handles the new-window request, the default is a popup that renders the remote page. That popup can pose as the vault unlock or password prompt, which is the spoofing this module says it prevents. So the module doc's "every top-level navigation that would leave the app's origin is cancelled, whatever started it" overstates the guarantee.

The app itself calls window.open in SettingsView.vue (lines 66 and 74) and UpdateModal.vue, so this path is live.

Suggestion: deny new-window requests too (recent Tauri 2 has an on_new_window handler on the webview builder), and send the app's own external links through the shell plugin. Then check in a packaged Windows build that window.open('https://example.com') opens nothing. I haven't confirmed this on a real build.


Generated by Claude Code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that on_navigation never sees new-window requests. The popup doesn't open, though. I checked against the versions in Cargo.lock (tauri / tauri-runtime-wry 2.12.1, wry 0.57.0): no webview here registers on_new_window, and each backend refuses the popup when no handler is set.

  • WebView2: wry always subscribes to NewWindowRequested. With no new_window_req_handler, it takes the else branch, calls args.SetHandled(true) and never sets NewWindow. window.open returns null and nothing renders (wry/src/webview2/mod.rs).
  • WKWebView: webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures: returns None when no handler is set (wry_web_view_ui_delegate.rs).
  • WebKitGTK: wry only calls connect_create when a handler is set. The default create handler returns no webview.

Tauri only passes a handler to wry when WebviewBuilder::on_new_window is called (tauri-runtime-wry lib.rs), and nothing in the app calls it. So the window.open calls in SettingsView.vue / UpdateModal.vue open nothing in the app. They're also only reached when the dynamic import of the bundled shell plugin fails. I haven't confirmed this on a packaged Windows build, only from source.

The doc did overstate what the module covers, though. f5f8972 adds a paragraph to the module doc: new-window requests never reach this hook, what refuses them on each backend, and that a webview registering on_new_window makes its handler responsible for refusing non-app URLs.

I haven't added an explicit on_new_window(|_, _| NewWindowResponse::Deny). The handler can only be set on the builder, so the main window would have to leave app.windows ("create": false) and be built in setup, which changes startup order. That would buy a log line for refused popups and independence from wry's no-handler default, which holds on all three backends. It wouldn't change what opens today. I'm leaving this thread open so you can decide. If you'd rather have the refusal in code, it's a small follow-up.


Generated by Claude Code

Comment thread desktop/src-tauri/capabilities/default.json
os:default left the capability because nothing in the frontend imports
@tauri-apps/plugin-os, but the plugin itself stayed registered. Its init
script runs in every page whatever the capability grants and defines
window.__TAURI_OS_PLUGIN_INTERNALS__ with the platform, OS type, family,
version, architecture, EOL and executable extension, so page script
could still read them with no grant. Nothing in the app reads them.

Dropping the plugin and the crate removes that, along with os_info,
sys-locale and gethostname, which only it pulled in.
The navigation guard sees top-level navigations of the webview it is
attached to. window.open and target=_blank links request a new window
instead, which never reaches on_navigation, and the module doc did not
say what handles them.

They open nothing because no webview registers on_new_window. With no
handler, wry 0.57 marks a WebView2 NewWindowRequested as handled
without supplying a window, its WKWebView UI delegate returns none, and
WebKitGTK falls back to its default create handler, which makes none.
The doc now says so, and that a webview which registers a handler takes
on refusing non-app URLs itself.
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

TREX TREX could not finish testing

  • Plan what to test
  • Set up the environment and test the changed flows
  • Post test results

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo


Action required

1. Webview script can control local shells 📎
Description
The capability grants the main app webview both create_local_terminal and session_write without
any backend-attested authorization. JavaScript running on that page can therefore spawn a shell or
send bytes to an existing local terminal by session ID, including terminals opened through the
normal UI.
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The main webview is granted both local-terminal creation and session input commands, while the backend authorizes requests only by command parameters and session ID. Webview JavaScript can create a shell or write to an existing local terminal without backend-attested authorization.

## Fix Focus Areas
- desktop/src-tauri/capabilities/default.json[51-53]
- desktop/src-tauri/permissions/app-commands.toml[177-183]
- desktop/src-tauri/src/commands.rs[669-766]
- desktop/src-tauri/src/commands.rs[866-894]

## Recommended Fix
Remove direct webview access to terminal creation and session input, or require a backend-issued authorization bound to the intended terminal and session before both commands execute. Ensure `session_write` cannot act solely on a caller-supplied session ID and that the normal UI obtains authorization through a backend-controlled flow.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


View medium (1)
Remediation recommended
2. Windows accepts a second page origin 🐞
Description
is_app_page accepts both HTTP and HTTPS for tauri.localhost, although this app does not enable
the HTTPS scheme for its Windows assets. If the main window navigates to https://tauri.localhost,
the guard permits a page outside the configured app origin instead of cancelling the navigation.
Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Windows navigation guard accepts HTTPS even though this app serves its packaged pages over HTTP by default.

## Fix Focus Areas
- desktop/src-tauri/src/navigation.rs[64-68]
- desktop/src-tauri/src/navigation.rs[114-131]

## Recommended Fix
Accept only the scheme configured for the Windows custom protocol. Update the packaged Windows navigation test so HTTPS is refused unless HTTPS scheme support is enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


Comment on lines +51 to +53
"allow-connect-ssh",
"allow-create-local-terminal",
"allow-session-write",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Webview script can control local shells 📎 Requirement gap ⛨ Security

The capability grants the main app webview both create_local_terminal and session_write without
any backend-attested authorization. JavaScript running on that page can therefore spawn a shell or
send bytes to an existing local terminal by session ID, including terminals opened through the
normal UI.
Agent Prompt
## Issue description
The main webview is granted both local-terminal creation and session input commands, while the backend authorizes requests only by command parameters and session ID. Webview JavaScript can create a shell or write to an existing local terminal without backend-attested authorization.

## Fix Focus Areas
- desktop/src-tauri/capabilities/default.json[51-53]
- desktop/src-tauri/permissions/app-commands.toml[177-183]
- desktop/src-tauri/src/commands.rs[669-766]
- desktop/src-tauri/src/commands.rs[866-894]

## Recommended Fix
Remove direct webview access to terminal creation and session input, or require a backend-issued authorization bound to the intended terminal and session before both commands execute. Ensure `session_write` cannot act solely on a caller-supplied session ID and that the normal UI obtains authorization through a backend-controlled flow.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment on lines +65 to +67
matches!(url.scheme(), "http" | "https")
&& url.host_str() == Some("tauri.localhost")
&& url.port().is_none()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Windows accepts a second page origin 🐞 Bug ⛨ Security

is_app_page accepts both HTTP and HTTPS for tauri.localhost, although this app does not enable
the HTTPS scheme for its Windows assets. If the main window navigates to https://tauri.localhost,
the guard permits a page outside the configured app origin instead of cancelling the navigation.
Agent Prompt
## Issue description
The Windows navigation guard accepts HTTPS even though this app serves its packaged pages over HTTP by default.

## Fix Focus Areas
- desktop/src-tauri/src/navigation.rs[64-68]
- desktop/src-tauri/src/navigation.rs[114-131]

## Recommended Fix
Accept only the scheme configured for the Windows custom protocol. Update the packaged Windows navigation test so HTTPS is refused unless HTTPS scheme support is enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants