diff --git a/Cargo.lock b/Cargo.lock index 8bf2e5d4..2344c579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1313,6 +1313,25 @@ dependencies = [ "libc", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1629,6 +1648,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", + "open", "parking_lot", "rand 0.9.2", "raw-window-handle", @@ -2230,6 +2250,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2299,6 +2330,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/Cargo.toml b/Cargo.toml index 76d8a2a2..6f3276ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ neovide-derive = { path = "neovide-derive", version = "0.1.5" } notify-debouncer-full = "0.6.0" num = "0.4.3" nvim-rs = { version = "0.9.2", features = ["use_tokio"] } +open = "5" parking_lot = "0.12.3" rand = "0.9.0" raw-window-handle = "0.6.2" diff --git a/lua/init.lua b/lua/init.lua index 0848d41d..38124167 100644 --- a/lua/init.lua +++ b/lua/init.lua @@ -3,6 +3,7 @@ ---@field neovide_version string ---@field config_path string ---@field register_clipboard boolean +---@field register_open boolean ---@field register_right_click boolean ---@field remote boolean ---@field enable_focus_command boolean @@ -80,6 +81,35 @@ if args.register_clipboard and not vim.g.neovide_no_custom_clipboard then vim.cmd.runtime("autoload/provider/clipboard.vim") end +if args.register_open and not vim.g.neovide_no_remote_open then + local original_open = vim.ui.open + + vim.ui.open = function(path, opt) + opt = opt or {} + -- If user specified a custom command or opted out of remote open, + -- delegate to the original implementation (runs on the remote machine) + if opt.cmd or opt.neovide_no_remote_open then + return original_open(path, opt) + end + + -- Send to Neovide to open on the host system + local ok, err = pcall(rpcrequest, "neovide.open", path) + if not ok then + return nil, "neovide.open failed: " .. tostring(err) + end + + -- Return a mock SystemObj for API compatibility + -- (callers may chain :wait() on the result) + local obj = { + pid = 0, + wait = function(_, _timeout) + return { code = 0, signal = 0, stdout = "", stderr = "" } + end, + } + return obj, nil + end +end + if args.register_right_click then vim.api.nvim_create_user_command("NeovideRegisterRightClick", function() rpcnotify("neovide.register_right_click") diff --git a/src/bridge/handler.rs b/src/bridge/handler.rs index 9ee2f7f2..8e019a02 100644 --- a/src/bridge/handler.rs +++ b/src/bridge/handler.rs @@ -29,6 +29,7 @@ use crate::{ }; use super::ui_commands::UiCommand; +use super::url_allowlist::is_url_allowed; #[derive(Debug, PartialEq, Eq)] enum ClipboardRequestError { @@ -90,6 +91,7 @@ pub struct NeovimHandler { #[allow(dead_code)] settings: Arc, clipboard: ClipboardHandle, + allowed_url_patterns: Option>, } impl std::fmt::Debug for NeovimHandler { @@ -109,6 +111,7 @@ impl NeovimHandler { route_id: RouteId, settings: Arc, clipboard: ClipboardHandle, + allowed_url_patterns: Option>, ) -> Self { Self { proxy: Arc::new(Mutex::new(proxy)), @@ -121,6 +124,7 @@ impl NeovimHandler { route_id, settings, clipboard, + allowed_url_patterns, } } @@ -192,6 +196,20 @@ impl Handler for NeovimHandler { .map_err(|_| ClipboardRequestError::CannotSetContents) }) .map_err(Value::from), + "neovide.open" => { + let path = arguments + .first() + .and_then(|v| v.as_str()) + .ok_or_else(|| Value::from("neovide.open: missing path argument"))?; + + if !is_url_allowed(path, &self.allowed_url_patterns) { + return Err(Value::from(format!("URL rejected by allowlist: {path}"))); + } + + open::that(path) + .map(|_| Value::Nil) + .map_err(|e| Value::from(format!("neovide.open: {e}"))) + } "neovide.quit" => { let error_code = arguments[0].as_i64().expect("Could not parse error code from neovim"); diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 855c79cd..1eefd4fa 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -7,6 +7,7 @@ mod restart; pub mod session; mod setup; mod ui_commands; +mod url_allowlist; use std::{ io::Error, @@ -399,12 +400,15 @@ impl NeovimRuntime { mode: OpenMode, ) -> Result { let mut colorscheme_stream = self.colorscheme_stream(); + let allowed_url_patterns = + config.remote.as_ref().and_then(|r| r.allowed_url_patterns.clone()); let editor_handler = start_editor_handler( route_id, event_loop_proxy.clone(), running_tracker, settings.clone(), self.clipboard.clone(), + allowed_url_patterns, ); let initial_background = self.runtime().block_on(initial_background_from_stream(&mut colorscheme_stream)); diff --git a/src/bridge/setup.rs b/src/bridge/setup.rs index ffd52284..5e80a3e6 100644 --- a/src/bridge/setup.rs +++ b/src/bridge/setup.rs @@ -55,6 +55,7 @@ pub async fn setup_neovide_specific_state( .context("Error setting client info")?; let register_clipboard = remote; + let register_open = remote; let register_right_click = cfg!(target_os = "windows"); let setting_locations = settings.setting_locations(); @@ -80,6 +81,7 @@ pub async fn setup_neovide_specific_state( "neovide_version" => BUILD_VERSION, "config_path" => config_path().to_string_lossy().into_owned(), "register_clipboard" => register_clipboard, + "register_open" => register_open, "register_right_click" => register_right_click, "remote" => remote, "global_variable_settings" => global_variable_settings, diff --git a/src/bridge/url_allowlist.rs b/src/bridge/url_allowlist.rs new file mode 100644 index 00000000..2970c309 --- /dev/null +++ b/src/bridge/url_allowlist.rs @@ -0,0 +1,167 @@ +/// Check if a URL/path matches a single wildcard pattern. +/// +/// `*` matches any sequence of characters (including none). +/// All other characters are matched literally. +fn wildcard_match(pattern: &str, text: &str) -> bool { + // Split pattern on `*`, then verify each literal segment appears in order. + let segments: Vec<&str> = pattern.split('*').collect(); + + // If there are no wildcards, require exact match + if segments.len() == 1 { + return pattern == text; + } + + let mut pos = 0; + + for (i, segment) in segments.iter().enumerate() { + if segment.is_empty() { + continue; + } + + match text[pos..].find(segment) { + Some(offset) => { + // First segment must anchor to the start + if i == 0 && offset != 0 { + return false; + } + pos += offset + segment.len(); + } + None => return false, + } + } + + // The last segment is what comes after the final `*`. + // If it's non-empty, the text must end with it (end anchor). + // If it's empty (pattern ends with `*`), no end anchoring needed. + let last = segments.last().expect("split always produces at least one segment"); + if !last.is_empty() { + return text.ends_with(last); + } + true +} + +/// Check if a URL is allowed by any of the patterns. +/// +/// Returns `false` if patterns is `None` or empty (deny by default). +pub fn is_url_allowed(url: &str, patterns: &Option>) -> bool { + match patterns { + None => false, + Some(p) if p.is_empty() => false, + Some(patterns) => patterns.iter().any(|p| wildcard_match(p, url)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_match() { + assert!(wildcard_match("hello", "hello")); + assert!(!wildcard_match("hello", "world")); + } + + #[test] + fn test_wildcard_matches_all() { + assert!(wildcard_match("*", "anything")); + assert!(wildcard_match("*", "")); + } + + #[test] + fn test_wildcard_prefix() { + assert!(wildcard_match("http://*", "http://example.com")); + assert!(wildcard_match("http://*", "http://example.com/path/to/page")); + assert!(!wildcard_match("http://*", "https://example.com")); + } + + #[test] + fn test_wildcard_suffix() { + assert!(wildcard_match("*.txt", "file.txt")); + assert!(wildcard_match("*.txt", "path/to/file.txt")); + assert!(!wildcard_match("*.txt", "file.rs")); + } + + #[test] + fn test_wildcard_both_ends() { + assert!(wildcard_match("http://*.com", "http://example.com")); + assert!(wildcard_match("http://*.com", "http://www.example.com")); + assert!(!wildcard_match("http://*.com", "http://example.org")); + } + + #[test] + fn test_multiple_wildcards() { + assert!(wildcard_match("http://*/path/*", "http://example.com/path/to/page")); + assert!(!wildcard_match("http://*/path/*", "http://example.com/other/page")); + } + + #[test] + fn test_anchoring() { + // Pattern must match from start + assert!(!wildcard_match("://*", "http://example.com")); + // Pattern must match to end + assert!(!wildcard_match("*.com/path", "http://example.com/path/extra")); + } + + #[test] + fn test_is_url_allowed() { + let patterns = Some(vec!["https://*".to_string(), "http://*".to_string()]); + assert!(is_url_allowed("https://example.com", &patterns)); + assert!(is_url_allowed("http://example.com/path", &patterns)); + assert!(!is_url_allowed("ftp://example.com", &patterns)); + + // Deny by default + assert!(!is_url_allowed("https://example.com", &None)); + assert!(!is_url_allowed("https://example.com", &Some(vec![]))); + } + + // --- Adversarial pattern examples --- + // + // These tests document some common but insecure patterns. + + #[test] + fn adversarial_userinfo_spoof() { + // Pattern intends: any github.com URL + // Problem: `*` matches the `@`, so `github.com` appears as the userinfo + // (username:password) prefix of a completely different host. + assert!(wildcard_match( + "https://github.com*", + "https://github.com:foo@phishing-domain.com/", + )); + // Solution: always include the `/` after the domain name. + assert!(!wildcard_match( + "https://github.com/*", + "https://github.com:foo@phishing-domain.com/", + )); + } + + #[test] + fn adversarial_scheme() { + // Pattern intends: any path on mydomain.com + // Problem: the pattern matches any URL scheme + assert!(wildcard_match( + "*://mydomain.com/*", + "mailto:hacker@phishing-domain.com?subject=://mydomain.com/&body=Get%20phished", + )); + // Solution: always start the pattern with the intended scheme + assert!(!wildcard_match( + "https://mydomain.com/*", + "mailto:hacker@phishing-domain.com?subject=://mydomain.com/&body=Get%20phished", + )); + } + + #[test] + fn adversarial_subdomains() { + // Pattern intends: any path on *.mydomain.com + // Problem: `*` matches across the `?` query boundary, so a phishing + // domain can embed the literal substring in its query string. + assert!(wildcard_match( + "https://*.mydomain.com/*", + "https://phishing-domain.com/?.mydomain.com/", + )); + // Solution: do not use wildcard subdomains. + assert!(!wildcard_match( + "https://allowed.mydomain.com/*", + "https://phishing-domain.com/?.mydomain.com/", + )); + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index a0c87abb..b676f67e 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -1335,6 +1335,7 @@ pub fn start_editor_handler( running_tracker: RunningTracker, settings: Arc, clipboard: ClipboardHandle, + allowed_url_patterns: Option>, ) -> NeovimHandler { let (redraw_event_sender, mut redraw_event_receiver) = unbounded_channel(); let (ui_command_sender, ui_command_receiver) = unbounded_channel(); @@ -1347,6 +1348,7 @@ pub fn start_editor_handler( route_id, settings.clone(), clipboard, + allowed_url_patterns, ); thread::spawn(move || { let mut editor = Editor::new(route_id, event_loop_proxy.clone(), settings.clone()); diff --git a/src/settings/config.rs b/src/settings/config.rs index 34d6e9b7..d3cd66ae 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -21,6 +21,12 @@ use std::path::{Path, PathBuf}; use super::font::FontSettings; +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct RemoteConfig { + pub allowed_url_patterns: Option>, +} + const CONFIG_FILE: &str = "config.toml"; #[cfg(unix)] @@ -119,6 +125,7 @@ pub struct Config { pub wayland_app_id: Option, pub x11_wm_class: Option, pub x11_wm_class_instance: Option, + pub remote: Option, } #[derive(Debug, Clone, PartialEq)] diff --git a/website/docs/config-file.md b/website/docs/config-file.md index 2335988c..eb352701 100644 --- a/website/docs/config-file.md +++ b/website/docs/config-file.md @@ -74,6 +74,9 @@ mode = "font-glyph" [box-drawing.sizes] default = [2, 4] # Thin and thick values respectively, for all sizes + +[remote] +allowed-url-patterns = ["https://*", "http://*"] # deny all if unset ``` Refer to [Command Line Reference](command-line-reference.md) for details about the config settings @@ -258,3 +261,39 @@ The default location is the following: | Linux | `$XDG_DATA_HOME or $HOME/.local/share/neovide` | `/home/alice/.local/share/neovide` | | macOS | `$HOME/Library/Application Support/neovide` | `/Users/Alice/Library/Application Support/neovide` | | Windows | `{FOLDERID_LocalAppData}\neovide` | `C:\Users\Alice\AppData\Local\neovide` | + +#### Remote + +Controls behavior when Neovide connects to a remote Neovim instance (`--server` or `--wsl`). + +##### `allowed-url-patterns` + +```toml +[remote] +allowed-url-patterns = [ + "https://*", + "http://*", +] +``` + +When running in remote mode, Neovide overrides `vim.ui.open` to open URLs and files on the host +system. Patterns use simple wildcard matching where `*` matches any sequence of characters (including +`/`). Everything else is matched literally. For example: + +| Pattern | Matches | +|---------|--------| +| `https://*` | Any HTTPS URL | +| `http://example.com/*` | Any HTTP URL on the domain `example.com` | +| `*` | Dangerous! All URLs, including app deep links (e.g. `zoommtg://zoom.us/join?confno=`) | + +**Note:** Because `*` matches all characters, overly broad patterns can match unintended URLs. For +example, `https://*.mydomain.com/*` also matches `https://phishing-domain.com/?.mydomain.com/`. It +is recommended to always start your URL patterns with `https://` at a minimum. + +If `allowed-url-patterns` is not set or is empty, remote open will reject all URLs. When a URL is +rejected, you will see this error: + +> `neovide.open failed: URL rejected by allowlist: URL` + +You can disable the remote open functionality entirely by using `vim.g.neovide_no_remote_open` in +your [Vim Configuration](configuration.md). diff --git a/website/docs/configuration.md b/website/docs/configuration.md index aef800a3..aabe158c 100644 --- a/website/docs/configuration.md +++ b/website/docs/configuration.md @@ -851,6 +851,61 @@ indicator. Note: recommended setup is `--frame full` with titles enabled for a cleaner look. +### Remote Settings + +These settings only take effect when Neovide is connecting to a remote Neovim instance, i.e. when +`--server` or `--wsl` is passed (even on non-Windows platforms). + +#### No Custom Clipboard + +VimScript: + +```vim +let g:neovide_no_custom_clipboard = v:true +``` + +Lua: + +```lua +vim.g.neovide_no_custom_clipboard = true +``` + +When running in remote mode, Neovide normally overrides the clipboard provider so that yanking and +pasting use the host system's clipboard rather than the remote machine's. Setting +`g:neovide_no_custom_clipboard` to a boolean value of `true` disables this override, letting the +remote Neovim's own clipboard provider handle things instead. The default is `false`. + +#### No Remote Open + +VimScript: + +```vim +let g:neovide_no_remote_open = v:true +``` + +Lua: + +```lua +vim.g.neovide_no_remote_open = true +``` + +When running in remote mode, Neovide normally overrides `vim.ui.open` so that opening URLs and files +(for example via `gx`) uses the host system's default handler rather than trying to open them on the +remote machine. Setting `g:neovide_no_remote_open` to a boolean value of `true` disables this +override entirely at startup, so the remote Neovim's native `vim.ui.open` is used instead. The +default is `false`. + +You can also bypass the override on a per-call basis by passing `neovide_no_remote_open = true` in +the options table: + +```lua +vim.ui.open("https://example.com", { neovide_no_remote_open = true }) +``` + +This forces the call to use the remote machine's native handler without disabling the override +globally. Additionally, if the call specifies a `cmd` to use, Neovide does not intercept the call +(so `cmd` is always run remotely). + ### Input Settings #### macOS Option Key is Meta