use local open for remote neovim

This commit is contained in:
Ryan Patterson 2026-04-02 15:42:04 +08:00
parent 090851dc76
commit c9b26a24d5
11 changed files with 362 additions and 0 deletions

37
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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")

View file

@ -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<Settings>,
clipboard: ClipboardHandle,
allowed_url_patterns: Option<Vec<String>>,
}
impl std::fmt::Debug for NeovimHandler {
@ -109,6 +111,7 @@ impl NeovimHandler {
route_id: RouteId,
settings: Arc<Settings>,
clipboard: ClipboardHandle,
allowed_url_patterns: Option<Vec<String>>,
) -> 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");

View file

@ -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<NeovimHandler> {
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));

View file

@ -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,

167
src/bridge/url_allowlist.rs Normal file
View file

@ -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<Vec<String>>) -> 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/",
));
}
}

View file

@ -1335,6 +1335,7 @@ pub fn start_editor_handler(
running_tracker: RunningTracker,
settings: Arc<Settings>,
clipboard: ClipboardHandle,
allowed_url_patterns: Option<Vec<String>>,
) -> 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());

View file

@ -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<Vec<String>>,
}
const CONFIG_FILE: &str = "config.toml";
#[cfg(unix)]
@ -119,6 +125,7 @@ pub struct Config {
pub wayland_app_id: Option<String>,
pub x11_wm_class: Option<String>,
pub x11_wm_class_instance: Option<String>,
pub remote: Option<RemoteConfig>,
}
#[derive(Debug, Clone, PartialEq)]

View file

@ -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).

View file

@ -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