fix(security): close the findings of GHSA-442p-gxfg-qxv9

- Restricted Mode git: reads run without a signature verifier, hooks
  (including config-defined ones), an external diff, filters or a lazy
  fetch, also inside submodules; every repository and every git starts
  restricted until the project is trusted.
- project.write and plugin quick fixes cannot reach git's own files under
  any spelling or through links; project.write is a high-risk grant.
- Built-in web server: each served root has its own port and token; a
  file outside the project is served with its folder minus dot names, or
  alone when that folder is the home folder or above it; no fixed port.
- Images, references, Markdown links and language-server links reach no
  network share other than the one already open.
- Marketplace: entries whose id or version no manifest may carry are
  dropped; an index read over the network names nothing on this machine;
  network reads are bounded in size and time.
- Agent replies load remote images on a click; the Markdown preview and
  Mermaid configuration no longer fetch from other hosts.
This commit is contained in:
Fabio Nunes Cerqueira
2026-11-11 22:18:48 +08:00
committed by l0ng-ai
parent 8b179992e8
commit a497178b3e
53 changed files with 3206 additions and 243 deletions
Generated
+1 -1
View File
@@ -4964,9 +4964,9 @@ dependencies = [
name = "ide-web"
version = "0.1.42"
dependencies = [
"dirs 6.0.0",
"ide-core",
"open",
"parking_lot",
"percent-encoding",
"serde",
"specta",
+13 -3
View File
@@ -14,8 +14,18 @@ pub(crate) struct Created {
pub base_commit: String,
}
/// The repository of `path`, free to execute: the host runs every agent command that
/// reaches a repository only in a trusted project.
fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let repo = Repo::discover(path)?;
if let Some(repo) = &repo {
repo.set_execution_allowed(true);
}
Ok(repo)
}
fn repo_at(path: &Path) -> IdeResult<Repo> {
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
}
/// The current branch (or commit when detached) and its commit.
@@ -91,7 +101,7 @@ fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Res
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
/// Outside a repository there is nothing to compare against: no changes.
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
let root = repo.root().to_path_buf();
let stats: std::collections::HashMap<String, (u32, u32)> =
repo.numstat_with_worktree(base)?.into_iter().map(|(p, a, r)| (p, (a, r))).collect();
@@ -209,7 +219,7 @@ pub(crate) fn restore(
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let Some(repo) = discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
+17 -2
View File
@@ -71,7 +71,6 @@ impl Capability {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
@@ -80,7 +79,14 @@ impl Capability {
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
// A trusted project's own files name the programs the IDE starts (its settings, its
// build scripts), so writing them is running code.
Self::ProjectWrite
| Self::IdeCommands
| Self::Ipc(_)
| Self::Process(_)
| Self::FsRead(_)
| Self::FsWrite(_) => Risk::High,
}
}
@@ -402,6 +408,15 @@ mod tests {
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
/// A trusted project's files name programs the IDE starts by itself: the git and language
/// server executables in `.openidea/settings.toml`, its build scripts. Writing them is
/// running code, and the install dialog says so.
#[test]
fn writing_project_files_is_a_high_risk_grant() {
assert_eq!(Capability::ProjectWrite.risk(), Risk::High);
assert!(!Capability::ProjectWrite.auto_migrates());
}
#[test]
fn host_patterns() {
assert!(host_matches("crates.io", "crates.io"));
+70 -3
View File
@@ -405,10 +405,31 @@ impl Index {
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
pub fn parse(text: &str) -> Result<Self, String> {
let (index, dropped) = Self::parse_dropping(text)?;
if let Some(first) = dropped.first() {
// One line per index, and a bounded one: whoever wrote the index chose the text.
let id: String = first.id.chars().take(80).collect();
tracing::warn!(
"index: {} entries are not offered, their id or version is not one a manifest may carry (first: {id:?})",
dropped.len()
);
}
Ok(index)
}
/// [`Index::parse`], with the entries it does not offer: those whose id or
/// version no manifest may carry, so no IDE could install them.
pub fn parse_dropping(text: &str) -> Result<(Self, Vec<IndexEntry>), String> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
if format == u64::from(INDEX_FORMAT) {
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
let mut index: Index = serde_json::from_value(value).map_err(|e| format!("index: {e}"))?;
// What no manifest may carry is not offered: the id and version also name files on disk.
let (offered, dropped) = std::mem::take(&mut index.plugins)
.into_iter()
.partition(|e| crate::validate_plugin_id(&e.id).is_ok() && e.semver().is_some());
index.plugins = offered;
return Ok((index, dropped));
}
if format < u64::from(INDEX_FORMAT) {
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
@@ -417,13 +438,14 @@ impl Index {
let items = value.get(field).and_then(serde_json::Value::as_array);
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
}
Ok(Index {
let index = Index {
format: u32::try_from(format).unwrap_or(u32::MAX),
publishers: each(&value, "publishers"),
plugins: Vec::new(),
revoked: each(&value, "revoked"),
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
})
};
Ok((index, Vec::new()))
}
/// Written in a format newer than this IDE reads: it lists no plugins.
@@ -908,6 +930,51 @@ mod tests {
}
}
#[test]
fn an_index_offers_only_the_ids_and_versions_a_manifest_may_carry() {
let invalid_ids = [
"../x",
"../../escape",
"acme/../x",
r"acme.\..\x",
"acme./../../../escape",
"/abs",
r"C:\x",
"ACME.x",
"acme.",
"acme..x",
"acme.x y",
"",
];
let invalid_versions =
["../1.0.0", "1.0.0/../../../escape", r"1.0.0\..\x", "1.0.0-..", " 1.0.0", "1.0", "latest", ""];
let valid = [("acme.x", "1.0.0"), ("acme.tools-2_b", "1.0.0-rc.1+b.2")];
let mut listed: Vec<(&str, &str)> = valid.to_vec();
listed.extend(invalid_ids.iter().map(|id| (*id, "1.0.0")));
listed.extend(invalid_versions.iter().map(|version| ("acme.x", *version)));
let plugins: Vec<serde_json::Value> = listed
.iter()
.map(|(id, version)| {
serde_json::json!({
"id": id, "name": "X", "version": version, "publisher": "acme",
"download": "x.oiplugin", "digest": "d", "engine": "^1.0",
})
})
.collect();
let text = serde_json::json!({ "format": INDEX_FORMAT, "plugins": plugins }).to_string();
let pairs = |entries: &[IndexEntry]| -> Vec<(String, String)> {
entries.iter().map(|e| (e.id.clone(), e.version.clone())).collect()
};
let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
pairs.iter().map(|(id, version)| ((*id).to_owned(), (*version).to_owned())).collect()
};
assert_eq!(pairs(&Index::parse(&text).unwrap().plugins), owned(&valid));
let (index, dropped) = Index::parse_dropping(&text).unwrap();
assert_eq!(pairs(&index.plugins), owned(&valid));
assert_eq!(pairs(&dropped), owned(&listed[valid.len()..]));
}
#[test]
fn a_newer_index_still_revokes_and_moves() {
let newer = format!(
+10 -2
View File
@@ -102,6 +102,14 @@ fn parse<T>(r: serde_json::Result<T>) -> IdeResult<T> {
r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Protocol, format!("unexpected gh output: {e}")))
}
/// The repository of `root`, free to execute: the host runs the `gh_*` commands only in a
/// trusted project.
fn trusted_repo(root: &Path) -> IdeResult<Repo> {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
repo.set_execution_allowed(true);
Ok(repo)
}
/// Whether gh is installed and signed in, and which GitHub repository `root` maps to.
pub fn availability(root: &Path) -> GhAvailability {
if gh_executable().is_none() {
@@ -233,7 +241,7 @@ pub fn create(
return Err(IdeError::invalid("choose a base branch"));
}
if req.push {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
let repo = trusted_repo(root)?;
repo.push(false, true, cancel, on_output)?;
}
let mut args = vec!["pr", "create", "--title", req.title.trim(), "--body", &req.body, "--base", req.base.trim()];
@@ -354,7 +362,7 @@ pub fn prepare_diff(
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<PrDiffRevisions> {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
let repo = trusted_repo(root)?;
if !repo.has_commit(head_oid) || !repo.has_commit(base_oid) {
let remotes = repo.remotes()?;
let remote = remote_for(&remotes, name_with_owner)
@@ -22,7 +22,7 @@
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::{PluginHost, plugin_icon, read_market_url, resolve_market_href};
use ide_host::plugins::{PluginHost, market_href_reachable_from, plugin_icon, read_market_url, resolve_market_href};
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -591,7 +591,7 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
let base = a.value("download-base").map(|b| b.trim_end_matches('/'));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
Ok(text) => parse_index(&text, &out.to_string_lossy())?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
let publisher = match (a.value("publisher"), a.value("key")) {
@@ -722,14 +722,22 @@ fn register_key(
Ok(publishers)
}
/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
fn current_format(index: Index, file: &str) -> Result<Index, String> {
/// The index in `text` (read from `file`), unless a newer `openidea-plugin` wrote it (rewriting it
/// would drop what this one can't read). Says which entries it does not keep: no IDE offers them.
fn parse_index(text: &str, file: &str) -> Result<Index, String> {
let (index, dropped) = Index::parse_dropping(text)?;
if index.is_newer() {
return Err(format!(
"{file}: index format {} is newer than this tool writes; update openidea-plugin",
index.format
));
}
for e in &dropped {
eprintln!(
"warning: {file}: dropped {:?} {:?}: its id or version is not one a manifest may carry",
e.id, e.version
);
}
Ok(index)
}
@@ -740,14 +748,15 @@ fn cmd_merge(args: &[String]) -> Result<(), String> {
};
let keep = a.value("keep").ok_or("--keep is required")?;
let out = a.value("out").unwrap_or(base);
let mut index =
current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
let mut index = parse_index(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?, base)?;
let bytes = read_market_url(other, 16 << 20)?;
let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
let mut theirs = current_format(theirs, other)?;
let mut theirs = parse_index(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?, other)?;
// Their downloads are relative to their index, not to ours.
for e in &mut theirs.plugins {
e.download = resolve_market_href(other, &e.download);
if !market_href_reachable_from(other, &e.download) {
return Err(format!("{other}: the download of {} {} is not on the network", e.id, e.version));
}
}
let count = theirs.plugins.len();
index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?;
@@ -947,6 +956,47 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
/// An index read over the network is not merged when it names a download on this machine:
/// the merged index is a local one, which may.
#[test]
fn an_index_read_over_the_network_is_not_merged_with_local_downloads() {
use std::io::{BufRead, BufReader, Write};
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("index.json");
write_index(&Index { format: package::INDEX_FORMAT, ..Index::default() }, &base).unwrap();
let local = tmp.path().join("alice.dawn-1.0.0.oiplugin").to_string_lossy().into_owned();
let theirs = serde_json::json!({
"format": package::INDEX_FORMAT,
"publishers": [{ "id": "alice", "name": "Alice", "keys": [] }],
"plugins": [{
"id": "alice.dawn", "name": "Dawn", "version": "1.0.0", "publisher": "alice",
"download": local, "digest": "d", "engine": "^1.0",
}],
})
.to_string();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let other = format!("http://127.0.0.1:{}/index.json", listener.local_addr().unwrap().port());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
line.clear();
}
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{theirs}",
theirs.len()
);
}
});
let err = cmd_merge(&strings(&[base.to_str().unwrap(), &other, "--keep", "openidea"])).unwrap_err();
assert!(err.contains("is not on the network"), "{err}");
let merged = Index::parse(&std::fs::read_to_string(&base).unwrap()).unwrap();
assert!(merged.plugins.is_empty(), "{:?}", merged.plugins);
}
#[test]
fn the_index_carries_plugin_icons() {
let tmp = tempfile::tempdir().unwrap();
+311 -28
View File
@@ -45,26 +45,12 @@ fn innermost_repo<T>(repos: Vec<(PathBuf, T)>, path: &Path) -> Option<(PathBuf,
// Read tools must never invoke repository-configured executables, including in Restricted Mode.
fn read_command(git: &str, root: &Path) -> std::process::Command {
let mut command = std::process::Command::new(git);
ide_vcs::restrict(&mut command);
command
.current_dir(root)
.args([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=",
"-c",
"diff.external=",
"-c",
"core.attributesFile=",
"-c",
"submodule.recurse=false",
"-c",
"color.ui=false",
])
.env("GIT_NO_LAZY_FETCH", "1")
.args(["-c", "color.ui=false"])
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_EXTERNAL_DIFF")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
@@ -72,6 +58,32 @@ fn read_command(git: &str, root: &Path) -> std::process::Command {
command
}
/// `-c` arguments that empty the clean and process filters the repository's own config names:
/// git runs them to compare a changed file with the index. Filters of the user's config stay
/// (Git LFS), as they are not the repository's choice.
fn filter_overrides(git: &str, root: &Path) -> IdeResult<Vec<String>> {
let mut cmd = read_command(git, root);
cmd.args(["config", "--null", "--list", "--show-scope"]);
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run git: {e}")))?;
if !out.status.success() {
return Err(IdeError::internal(String::from_utf8_lossy(&out.stderr).trim().to_owned()));
}
repository_filter_overrides(&out.stdout)
}
/// [`filter_overrides`] out of `listing`, the output of `git config --null --list --show-scope`.
fn repository_filter_overrides(listing: &[u8]) -> IdeResult<Vec<String>> {
let mut fields = listing.split(|b| *b == 0);
let mut own = Vec::new();
while let (Some(scope), Some(record)) = (fields.next(), fields.next()) {
if matches!(scope, b"local" | b"worktree") {
own.extend_from_slice(record);
own.push(0);
}
}
ide_vcs::filter_overrides_from(&own)
}
/// The branch HEAD is on (unborn too); `None` when HEAD is detached or git fails.
fn read_branch(git: &str, root: &Path) -> Option<String> {
let mut cmd = read_command(git, root);
@@ -167,7 +179,10 @@ fn git_executable(call: &Call) -> String {
/// `git status` of the repository at `root`, paths relative to it.
fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeResult<Vec<StatusEntry>> {
let mut cmd = read_command(git, root);
cmd.args(filter_overrides(git, root)?);
cmd.args(["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--find-renames"]);
// git asks a submodule with changes for its own status, which runs under the submodule's config.
cmd.arg("--ignore-submodules=dirty");
// git refuses `--ignored=matching` with `--untracked-files=no`: untracked files are dropped below.
cmd.arg(if untracked || ignored { "--untracked-files=all" } else { "--untracked-files=no" });
if ignored {
@@ -183,6 +198,39 @@ fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeRes
Ok(entries)
}
/// `git diff` of the repository at `root` (of `file` alone, when given): the index against HEAD
/// when `staged`, else disk against the index.
fn read_diff(
git: &str,
root: &Path,
file: Option<&Path>,
staged: bool,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let mut cmd = read_command(git, root);
cmd.args(filter_overrides(git, root)?);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
// git asks a submodule with changes for its own status, and for its own diff when the
// repository's `diff.submodule` says so: both run under the submodule's config.
cmd.args(["--ignore-submodules=dirty", "--submodule=short"]);
if staged {
cmd.arg("--cached");
}
cmd.arg("--");
if let Some(file) = file {
cmd.arg(file.strip_prefix(root).map_err(|e| IdeError::invalid(e.to_string()))?);
}
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let out = cmd.output()?;
if !out.status.success() {
return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// A git repository in the project, for paths outside the repositories IDEA knows: the
/// closest folder with `.git` from `start` up to the project root.
fn find_git_root(root: &Path, start: &Path) -> Option<PathBuf> {
@@ -307,18 +355,7 @@ fn git_diff() -> Tool {
let staged = call.flag("staged", false)?;
let git = git_executable(&call);
let cancel = call.cancel.clone();
let text = call.host.blocking(move || {
let mut cmd = read_command(&git, &root);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
if staged { cmd.arg("--cached"); }
cmd.arg("--");
if let Some(file) = file { cmd.arg(file.strip_prefix(&root).map_err(|e| IdeError::invalid(e.to_string()))?); }
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() { return Err(IdeError::cancelled()); }
let out = cmd.output()?;
if !out.status.success() { return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim())); }
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}).await?;
let text = call.host.blocking(move || read_diff(&git, &root, file.as_deref(), staged, &cancel)).await?;
Ok(Output::text(text))
})
.param(Param::optional("repositoryPathRelativeToProject", ParamType::String, "Select the containing repository"))
@@ -342,6 +379,252 @@ mod tests {
assert!(e[3].conflicted() && !e[3].staged() && !e[3].unstaged());
}
/// Security audit lead 2 (variant): reading status or a diff must not run the repository's filters.
#[test]
fn status_and_diff_do_not_run_repository_filters() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@example.test"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
std::fs::write(root.join(".gitattributes"), "*.txt filter=marker\n").unwrap();
std::fs::write(root.join("a.txt"), "one\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "init"]);
git(&["config", "filter.marker.clean", "echo x > executed-marker; cat"]);
git(&["config", "filter.marker.process", "echo x > executed-marker; cat"]);
git(&["config", "filter.marker.required", "true"]);
let marker = root.join("executed-marker");
let mut ran = Vec::new();
// The size it has in the index, so git has to read the file to tell whether it changed.
std::fs::write(root.join("a.txt"), "two\n").unwrap();
let status = read_status("git", &root, false, false);
if marker.exists() {
ran.push("status");
}
let _ = std::fs::remove_file(&marker);
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap();
let diff = read_diff("git", &root, None, false, &ide_core::CancellationToken::new());
if marker.exists() {
ran.push("diff");
}
// Control: the fixture's filter really runs when git is used directly.
let _ = std::fs::remove_file(&marker);
std::fs::write(root.join("a.txt"), "six\n").unwrap();
let _ = std::process::Command::new("git").current_dir(&root).args(["status", "--porcelain"]).output();
assert!(marker.exists(), "control: plain `git status` did not run the filter, fixture is not live");
assert!(ran.is_empty(), "a read tool ran the repository's filter via: {ran:?}");
assert_eq!(status.unwrap().len(), 1);
let diff = diff.unwrap();
assert!(diff.contains("+three"), "{diff}");
}
#[test]
fn only_filters_of_the_repository_s_own_config_are_emptied() {
let listing = b"system\0filter.lfs.clean\ngit-lfs clean -- %f\0\
global\0filter.lfs.process\ngit-lfs filter-process\0\
global\0filter.lfs.required\ntrue\0\
local\0core.bare\nfalse\0\
local\0filter.a.clean\nx\0\
worktree\0filter.b.process\ny\0\
local\0filter.a.required\ntrue\0\
command\0filter.c.clean\nz\0";
assert_eq!(
repository_filter_overrides(listing).unwrap(),
["-c", "filter.a.clean=", "-c", "filter.b.process=", "-c", "filter.a.required=false"]
);
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
/// A repository at `dir` with `a.txt` committed.
fn repository(dir: &Path, attributes: &[u8]) {
std::fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.email", "t@example.test"]);
git(dir, &["config", "user.name", "t"]);
git(dir, &["config", "commit.gpgsign", "false"]);
std::fs::write(dir.join(".gitattributes"), attributes).unwrap();
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-qm", "init"]);
}
/// A shell command that leaves `marker` behind and passes its input on.
fn leaves(marker: &Path) -> String {
format!("echo x > '{}'; cat", marker.to_string_lossy().replace('\\', "/"))
}
/// Whether a program of the fixture left `marker` behind; removes it.
fn left(marker: &Path) -> bool {
let was = marker.exists();
let _ = std::fs::remove_file(marker);
was
}
/// Reads the status and then the diff of `root` after rewriting `file`, then has plain git
/// read the status as a control. Each write keeps the size the file has in the index, so git
/// has to read it to tell whether it changed.
fn read_both(root: &Path, file: &Path, marker: &Path) -> (IdeResult<Vec<StatusEntry>>, IdeResult<String>) {
let mut ran = Vec::new();
std::fs::write(file, "two\n").unwrap();
let status = read_status("git", root, false, false);
if left(marker) {
ran.push("status");
}
std::fs::write(file, "six\n").unwrap();
let diff = read_diff("git", root, None, false, &ide_core::CancellationToken::new());
if left(marker) {
ran.push("diff");
}
std::fs::write(file, "ten\n").unwrap();
let _ = std::process::Command::new("git").current_dir(root).args(["status", "--porcelain"]).output();
assert!(left(marker), "control: plain `git status` ran no program of the fixture, it is not live");
assert!(ran.is_empty(), "a read tool ran a program of the repository via: {ran:?}");
(status, diff)
}
#[test]
fn status_and_diff_do_not_run_filters_of_any_repository_config() {
type Configure = fn(&Path, &str);
let cases: [(&str, Configure); 4] = [
("clean", |root, command| git(root, &["config", "filter.marker.clean", command])),
("process", |root, command| git(root, &["config", "filter.marker.process", command])),
("included file", |root, command| {
let included = format!("[filter \"marker\"]\n\tclean = \"{command}\"\n");
std::fs::write(root.join("extra.gitconfig"), included).unwrap();
git(root, &["config", "include.path", "../extra.gitconfig"]);
}),
("worktree config", |root, command| {
git(root, &["config", "extensions.worktreeConfig", "true"]);
git(root, &["config", "--worktree", "filter.marker.clean", command]);
}),
];
for (case, configure) in cases {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let marker = root.with_file_name("marker");
repository(&root, b"*.txt filter=marker\n");
configure(&root, &leaves(&marker));
let (status, diff) = read_both(&root, &root.join("a.txt"), &marker);
assert_eq!(status.unwrap().len(), 1, "{case}");
let diff = diff.unwrap();
assert!(diff.contains("+six"), "{case}: {diff}");
}
}
/// git splits `-c name=value` at the first `=` and takes no name that is not UTF-8 on every
/// platform, so such a filter cannot be emptied: the tools read nothing instead.
#[test]
fn status_and_diff_refuse_a_repository_filter_git_cannot_be_told_to_skip() {
for name in [&b"x=y"[..], &[0xE9]] {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let marker = root.with_file_name("marker");
repository(&root, &[b"*.txt filter=", name, b"\n"].concat());
let section =
[b"[filter \"", name, format!("\"]\n\tclean = \"{}\"\n", leaves(&marker)).as_bytes()].concat();
let config = root.join(".git").join("config");
std::fs::write(&config, [std::fs::read(&config).unwrap(), section].concat()).unwrap();
let (status, diff) = read_both(&root, &root.join("a.txt"), &marker);
assert!(status.is_err() && diff.is_err(), "{name:?}");
}
}
/// git asks a submodule for its own status to tell whether it is dirty, under the
/// submodule's config.
#[test]
fn status_and_diff_do_not_run_filters_of_a_submodule() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let (sub, marker) = (root.join("sub"), root.with_file_name("marker"));
repository(&root, b"");
repository(&sub, b"*.txt filter=marker\n");
git(&root, &["add", "sub"]);
git(&root, &["commit", "-qm", "submodule"]);
git(&sub, &["config", "filter.marker.clean", &leaves(&marker)]);
git(&root, &["config", "diff.ignoreSubmodules", "none"]);
git(&root, &["config", "submodule.sub.ignore", "none"]);
let (status, diff) = read_both(&root, &sub.join("a.txt"), &marker);
status.unwrap();
diff.unwrap();
}
/// `git diff` writes the index back when it finds a file whose stat changed and whose content
/// did not, and git runs the hooks a config names for `post-index-change` on that write.
#[test]
fn status_and_diff_do_not_run_hooks_of_the_repository_s_config() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let marker = root.with_file_name("marker");
repository(&root, b"");
git(&root, &["config", "hook.marker.event", "post-index-change"]);
let command = format!("echo x > '{}'", marker.to_string_lossy().replace('\\', "/"));
git(&root, &["config", "hook.marker.command", &command]);
let listed = std::process::Command::new("git")
.current_dir(&root)
.args(["hook", "list", "post-index-change"])
.output()
.unwrap();
if !String::from_utf8_lossy(&listed.stdout).contains("marker") {
return; // This git has no hooks named by a config.
}
// The content it has in the index, with a time of its own that is not the index's.
let touch = |seconds: u64| {
let file = std::fs::File::options().write(true).open(root.join("a.txt")).unwrap();
file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(seconds)).unwrap();
};
let mut ran = Vec::new();
touch(60);
let status = read_status("git", &root, false, false);
if left(&marker) {
ran.push("status");
}
touch(120);
let diff = read_diff("git", &root, None, false, &ide_core::CancellationToken::new());
if left(&marker) {
ran.push("diff");
}
touch(180);
let _ = std::process::Command::new("git").current_dir(&root).args(["--no-pager", "diff"]).output();
assert!(left(&marker), "control: plain `git diff` did not run the hook, fixture is not live");
assert!(ran.is_empty(), "a read tool ran a hook of the repository's config via: {ran:?}");
assert!(status.unwrap().is_empty());
assert_eq!(diff.unwrap(), "");
}
/// `diff.submodule=diff` has git run `git diff` in the submodule, under the submodule's config.
#[test]
fn diff_does_not_run_a_diff_driver_of_a_submodule() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let (sub, marker) = (root.join("sub"), root.with_file_name("marker"));
repository(&root, b"");
repository(&sub, b"*.txt diff=marker\n");
git(&root, &["add", "sub"]);
git(&root, &["commit", "-qm", "submodule"]);
std::fs::write(sub.join("a.txt"), "one\ntwo\n").unwrap();
git(&sub, &["commit", "-qam", "second"]);
git(&sub, &["config", "diff.marker.command", &leaves(&marker)]);
git(&root, &["config", "diff.submodule", "diff"]);
let diff = read_diff("git", &root, None, false, &ide_core::CancellationToken::new());
let ran = left(&marker);
let _ = std::process::Command::new("git").current_dir(&root).args(["--no-pager", "diff"]).output();
assert!(left(&marker), "control: plain `git diff` did not run the diff driver, fixture is not live");
assert!(!ran, "git_diff ran the submodule's diff driver");
let diff = diff.unwrap();
assert!(diff.contains("Subproject commit"), "{diff}");
assert_eq!(read_status("git", &root, false, false).unwrap().len(), 1);
}
#[cfg(unix)]
#[test]
fn paths_through_a_symlinked_root_compare_canonically() {
+5 -3
View File
@@ -17,9 +17,11 @@ pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u
blocking(move || ide_vfs::read_range(&path, offset, len)).await
}
/// An image file as a `data:` URL.
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
blocking(move || ide_vfs::read_data_url(&path)).await
/// An image file as a `data:` URL. A network path is read only from a share an open project is
/// on: the path may come from Markdown, and opening a share signs in to its server.
pub async fn fs_read_image(host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
let opened: Vec<PathBuf> = host.projects().iter().map(|project| project.root.clone()).collect();
blocking(move || ide_vfs::read_data_url(&path, &opened)).await
}
/// Commands of this module the remote server answers.
+135 -3
View File
@@ -884,7 +884,6 @@ fn plugin_file_edits(
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
use ide_core::FileEdit;
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
@@ -913,13 +912,14 @@ fn plugin_file_edits(
}
}
for (path, edits) in &a.edits {
let path = if confine { writable(path)? } else { path.clone() };
let path = writable(path)?;
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
}
let open = a.open.as_deref().map(confined).transpose()?;
// Opening a file hands its text to the plugin, so a link is judged by the file it leads to.
let open = a.open.as_deref().map(writable).transpose()?;
Ok((files, open))
}
@@ -2341,6 +2341,138 @@ mod tests {
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
}
/// Advisory #4b: an action carrying only text edits (no file operation, no `open`) must still
/// be confined to the project root and kept off git's own files. A plugin needs no grant to
/// offer a quick fix, so this is decided here.
#[test]
fn a_plugin_edits_only_action_stays_in_the_project_and_off_git() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git/config"), "[core]\n").unwrap();
std::fs::write(root.join("a.txt"), "a").unwrap();
let root = dunce::canonicalize(&root).unwrap();
let edits_only = |path: std::path::PathBuf| ide_plugin::CodeAction {
title: "Add import".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: vec![(path, vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
operations: Vec::new(),
open: None,
data: None,
file_action: false,
};
// In the project root: allowed.
assert_eq!(plugin_code_action("acme.x", &root, edits_only(root.join("a.txt"))).disabled_reason, None);
// Outside the root: disabled (previously an edits-only action was not confined).
let outside = plugin_code_action("acme.x", &root, edits_only(tmp.path().join("evil.txt")));
assert!(
outside.disabled_reason.as_deref().is_some_and(|r| r.contains("outside the project root")),
"{outside:?}"
);
assert!(outside.edit.unwrap().files.is_empty());
// Into .git: disabled.
let git = plugin_code_action("acme.x", &root, edits_only(root.join(".git/config")));
assert!(git.disabled_reason.as_deref().is_some_and(|r| r.contains("git's own files")), "{git:?}");
}
/// Advisory #4b: a quick fix's file operations and its `open` are held to the same rule as its
/// edits, under every name that reaches git's own files.
#[test]
fn a_plugin_action_cannot_change_or_open_git_files() {
use ide_plugin::FileOperation::{Create, Delete, Rename};
let tmp = tempfile::tempdir().unwrap();
for dir in ["proj/.git/hooks", "worktree"] {
std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
}
let root = dunce::canonicalize(tmp.path().join("proj")).unwrap();
let worktree = dunce::canonicalize(tmp.path().join("worktree")).unwrap();
std::fs::write(
root.join(".git/config"),
"[core]
",
)
.unwrap();
std::fs::write(root.join("a.txt"), "a").unwrap();
std::fs::write(
worktree.join(".git"),
"gitdir: elsewhere
",
)
.unwrap();
std::fs::write(tmp.path().join("profile.txt"), "outside").unwrap();
let action =
|edits: Vec<std::path::PathBuf>, operations: Vec<ide_plugin::FileOperation>| ide_plugin::CodeAction {
title: "Add import".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: edits
.into_iter()
.map(|path| (path, vec![TextEdit { range: Range::default(), new_text: "x".into() }]))
.collect(),
operations,
open: None,
data: None,
file_action: false,
};
let refused = |root: &Path, action: ide_plugin::CodeAction, why: &str| {
let offered = plugin_code_action("acme.x", root, action);
assert!(offered.disabled_reason.as_deref().is_some_and(|r| r.contains(why)), "{offered:?}");
assert!(offered.edit.unwrap().files.is_empty());
};
for git in [root.join(".git/config"), root.join(".GIT/config"), ".git/hooks/pre-commit".into()] {
refused(&root, action(vec![git], Vec::new()), "git's own files");
}
for operation in [
Create {
path: root.join(".git/hooks/pre-commit"),
text: "#!/bin/sh
"
.into(),
overwrite: false,
},
Rename { from: root.join("a.txt"), to: root.join(".git/config") },
Rename { from: root.join(".git/config"), to: root.join("b.txt") },
Delete { path: root.join(".git/config") },
] {
refused(&root, action(Vec::new(), vec![operation]), "git's own files");
}
// Opening a file hands its text to the plugin's providers.
let opens =
|path: std::path::PathBuf| ide_plugin::CodeAction { open: Some(path), ..action(Vec::new(), Vec::new()) };
refused(&root, opens(root.join(".git/config")), "git's own files");
// An edit lands in the file a link leads to, and `open` shows that file: both are judged by it.
#[cfg(unix)]
{
std::os::unix::fs::symlink(tmp.path().join("profile.txt"), root.join("link.txt")).unwrap();
std::os::unix::fs::symlink(root.join(".git/config"), root.join("alias")).unwrap();
refused(&root, action(vec![root.join("link.txt")], Vec::new()), "outside the project root");
refused(&root, action(vec![root.join("alias")], Vec::new()), "git's own files");
refused(&root, opens(root.join("link.txt")), "outside the project root");
refused(&root, opens(root.join("alias")), "git's own files");
}
// A linked worktree's `.git` file, under the short name Windows opens it by as well.
if cfg!(windows) && worktree.join("GIT~1").exists() {
for operation in [
Delete { path: worktree.join("GIT~1") },
Create {
path: worktree.join("GIT~1"),
text: "gitdir: other
"
.into(),
overwrite: true,
},
] {
refused(&worktree, action(Vec::new(), vec![operation]), "git's own files");
}
refused(&worktree, action(vec![worktree.join("GIT~1")], Vec::new()), "git's own files");
refused(&worktree, opens(worktree.join("GIT~1")), "git's own files");
} else {
eprintln!("short name not tried: this volume keeps none");
}
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+44 -2
View File
@@ -1263,6 +1263,8 @@ pub(super) fn commit_draft(
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
// Both callers have checked that the window's project is trusted.
r.set_execution_allowed(true);
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
@@ -2093,7 +2095,7 @@ impl HostApi for PluginApi {
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) };
let Some(repo) = self.repo(root)? else { return Ok(None) };
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
@@ -2119,7 +2121,7 @@ impl HostApi for PluginApi {
if !workspace.is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
}
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else {
let Some(repo) = self.repo(root)? else {
return Err(HostError::NotFound("the project is not in a git repository".into()));
};
match repo.fetch(cancel, &|_| {}) {
@@ -3936,6 +3938,16 @@ fn valid_range(document: &ide_text::Document, range: ide_core::Range) -> bool {
}
impl PluginApi {
/// The repository of `root`, which may execute only when `root` is a trusted project.
/// Status needs it there: a restricted repository is read without its config, so it knows
/// no upstream. The other reads use [`Self::read_repo`] and never execute.
fn repo(&self, root: &Path) -> Result<Option<ide_vcs::Repo>, HostError> {
let repo = ide_vcs::Repo::discover(root).map_err(failed)?;
if let Some(repo) = &repo {
repo.set_execution_allowed(self.shared.workspace().is_some_and(|w| w.trusted(root)));
}
Ok(repo)
}
fn read_repo(&self, root: &Path) -> Result<Option<ide_vcs::Repo>, HostError> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
@@ -4200,6 +4212,36 @@ mod workbench_tests {
std::fs::write(&path, "x".repeat(5 << 20)).unwrap();
assert!(api.vcs_diff(&root, None, None, &cancel).is_err());
}
/// Security audit lead 2: status has no trust check to stop it, so in Restricted Mode the
/// repository it opens must not run what its config names.
#[test]
fn vcs_status_runs_no_repository_program_in_restricted_mode() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
git(&root, &["init", "-q"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "test@example.com"]);
git(&root, &["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("a.txt"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-qm", "base"]);
let marker = dir.path().join("FILTER-RAN");
let clean = format!("echo x > '{}'; cat", marker.to_str().unwrap().replace('\\', "/"));
git(&root, &["config", "filter.marker.clean", &clean]);
std::fs::write(root.join(".gitattributes"), "*.txt filter=marker\n").unwrap();
// Same length as the committed text: only reading the file tells them apart.
std::fs::write(root.join("a.txt"), "BASE\n").unwrap();
let restricted = api(&root, false, None);
let status = restricted.vcs_status(&root);
assert!(!marker.exists(), "status ran the repository's clean filter in Restricted Mode");
assert_eq!(status.unwrap().unwrap().changed_files, 2);
// Control: the same call runs the fixture's filter in a trusted project.
api(&root, true, None).vcs_status(&root).unwrap();
assert!(marker.exists(), "control: a trusted status did not run the clean filter, fixture is not live");
}
#[cfg(unix)]
#[test]
fn vcs_reads_never_run_repository_configured_helpers() {
+115 -16
View File
@@ -14,6 +14,7 @@ use ide_extension::{MANIFEST_FILE, Manifest};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// The official marketplace: the index the release workflow publishes with
/// the app, next to the signed packages of the official plugins.
@@ -93,22 +94,29 @@ fn safe_suggestions(paths: Vec<String>) -> Vec<String> {
paths.into_iter().filter(ok).take(MAX_SUGGESTIONS).collect()
}
/// Whether `url` is read over the network: `http(s)://…`, not `file://…` or a local path.
fn remote(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
/// Whether an index read from `base` may send the IDE to `href` (resolved). One read over
/// the network names nothing on this machine: the IDE would read the file for it, or sign
/// in to the share it names.
pub fn reachable_from(base: &str, href: &str) -> bool {
!remote(base) || remote(href)
}
/// The slowest transfer a read over the network waits for, in bytes per second: with
/// `READ_GRACE`, the largest index is given up on after about five minutes, the largest
/// package after a little over an hour.
const SLOWEST_READ: usize = 64 << 10;
/// What a read over the network may take before its first byte.
const READ_GRACE: Duration = Duration::from_secs(30);
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
if url.starts_with("https://") || url.starts_with("http://") {
let client = ide_http::client::shared().map_err(|e| e.message)?;
let url = url.to_owned();
return super::api::net_runtime().block_on(async move {
let response = client.get(&url).send().await.map_err(|e| format!("{url}: {e}"))?;
if !response.status().is_success() {
return Err(format!("{url}: HTTP {}", response.status()));
}
let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?;
if bytes.len() > max {
return Err(format!("{url}: larger than {} MiB", max >> 20));
}
Ok(bytes.to_vec())
});
if remote(url) {
return read_remote(url, max, READ_GRACE + Duration::from_secs((max / SLOWEST_READ) as u64));
}
let path = url.strip_prefix("file://").unwrap_or(url);
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
@@ -118,6 +126,35 @@ pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
Ok(bytes)
}
/// Reads `url` over http(s): no more than `max` bytes, whatever the server announces or
/// sends, and within `deadline` from the request to the last byte. The marketplace is read
/// with nobody asking, so its server decides neither how much is kept nor for how long.
fn read_remote(url: &str, max: usize, deadline: Duration) -> Result<Vec<u8>, String> {
let client = ide_http::client::shared().map_err(|e| e.message)?;
super::api::net_runtime().block_on(async move {
let failed = |e: reqwest::Error| match e.is_timeout() {
true => format!("{url}: no answer in time"),
false => format!("{url}: {e}"),
};
let too_large = || format!("{url}: larger than {} MiB", max >> 20);
let mut response = client.get(url).timeout(deadline).send().await.map_err(failed)?;
if !response.status().is_success() {
return Err(format!("{url}: HTTP {}", response.status()));
}
if response.content_length().is_some_and(|announced| announced > max as u64) {
return Err(too_large());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(failed)? {
if bytes.len() + chunk.len() > max {
return Err(too_large());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
})
}
/// The index at `url`, following `moved`, with downloads made absolute against
/// where it was read from.
fn read_index(url: &str) -> IdeResult<Index> {
@@ -132,12 +169,25 @@ fn read_index(url: &str) -> IdeResult<Index> {
if url.starts_with("https://") && !next.starts_with("https://") {
return Err(IdeError::invalid(format!("the marketplace moved to {next}, which isn't https")));
}
if !reachable_from(&url, &next) {
return Err(IdeError::invalid("the marketplace moved to an address that is not on the network"));
}
url = next;
}
None => {
for e in &mut index.plugins {
e.download = resolve(&url, &e.download);
}
// Judged here, against where this index was read: the address in use may be
// an index on this machine that moved to it.
let listed = index.plugins.len();
index.plugins.retain(|e| reachable_from(&url, &e.download));
if index.plugins.len() < listed {
tracing::warn!(
"plugin marketplace: {} downloads are not on the network; they are not offered",
listed - index.plugins.len()
);
}
return Ok(index);
}
}
@@ -295,13 +345,23 @@ impl PluginHost {
entry.name, entry.version, entry.engine
)));
}
let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?;
let download = resolve(&url, &entry.download);
// An index read by this version holds only downloads it may reach. The one kept by an
// earlier version does not say where it was read: it is judged against the marketplace in use.
if !reachable_from(&url, &download) {
return Err(IdeError::invalid(format!(
"the marketplace's download of {} {} is not on the network",
entry.id, entry.version
)));
}
let bytes = read_url(&download, MAX_PACKAGE).map_err(IdeError::unavailable)?;
let downloads = self.plugins_dir.join(DOWNLOADS);
std::fs::create_dir_all(&downloads)?;
let archive = downloads.join(format!("{}-{}.{}", entry.id, entry.version, package::PACKAGE_EXTENSION));
std::fs::write(&archive, bytes)?;
let dir = self.unpack_package(&archive)?;
let dir = self.unpack_package(&archive);
let _ = std::fs::remove_file(&archive);
let dir = dir?;
let manifest = Manifest::load(&dir).map_err(IdeError::invalid)?;
let files = self.package_files_in(&dir)?;
let digest = package::package_digest(&dir, &files)?;
@@ -508,4 +568,43 @@ mod tests {
assert_eq!(safe_suggestions(paths.iter().map(|p| p.to_string()).collect()), ["pom.xml", "config/routes.rb"]);
assert_eq!(safe_suggestions(vec!["a".into(); 20]).len(), MAX_SUGGESTIONS);
}
#[test]
fn an_index_read_over_the_network_reaches_only_the_network() {
let base = "https://x.dev/market/index.json";
let reaches = |href: &str| reachable_from(base, &resolve(base, href));
for href in ["p/a.oiplugin", "../a.oiplugin", "https://cdn.dev/a"] {
assert!(reaches(href), "{href}");
}
for href in ["/etc/passwd", "//attacker/share/a", "file:///etc/passwd", "file://attacker/share/a"] {
assert!(!reaches(href), "{href}");
}
#[cfg(windows)]
for href in [r"C:\Users\me\.ssh\id_ed25519", r"\\attacker\share\a", r"\\?\UNC\attacker\share\a"] {
assert!(!reaches(href), "{href}");
}
// A local index names what whoever put it there chose.
assert!(reachable_from("/srv/market/index.json", "/srv/market/a.oiplugin"));
}
#[test]
fn a_server_that_stops_answering_does_not_hold_the_read() {
use std::io::{BufRead, BufReader, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://127.0.0.1:{}/index.json", listener.local_addr().unwrap().port());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
line.clear();
}
// The headers, then nothing for longer than the read waits.
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n");
std::thread::sleep(Duration::from_secs(5));
}
});
let err = read_remote(&url, MAX_INDEX, Duration::from_millis(300)).unwrap_err();
assert!(err.contains("no answer in time"), "{err}");
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ pub use icons::{
pub(crate) use inlay_hints::merge as merge_inlay_hints;
pub use market::{
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
read_url as read_market_url, resolve as resolve_market_href,
reachable_from as market_href_reachable_from, read_url as read_market_url, resolve as resolve_market_href,
};
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
pub use providers::{ProviderKind, set_editor_providers};
+218
View File
@@ -209,6 +209,49 @@ fn write_index(
path.to_string_lossy().into_owned()
}
/// Security audit lead 5: an index entry's id and version name the download file,
/// so an entry whose id or version would place it outside the downloads folder
/// is not offered, and nothing is written for it.
#[test]
fn market_entry_ids_cannot_place_the_download_outside_the_downloads_folder() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, hello) = signed_hello(&market, "1.0.0", &key, &f.host);
// Never read: an entry that is not offered is not downloaded.
std::fs::write(market.join("junk.bin"), b"attacker bytes").unwrap();
// The id leaves the folder on every platform (`.downloads` exists by then); the version
// only where `..` is resolved before the path is opened (Windows).
for (id, version) in [("../../escape", "1.0.0"), ("acme.hello", "1.0.0/../../../escape")] {
let entry = IndexEntry { id: id.into(), version: version.into(), download: "junk.bin".into(), ..hello.clone() };
let url = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(url));
f.host.refresh_market().unwrap();
assert!(f.host.market_plugins().is_empty(), "{id} {version} is offered");
let err = f.host.download_market_plugin(id).unwrap_err();
assert!(err.message.contains("is not in the marketplace"), "{id} {version}: {}", err.message);
}
let downloads = f.plugins_dir.join(".downloads");
let mut escaped = Vec::new();
let mut pending = vec![f._tmp.path().to_path_buf()];
while let Some(dir) = pending.pop() {
for e in std::fs::read_dir(&dir).unwrap().flatten() {
let path = e.path();
if path.is_dir() {
pending.push(path);
} else if path.extension().is_some_and(|x| x == "oiplugin")
&& !path.starts_with(&downloads)
&& !path.starts_with(&market)
{
escaped.push(path);
}
}
}
assert!(escaped.is_empty(), "the download was written outside {}: {escaped:?}", downloads.display());
}
/// First run: nothing cached yet, so listing the marketplace reads its index
/// instead of showing an empty one; later listings use the index read.
#[test]
@@ -366,6 +409,181 @@ fn a_moved_marketplace_is_read_where_it_moved_to() {
assert!(err.message.contains("moved more than"), "{}", err.message);
}
/// A loopback HTTP server whose answer to every request `respond` writes; the URL of its index.
fn serve_with(respond: impl Fn(&mut std::net::TcpStream) + Send + 'static) -> String {
use std::io::{BufRead, BufReader};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://127.0.0.1:{}/index.json", listener.local_addr().unwrap().port());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
line.clear();
}
respond(&mut stream);
}
});
url
}
/// A loopback HTTP server answering every request with `body`; the URL of its index.
fn serve(body: String) -> String {
use std::io::Write;
serve_with(move |stream| {
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
})
}
/// An index is read up to the size an index may have, whatever its server says or sends:
/// the marketplace is read again every day, with nobody asking.
#[test]
fn an_index_larger_than_an_index_may_be_is_not_read_to_its_end() {
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
let f = fixture();
// Announced: refused on its headers, before any of it is read.
f.host.set_market_url(Some(serve_with(|stream| {
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1073741824\r\nConnection: close\r\n\r\n{");
})));
let err = f.host.refresh_market().unwrap_err();
assert!(err.message.contains("larger than 16 MiB"), "{}", err.message);
// Not announced: the read stops at the limit, and the server is left with the rest.
const BODY: usize = 96 << 20;
let sent = Arc::new(AtomicUsize::new(0));
f.host.set_market_url(Some(serve_with({
let sent = sent.clone();
move |stream| {
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n");
let chunk = [b' '; 64 << 10];
let mut written = 0;
while written < BODY && stream.write_all(&chunk).is_ok() {
written += chunk.len();
}
sent.store(written.max(1), Ordering::SeqCst);
}
})));
let err = f.host.refresh_market().unwrap_err();
assert!(err.message.contains("larger than 16 MiB"), "{}", err.message);
let waiting = std::time::Instant::now();
while sent.load(Ordering::SeqCst) == 0 && waiting.elapsed() < std::time::Duration::from_secs(30) {
std::thread::sleep(std::time::Duration::from_millis(20));
}
let sent = sent.load(Ordering::SeqCst);
assert!(sent > 0 && sent < BODY, "the server sent {sent} of {BODY} bytes");
}
/// An index read over the network names downloads on the network: not a file of this
/// machine, nor a share the machine would sign in to.
#[test]
fn an_index_read_over_the_network_cannot_name_a_local_download() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (package, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let path = package.to_string_lossy().into_owned();
// A package that installs, so only where the index was read from can refuse it.
for download in [path.clone(), format!("file://{path}")] {
let entry = IndexEntry { download: download.clone(), ..entry.clone() };
let local = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(local.clone()));
f.host.refresh_market().unwrap();
let dir = f.host.download_market_plugin("acme.hello").unwrap();
assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0", "{download}");
f.host.set_market_url(Some(serve(std::fs::read_to_string(&local).unwrap())));
assert!(f.host.refresh_market().unwrap().is_empty(), "{download} is offered");
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("is not in the marketplace"), "{download}: {}", err.message);
}
}
/// Nor when an index on this machine is what sent the IDE to the one on the network.
#[test]
fn an_index_reached_through_a_local_one_cannot_name_a_local_download() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (package, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let entry = IndexEntry { download: package.to_string_lossy().into_owned(), ..entry };
let served = serve(std::fs::read_to_string(write_index(&market, &key, vec![entry], vec![], false)).unwrap());
let old = f.work.join("old");
std::fs::create_dir_all(&old).unwrap();
let moved = serde_json::json!({ "format": package::INDEX_FORMAT, "moved": served }).to_string();
std::fs::write(old.join("index.json"), moved).unwrap();
f.host.set_market_url(Some(old.join("index.json").to_string_lossy().into_owned()));
assert!(f.host.refresh_market().unwrap().is_empty(), "the local download is offered");
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("is not in the marketplace"), "{}", err.message);
}
/// The index kept from the last read does not say where it was read from: what an earlier
/// version kept is judged against the marketplace in use.
#[test]
fn a_kept_index_cannot_name_a_local_download_for_a_marketplace_on_the_network() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (package, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let entry = IndexEntry { download: package.to_string_lossy().into_owned(), ..entry };
let kept = write_index(&market, &key, vec![entry], vec![], false);
std::fs::copy(kept, f.plugins_dir.join("market-index.json")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// Never read: the check comes before any request.
again.set_market_url(Some("https://market.invalid/index.json".into()));
assert_eq!(again.market_plugins()[0].entry.id, "acme.hello");
let err = again.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("is not on the network"), "{}", err.message);
}
/// Nor may it send the IDE to an index on this machine.
#[test]
fn an_index_read_over_the_network_cannot_move_to_a_local_one() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let local = write_index(&market, &key, vec![entry], vec![], false);
let moved = serde_json::json!({ "format": package::INDEX_FORMAT, "moved": local }).to_string();
f.host.set_market_url(Some(serve(moved)));
let err = f.host.refresh_market().unwrap_err();
assert!(err.message.contains("is not on the network"), "{}", err.message);
assert!(f.host.market_plugins().is_empty(), "the local index was read");
}
/// A download that turns out not to be a package is not kept.
#[test]
fn a_download_that_is_not_a_package_leaves_nothing_behind() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
std::fs::write(market.join("junk.bin"), b"not a package").unwrap();
let entry = IndexEntry { download: "junk.bin".into(), ..entry };
f.host.set_market_url(Some(write_index(&market, &key, vec![entry], vec![], false)));
f.host.refresh_market().unwrap();
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("not a plugin package"), "{}", err.message);
let left: Vec<_> = std::fs::read_dir(f.plugins_dir.join(".downloads"))
.map(|dir| dir.flatten().map(|e| e.file_name()).collect())
.unwrap_or_default();
assert!(left.is_empty(), "{left:?}");
}
/// `dir/index.json` listing `publishers` and `plugins`; its path.
fn index_of(dir: &Path, publishers: Vec<Publisher>, plugins: Vec<IndexEntry>) -> String {
let index = Index { format: package::INDEX_FORMAT, publishers, plugins, ..Index::default() };
+70
View File
@@ -0,0 +1,70 @@
//! An image path may come from Markdown someone else wrote, and on Windows opening a network
//! share signs in to its server with the user's credentials: the host reads an image from a
//! share only when a project is open on it. The share here is this machine's own `C$` over
//! loopback.
#![cfg(windows)]
use ide_core::NullSink;
use ide_host::ops::bytes::fs_read_image;
use ide_host::{Host, HostConfig};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// `path` as seen through the admin share of `server`, a name of this machine.
fn over_smb(server: &str, path: &Path) -> PathBuf {
let s = path.to_str().unwrap();
PathBuf::from(format!(r"\\{server}\{}${}", &s[..1], &s[2..]))
}
#[test]
fn images_on_a_share_are_read_only_where_a_project_is_open() {
let tmp = tempfile::tempdir().unwrap();
let local = dunce::canonicalize(tmp.path()).unwrap();
let root = local.join("project");
std::fs::create_dir_all(root.join("docs")).unwrap();
for image in [root.join("docs/a.png"), local.join("outside.png")] {
std::fs::write(image, b"\x89PNG dummy").unwrap();
}
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
});
let read = |path: PathBuf| runtime.block_on(fs_read_image(&host, "main", path));
let in_project = over_smb("127.0.0.1", &root.join("docs/a.png"));
let beside_project = over_smb("127.0.0.1", &local.join("outside.png"));
let other_server = over_smb("localhost", &root.join("docs/a.png"));
for path in [&in_project, &beside_project, &other_server] {
assert!(std::fs::read(path).is_ok(), "control: {} is not reachable over SMB", path.display());
}
let wanted = read(root.join("docs/a.png")).unwrap();
// No project is open on any share.
for path in [&in_project, &beside_project, &other_server] {
assert!(read(path.clone()).is_err(), "read {} with no project on its share", path.display());
}
let project_root = over_smb("127.0.0.1", &root);
host.trust.decide(&project_root, true, false).unwrap();
host.open_project("main", &project_root).unwrap();
assert_eq!(read(in_project.clone()).unwrap(), wanted);
// The whole share is one sign-in: a file outside the project folder is on it too.
assert_eq!(read(beside_project.clone()).unwrap(), wanted);
assert_eq!(read(PathBuf::from(in_project.to_str().unwrap().replace('\\', "/"))).unwrap(), wanted);
// The path as the webview holds it: the project root it was given, joined with `/`.
let root_in_webview = ide_core::ipc_path::to_ipc(&host.project("main").unwrap().root);
assert_eq!(read(PathBuf::from(format!("{root_in_webview}/docs/a.png"))).unwrap(), wanted);
assert!(read(other_server.clone()).is_err(), "read {} from a server no project is on", other_server.display());
host.close_project("main");
assert!(read(in_project.clone()).is_err(), "read {} after its project closed", in_project.display());
}
+70
View File
@@ -6574,3 +6574,73 @@ fn document_indexer_covers_workspace_members() {
.unwrap();
assert!(f.plugins.navigation_at(&f.root, &outside, 1, ide_core::Pos::new(0, 4), Some("main"), |_| None).is_none());
}
/// Advisory #4: `project.write` (Medium) must not reach VCS metadata such as `.git/hooks` or
/// `.git/config`, which git runs as code; the `.git` is inside the project root, so root
/// confinement alone does not stop it.
#[test]
fn project_write_cannot_reach_git_metadata() {
let f = fixture(Limits::default());
let init = std::process::Command::new("git").current_dir(&f.project).args(["init", "-q"]).output().unwrap();
assert!(init.status.success(), "git init");
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
// The grant works for an ordinary path, so each refusal below is the `.git` rule and nothing else.
assert_eq!(f.run("misbehave", "projwrite", Some("src/fresh.txt")).unwrap(), None);
for rel in [".git/hooks/pre-commit", ".git/config", ".GIT/config", "src/../.git/config"] {
let before = std::fs::read(f.project.join(rel)).ok();
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
assert_eq!(std::fs::read(f.project.join(rel)).ok(), before, "{rel}");
}
}
/// In a linked worktree or a submodule `.git` is a file naming the real git folder. Replacing it,
/// under any name that opens it, would point git at a folder of the plugin's choosing or leave the
/// checkout without a repository.
#[test]
fn project_write_cannot_replace_the_git_file_of_a_linked_worktree() {
let f = fixture(Limits::default());
let pointer = f.project.join(".git");
let gitdir = "gitdir: /elsewhere/.git/worktrees/project\n";
std::fs::write(&pointer, gitdir).unwrap();
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
assert_eq!(f.run("misbehave", "projwrite", Some("src/fresh.txt")).unwrap(), None);
let mut spellings = vec![".git", ".GIT", ".git.", ".git ", ".git::$DATA"];
// Windows opens a file by its short name too, on a volume that keeps them.
if cfg!(windows) && f.project.join("GIT~1").exists() {
spellings.extend(["GIT~1", "git~1", "GIT~1.", "GIT~1 ", "GIT~1::$DATA"]);
} else {
eprintln!("short name not tried: this volume keeps none");
}
for rel in spellings {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&pointer).unwrap(), gitdir, "{rel}");
}
}
/// A link already in the project is written through, so it is judged by where it points.
#[cfg(unix)]
#[test]
fn project_write_judges_a_link_by_its_target() {
let f = fixture(Limits::default());
let config = f.project.join(".git").join("config");
std::fs::create_dir(f.project.join(".git")).unwrap();
std::fs::write(&config, "[core]\n").unwrap();
let secret = f._tmp.path().join("secret.txt");
std::os::unix::fs::symlink(&config, f.project.join("alias")).unwrap();
std::os::unix::fs::symlink(&secret, f.project.join("escape")).unwrap();
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
// A folder that is not there, in front of the link, does not keep it from being followed.
for rel in ["alias", "missing/../alias"] {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&config).unwrap(), "[core]\n", "{rel}");
}
for rel in ["escape", "missing/../escape"] {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("outside the project root"), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "top secret", "{rel}");
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Security audit lead 2: every `vcs_*` command Restricted Mode allows, run against a
//! repository whose config names a program for a signed commit, an index read, an external
//! diff, a clean filter and a missing object. None may run.
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops, ops::vcs};
use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::{Command, Stdio};
use std::sync::Arc;
/// Allowed in Restricted Mode and not swept: none of them starts git for the project.
const NOT_THE_PROJECT_REPOSITORY: [&str; 7] = [
"vcs_clone", // clones a URL the user gives into a new folder
"vcs_diff_hunks", // compares two texts
"vcs_detected_git", // looks for the git executable
"vcs_shelves", // the shelf and the changelists are files of the IDE
"vcs_shelf_patch",
"vcs_changelists_load",
"vcs_write_patch", // writes a patch the UI already holds
];
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn spelled(path: &Path) -> String {
path.to_str().unwrap().replace('\\', "/")
}
struct Armed {
root: PathBuf,
first: String,
head: String,
/// A commit whose tree is missing: git asks the promisor for it.
promised: String,
}
/// A repository that starts a program of its own for a signed commit (`gpg`), an index read
/// (`fsmonitor`), a file diff (`extdiff`), a touched file (`filter`) and a missing object
/// (`promisor`). Each program leaves a file named after itself in `markers`. Other ways in
/// (textconv, process filters, merge drivers) are not armed here.
fn arm(root: &Path, markers: &Path) -> Armed {
std::fs::create_dir_all(root.join("src")).unwrap();
git(root, &["init", "-q", "-b", "main"]);
for (key, value) in [("user.name", "Test"), ("user.email", "test@example.com"), ("commit.gpgsign", "false")] {
git(root, &["config", key, value]);
}
std::fs::write(root.join("src/a.txt"), "one\ntwo\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-qm", "first"]);
let first = git(root, &["rev-parse", "HEAD"]);
std::fs::write(root.join("src/a.txt"), "one\ntwo\nthree\n").unwrap();
git(root, &["commit", "-qam", "second"]);
// HEAD again, carrying a signature header: `git log` then starts the verifier.
let body = git(root, &["cat-file", "commit", "HEAD"]);
let (headers, message) = body.split_once("\n\n").unwrap();
let signed = format!(
"{headers}\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEfake\n -----END PGP SIGNATURE-----\n\n{message}\n"
);
let mut child = Command::new("git")
.current_dir(root)
.args(["hash-object", "-t", "commit", "-w", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
std::io::Write::write_all(&mut child.stdin.take().unwrap(), signed.as_bytes()).unwrap();
let head = String::from_utf8(child.wait_with_output().unwrap().stdout).unwrap().trim().to_owned();
git(root, &["update-ref", "HEAD", &head]);
// A branch whose tip has a tree of its own, which is then dropped from the object database.
git(root, &["checkout", "-q", "-b", "promised", &first]);
std::fs::write(root.join("only-here.txt"), "promised\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-qm", "promised"]);
let promised = git(root, &["rev-parse", "HEAD"]);
let tree = git(root, &["rev-parse", "HEAD^{tree}"]);
git(root, &["checkout", "-q", "main"]);
std::fs::remove_file(root.join(".git/objects").join(&tree[..2]).join(&tree[2..])).unwrap();
let marker = |name: &str| spelled(&markers.join(name));
let script = |name: &str| {
let path = root.join(".git").join(format!("{name}.sh"));
std::fs::write(&path, format!("#!/bin/sh\necho x > '{}'\nexit 1\n", marker(name))).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
spelled(&path)
};
let settings = [
("log.showSignature", "true".to_owned()),
("gpg.program", script("gpg")),
("core.fsmonitor", script("fsmonitor")),
("diff.external", script("extdiff")),
("filter.marker.clean", format!("echo x > '{}'; cat", marker("filter"))),
("extensions.partialClone", "origin".to_owned()),
("remote.origin.url", spelled(root)),
("remote.origin.promisor", "true".to_owned()),
("remote.origin.partialclonefilter", "blob:none".to_owned()),
("remote.origin.uploadpack", format!("echo x > '{}'; git-upload-pack", marker("promisor"))),
];
std::fs::write(root.join(".gitattributes"), "*.txt filter=marker\n").unwrap();
// Same length as the committed text: only reading the file tells them apart.
std::fs::write(root.join("src/a.txt"), "ONE\ntwo\nthree\n").unwrap();
for (key, value) in &settings {
git(root, &["config", key, value]);
}
Armed { root: root.to_path_buf(), first, head, promised }
}
fn ran(markers: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(markers)
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn clear(markers: &Path) {
let _ = std::fs::remove_dir_all(markers);
std::fs::create_dir_all(markers).unwrap();
}
type Call<'a> = (&'static str, &'static str, Pin<Box<dyn Future<Output = bool> + 'a>>);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_vcs_commands_run_no_program_of_the_repository() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let markers = base.join("markers");
clear(&markers);
let project = arm(&base.join("project"), &markers);
let external = arm(&base.join("external"), &markers);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&project.root, false, false).unwrap();
clear(&markers);
assert!(!host.open_project("main", &project.root).unwrap().is_trusted());
let host = &host;
let w = "main";
let file = project.root.join("src/a.txt");
let root = || Some(project.root.clone());
let log = || Some(external.root.clone());
macro_rules! call {
($command:literal, $what:literal, $call:expr) => {
($command, $what, Box::pin(async { $call.await.is_ok() }) as Pin<Box<dyn Future<Output = bool> + '_>>)
};
}
macro_rules! sync {
($command:literal, $what:literal, $call:expr) => {
($command, $what, Box::pin(async { $call.is_ok() }) as Pin<Box<dyn Future<Output = bool> + '_>>)
};
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
call!("vcs_file_at", "", vcs::vcs_file_at(host, w, file.clone(), None)),
call!("vcs_blame", "", vcs::vcs_blame(host, w, file.clone(), None, None)),
call!("vcs_block_history", "", vcs::vcs_block_history(host, w, RequestId(1), file.clone(), None, (0, 1))),
call!("vcs_log", "", vcs::vcs_log(host, w, root(), Default::default())),
call!("vcs_log_refs", "", vcs::vcs_log_refs(host, w, root())),
call!("vcs_log_users", "", vcs::vcs_log_users(host, w, root())),
call!("vcs_log_details", "", vcs::vcs_log_details(host, w, root(), project.head.clone())),
call!(
"vcs_log_details_batch",
"",
vcs::vcs_log_details_batch(
host,
w,
root(),
vec![project.head.clone(), project.first.clone()],
RequestId(2)
)
),
call!(
"vcs_revision_children",
"",
vcs::vcs_revision_children(host, w, root(), project.head.clone(), "src".into())
),
call!(
"vcs_revision_children",
"missing tree",
vcs::vcs_revision_children(host, w, root(), project.promised.clone(), String::new())
),
call!("vcs_revision_files", "", vcs::vcs_revision_files(host, w, root(), project.head.clone())),
call!("vcs_revision_files", "missing tree", vcs::vcs_revision_files(host, w, root(), project.promised.clone())),
call!("vcs_commit_files", "", vcs::vcs_commit_files(host, w, root(), project.head.clone())),
call!("vcs_commits_changes", "", vcs::vcs_commits_changes(host, w, root(), vec![project.head.clone()])),
call!("vcs_branches", "", vcs::vcs_branches(host, w, root())),
call!("vcs_stashes", "", vcs::vcs_stashes(host, w, root())),
call!("vcs_conflict_versions", "", vcs::vcs_conflict_versions(host, w, file.clone())),
call!("vcs_diff_text", "", vcs::vcs_diff_text(host, w, file.clone(), false)),
call!("vcs_config_get", "", vcs::vcs_config_get(host, w, root(), "user.name".into())),
call!("vcs_tags", "", vcs::vcs_tags(host, w, root())),
call!("vcs_remotes", "", vcs::vcs_remotes(host, w, root())),
call!("vcs_worktrees", "", vcs::vcs_worktrees(host, w, root())),
call!("vcs_diff_with_worktree", "", vcs::vcs_diff_with_worktree(host, w, root(), "HEAD".into())),
call!("vcs_compare_scope", "", vcs::vcs_compare_scope(host, w, vec![project.root.clone()])),
call!(
"vcs_compare_paths",
"",
vcs::vcs_compare_paths(host, w, vec![project.root.clone()], "HEAD".into(), None, RequestId(3))
),
call!(
"vcs_diff_revisions",
"",
vcs::vcs_diff_revisions(host, w, root(), project.first.clone(), project.head.clone())
),
call!(
"vcs_diff_numstat",
"",
vcs::vcs_diff_numstat(host, w, root(), project.first.clone(), project.head.clone(), Vec::new())
),
call!("vcs_merge_base", "", vcs::vcs_merge_base(host, w, root(), project.first.clone(), project.head.clone())),
call!("vcs_has_hook", "", vcs::vcs_has_hook(host, w, root(), "pre-push".into())),
// A worktree outside the project, opened for its history only.
call!("vcs_open_external_log", "", vcs::vcs_open_external_log(host, w, external.root.clone())),
call!("vcs_log", "external", vcs::vcs_log(host, w, log(), Default::default())),
call!("vcs_log_users", "external", vcs::vcs_log_users(host, w, log())),
call!("vcs_log_details", "external", vcs::vcs_log_details(host, w, log(), external.head.clone())),
call!(
"vcs_revision_children",
"external, missing tree",
vcs::vcs_revision_children(host, w, log(), external.promised.clone(), String::new())
),
call!("vcs_revision_files", "external", vcs::vcs_revision_files(host, w, log(), external.head.clone())),
call!("vcs_commit_files", "external", vcs::vcs_commit_files(host, w, log(), external.head.clone())),
// Last: it rewrites nothing of an existing repository, but it does touch it.
call!("vcs_init", "", vcs::vcs_init(host, w, project.root.clone())),
];
// The sweep covers what `trust.rs` allows: a newly allowed `vcs_*` command fails here until
// it is swept or listed as not touching the project's repository.
let source = include_str!("../src/trust.rs");
let start = source.find("pub fn restricted_command_allowed").unwrap();
let end = source.find("pub fn projectless_command_allowed").unwrap();
let allowed: BTreeSet<&str> = source[start..end].split('"').filter(|s| s.starts_with("vcs_")).collect();
let swept: BTreeSet<&str> = calls.iter().map(|(command, _, _)| *command).collect();
let known: BTreeSet<&str> = swept.iter().copied().chain(NOT_THE_PROJECT_REPOSITORY).collect();
assert!(allowed.len() > 30, "trust.rs was not read: {allowed:?}");
assert_eq!(allowed, known, "the sweep and Restricted Mode's allowed vcs commands differ");
for command in &known {
assert!(ide_host::trust::restricted_command_allowed(command), "{command} is not allowed in Restricted Mode");
}
let mut executed = Vec::new();
let mut failed = Vec::new();
let opening = ran(&markers);
if !opening.is_empty() {
executed.push(format!("open_project: {}", opening.join(", ")));
}
for (command, what, call) in calls {
clear(&markers);
let ok = call.await;
let name = if what.is_empty() { command.to_owned() } else { format!("{command} ({what})") };
let programs = ran(&markers);
if !programs.is_empty() {
executed.push(format!("{name}: {}", programs.join(", ")));
}
if !ok {
failed.push(name);
}
}
// Control: plain git, in the same repository, starts every one of the fixture's programs.
clear(&markers);
for args in [&["log", "-1", "--format=%H"][..], &["status", "--porcelain"], &["ls-tree", &project.promised]] {
let _ = Command::new("git").current_dir(&project.root).args(args).output().unwrap();
}
let _ = Command::new("git").current_dir(&project.root).args(["diff"]).output().unwrap();
assert_eq!(
ran(&markers),
["extdiff", "filter", "fsmonitor", "gpg", "promisor"],
"control: the fixture is not live"
);
assert!(executed.is_empty(), "Restricted Mode ran a program of the repository:\n{}", executed.join("\n"));
// Only the listings of the commit whose tree is gone have nothing to return.
assert_eq!(
failed,
[
"vcs_revision_children (missing tree)",
"vcs_revision_files (missing tree)",
"vcs_revision_children (external, missing tree)"
]
);
}
+73
View File
@@ -118,6 +118,24 @@ pub fn inside_roots(roots: &[PathBuf], path: &str) -> Result<bool, String> {
/// be created) or that names a symlink itself (to delete or rename it): the
/// directory part is resolved through symlinks, the final name is kept as is,
/// and the result must stay inside `root`.
/// Whether a path (relative to the project root) goes through git's own `.git` folder or file.
/// git runs what it finds in `.git` (hooks, `gpg.program`, filters), so a content write must
/// never reach it. Names are normalized for trailing dots and spaces and NTFS stream suffixes;
/// the caller also checks the short-name-resolved path via [`resolve_real`].
pub fn names_git_dir(rel: &Path) -> bool {
rel.components().any(|part| {
let name = part.as_os_str().to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
name.eq_ignore_ascii_case(".git")
})
}
/// A resolved write destination that reaches git's own `.git` metadata, judged by the name
/// given and by the name the file system resolves it to.
fn reaches_git_dir(root: &Path, real: &Path) -> bool {
real.strip_prefix(root).is_ok_and(names_git_dir) || resolve_real(real).strip_prefix(root).is_ok_and(names_git_dir)
}
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let outside = || format!("{path}: access outside the project root is not allowed");
if path.is_empty() {
@@ -154,6 +172,9 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if !real.starts_with(&root) {
return Err(outside());
}
if reaches_git_dir(&root, &real) {
return Err(format!("{path}: writing git's own files is not allowed"));
}
Ok(real)
}
@@ -165,6 +186,9 @@ pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, Stri
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
}
if reaches_git_dir(&root, &real) {
return Err(format!("{path}: writing git's own files is not allowed"));
}
Ok(real)
}
@@ -559,6 +583,55 @@ mod tests {
assert!(resolve_new_in_root(&root, "").is_err());
}
/// Advisory #4: a content write may not reach git's own `.git` metadata, which git runs as
/// code (hooks, `gpg.program`, filters). The `.git` folder is inside the project root, so the
/// root confinement alone does not stop it.
#[test]
fn writes_cannot_reach_git_metadata() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join(".git/config"), "[core]\n").unwrap();
// An ordinary write is fine; each refusal below is the `.git` rule.
assert!(resolve_new_in_root(&root, "src/ok.txt").is_ok());
assert!(resolve_writable_in_root(&root, "src/ok.txt").is_ok());
for rel in [
".git/config",
".git/hooks/pre-commit",
".GIT/config",
"src/../.git/config",
".git./config",
".git /config",
] {
assert!(resolve_new_in_root(&root, rel).unwrap_err().contains("git's own files"), "new: {rel}");
assert!(resolve_writable_in_root(&root, rel).unwrap_err().contains("git's own files"), "writable: {rel}");
}
assert!(names_git_dir(Path::new("a/.git/b")));
assert!(!names_git_dir(Path::new("a/git/b")));
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
}
#[test]
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
for path in [
"proj/.git/hooks/pre-commit",
"proj/.git/config",
"proj/sub/.git",
"proj/.GIT/config",
"proj/.git./config",
"proj/.git /config",
"proj/.git::$INDEX_ALLOCATION/config",
] {
assert!(names_git_dir(Path::new(path)), "{path}");
}
for path in ["proj/.gitignore", "proj/.github/workflows/ci.yml", "proj/src/git/.gitkeep", "proj/a.git/x"] {
assert!(!names_git_dir(Path::new(path)), "{path}");
}
// A project opened from inside a git folder is git's own files all the way down.
assert!(names_git_dir(Path::new("repo/.git/modules/lib/hooks/pre-commit")));
}
#[cfg(unix)]
#[test]
fn a_symlink_is_named_not_followed() {
+93 -23
View File
@@ -205,6 +205,49 @@ pub(crate) fn run_paths(root: &Path, args: &[&str], paths: &[String]) -> IdeResu
run_paths_env(root, args, paths, None, &[])
}
const RESTRICTED_CONFIG: [&str; 8] = [
"core.fsmonitor=false",
"core.hooksPath=",
// `git diff` writes the index back after refreshing it, and that write runs the hooks a
// config names for `post-index-change`, which `core.hooksPath` does not reach.
"diff.autoRefreshIndex=false",
"hook.post-index-change.enabled=false",
"diff.external=",
"core.attributesFile=",
"submodule.recurse=false",
"log.showSignature=false",
];
/// The keys that name a signature verifier; `gpg.openpgp.program` is an alias of `gpg.program`.
const VERIFIER_KEYS: [&str; 4] = ["gpg.program", "gpg.openpgp.program", "gpg.ssh.program", "gpg.x509.program"];
/// A program path git opens directly and cannot find, as nothing exists below a regular file.
/// An empty name is not enough: Git for Windows looks it up on `PATH` and runs a file named `.exe`.
fn no_program() -> &'static std::ffi::OsStr {
static PATH: OnceLock<std::ffi::OsString> = OnceLock::new();
PATH.get_or_init(|| match std::env::current_exe() {
Ok(exe) if exe.is_absolute() => exe.join("no-program").into_os_string(),
_ => Default::default(),
})
}
/// Keeps `cmd`, a git that reads a repository, from running what that repository's config names
/// for a read: the fsmonitor hook, hooks, the external diff, a signature verifier and a lazy
/// fetch. Call it before the subcommand is added. Clean and process filters have a key per
/// driver ([`filter_overrides_from`] empties those); signing, transport and credential helpers
/// are not covered.
pub fn restrict(cmd: &mut Command) {
for setting in RESTRICTED_CONFIG {
cmd.arg("-c").arg(setting);
}
for key in VERIFIER_KEYS {
let mut setting = std::ffi::OsString::from(format!("{key}="));
setting.push(no_program());
cmd.arg("-c").arg(setting);
}
cmd.env_remove("GIT_EXTERNAL_DIFF").env("GIT_NO_LAZY_FETCH", "1");
}
/// Builds a `git` command with the environment every invocation needs.
pub(crate) fn command(root: &Path) -> IdeResult<Command> {
let restricted = !crate::execution_allowed();
@@ -215,16 +258,7 @@ pub(crate) fn command(root: &Path) -> IdeResult<Command> {
};
let mut cmd = Command::new(binary);
if restricted {
for setting in [
"core.fsmonitor=false",
"core.hooksPath=",
"diff.external=",
"core.attributesFile=",
"submodule.recurse=false",
] {
cmd.arg("-c").arg(setting);
}
cmd.env_remove("GIT_EXTERNAL_DIFF").env("GIT_NO_LAZY_FETCH", "1");
restrict(&mut cmd);
}
cmd.current_dir(root)
.arg("-c")
@@ -385,6 +419,13 @@ fn run_cancellable_raw_stdin(
run_capture(root, args, stdin, cancel, usize::MAX)
}
/// Options for a `git diff` that reads the working tree in Restricted Mode: git asks a submodule
/// with changes for its own status, and for its own diff when `diff.submodule` says so, and both
/// run under the submodule's config.
pub(crate) fn submodule_guards() -> &'static [&'static str] {
if crate::execution_allowed() { &[] } else { &["--ignore-submodules=dirty", "--submodule=short"] }
}
/// Neutralizes repository clean/process filters for a read of working-tree bytes.
/// Git still applies them with --no-ext-diff and --no-textconv.
pub(crate) fn filter_overrides(root: &Path, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
@@ -392,15 +433,27 @@ pub(crate) fn filter_overrides(root: &Path, cancel: &CancellationToken) -> IdeRe
return Ok(Vec::new());
}
let config = run_bounded(root, &["config", "--null", "--list"], cancel, 4 << 20)?;
filter_overrides_from(&config.stdout)
}
/// `-c` arguments that empty every clean and process filter in `config`, the output of
/// `git config --null --list`. Fails when one of them cannot be emptied this way: git splits
/// `-c name=value` at the first `=`, and a name that is not UTF-8 does not reach git as it is
/// on every platform.
pub fn filter_overrides_from(config: &[u8]) -> IdeResult<Vec<String>> {
let mut overrides = Vec::new();
for record in config.stdout_str().split('\0') {
let key = record.split('\n').next().unwrap_or_default();
if key.starts_with("filter.")
&& (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".required"))
{
overrides.push("-c".into());
overrides.push(format!("{key}={}", if key.ends_with(".required") { "false" } else { "" }));
for record in config.split(|b| *b == 0) {
let key = record.split(|b| *b == b'\n').next().unwrap_or_default();
let required = key.ends_with(b".required");
if !key.starts_with(b"filter.") || !(key.ends_with(b".clean") || key.ends_with(b".process") || required) {
continue;
}
let Some(key) = std::str::from_utf8(key).ok().filter(|key| !key.contains('=')) else {
let key = String::from_utf8_lossy(key);
return Err(IdeError::invalid(format!("git cannot be told to skip the filter setting `{key}`")));
};
overrides.push("-c".into());
overrides.push(format!("{key}={}", if required { "false" } else { "" }));
}
Ok(overrides)
}
@@ -760,6 +813,25 @@ mod command_session_tests {
}
}
#[cfg(test)]
mod restrict_tests {
use super::*;
#[test]
fn restrict_names_a_verifier_git_opens_directly_and_cannot_find() {
let mut cmd = Command::new("git");
restrict(&mut cmd);
let args: Vec<_> = cmd.get_args().map(|arg| arg.to_string_lossy().into_owned()).collect();
let programs: Vec<_> = args.iter().filter_map(|arg| arg.split_once(".program=")).collect();
assert_eq!(programs.len(), 4, "{args:?}");
for (key, program) in programs {
let program = Path::new(program);
assert!(program.is_absolute(), "{key}.program is {program:?}: git would look that name up on PATH");
assert!(!program.exists(), "{key}.program is {program:?}, which exists");
}
}
}
#[cfg(test)]
mod batching_tests {
use super::{WINDOWS_LIMIT, base_command_line_len, batch_paths, command_line_limit};
@@ -836,12 +908,10 @@ mod index_lock_tests {
run(dir.path(), &["init", "-q", "-b", "main"], None).unwrap();
std::fs::write(dir.path().join("file"), "main\n").unwrap();
run(dir.path(), &["add", "file"], None).unwrap();
run(
dir.path(),
&["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"],
None,
)
.unwrap();
// git started outside an execution scope is restricted and has no signing program, so the
// user's `commit.gpgsign` must not reach this commit.
let identity = ["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"];
run(dir.path(), &[&identity[..], &["commit", "-qm", "initial"]].concat(), None).unwrap();
run(dir.path(), &["branch", "other"], None).unwrap();
dir
}
+439 -15
View File
@@ -151,18 +151,12 @@ impl Repos {
}
let resolved = resolved.stdout_str().trim().to_owned();
// Disable textconv/external diff; these are also safe in untrusted projects.
let names =
run(&["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", &resolved, "--"])?;
let stats = run(&[
"diff",
"--numstat",
"-z",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
&resolved,
"--",
])?;
let diff = |options: &[&str]| {
let fixed = &["diff", "--no-ext-diff", "--no-textconv"][..];
run(&[fixed, cli::submodule_guards(), options, &[resolved.as_str(), "--"]].concat())
};
let names = diff(&["--name-status", "-z", "-M"])?;
let stats = diff(&["--numstat", "-z", "--no-renames"])?;
let stats: std::collections::HashMap<_, _> = ops::parse_numstat(&stats.stdout_str())
.into_iter()
.map(|(path, added, removed)| (path, (added, removed)))
@@ -207,8 +201,7 @@ impl Repos {
});
}
if files.iter().any(|f| f.kind == ChangeKind::Renamed) {
let renamed =
run(&["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", &resolved, "--"])?;
let renamed = diff(&["--numstat", "-z", "-M"])?;
let indexes: std::collections::HashMap<_, _> = files
.iter()
.enumerate()
@@ -289,7 +282,13 @@ mod tests {
use std::process::Command;
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
let output = Command::new("git")
.current_dir(root)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", if cfg!(windows) { "NUL" } else { "/dev/null" })
.output()
.unwrap();
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
@@ -506,4 +505,429 @@ mod tests {
git(&root, &["ls-files"]);
assert!(marker.exists(), "control invocation did not run fsmonitor");
}
/// git splits `-c name=value` at the first `=` and takes no name that is not UTF-8 on every
/// platform, so such a filter cannot be emptied: Restricted Mode compares nothing instead.
#[test]
fn restricted_comparison_refuses_a_filter_git_cannot_be_told_to_skip() {
for name in [&b"x=y"[..], &[0xE9]] {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path()).join("repo");
let marker = root.with_file_name("marker");
init(&root, "main");
std::fs::write(root.join(".gitattributes"), [b"*.txt filter=", name, b"\n"].concat()).unwrap();
let command = format!("echo x > '{}'; cat", marker.to_str().unwrap().replace('\\', "/"));
let section = [b"[filter \"", name, format!("\"]\n\tclean = \"{command}\"\n").as_bytes()].concat();
let config = root.join(".git/config");
std::fs::write(&config, [std::fs::read(&config).unwrap(), section].concat()).unwrap();
// The size it has in the index, so git has to read the file to tell whether it changed.
write(&root, "src/move.txt", "more\n");
let repos = Repos::discover(&root, &|_| false).unwrap();
for repo in repos.all() {
repo.set_execution_allowed(false);
}
let result = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new());
let ran = marker.exists();
let _ = std::fs::remove_file(&marker);
write(&root, "src/move.txt", "less\n");
let _ = Command::new("git").current_dir(&root).args(["status", "--porcelain"]).output();
assert!(marker.exists(), "control: plain `git status` did not run the filter, fixture is not live");
assert!(!ran, "Restricted Mode ran the repository's filter {name:?}");
assert!(result.is_ok_and(|r| r.repositories[0].error.is_some()), "{name:?}");
}
}
/// A repository with a second one in `sub`, which has `attributes`, committed as a submodule;
/// returns the two roots and the path of the marker a program of the fixture leaves behind.
fn with_submodule(temp: &Path, attributes: &str) -> (PathBuf, PathBuf, PathBuf) {
let root = normalized(temp).join("repo");
let sub = root.join("sub");
init(&root, "main");
init(&sub, "main");
write(&sub, ".gitattributes", attributes);
git(&sub, &["add", "."]);
git(&sub, &["commit", "-qm", "attributes"]);
git(&root, &["add", "sub"]);
git(&root, &["commit", "-qm", "submodule"]);
(root.clone(), sub, root.with_file_name("marker"))
}
/// git asks a submodule with changes for its own status, which runs under the submodule's config.
#[test]
fn restricted_reads_do_not_run_filters_of_a_submodule() {
let temp = tempfile::tempdir().unwrap();
let (root, sub, marker) = with_submodule(temp.path(), "*.txt filter=marker\n");
let command = format!("echo x > '{}'; cat", marker.to_str().unwrap().replace('\\', "/"));
git(&sub, &["config", "filter.marker.clean", &command]);
let repos = Repos::discover(&root, &|_| false).unwrap();
for repo in repos.all() {
repo.set_execution_allowed(false);
}
let mut ran = Vec::new();
// The size it has in the index, so git has to read the file to tell whether it changed.
write(&sub, "src/move.txt", "more\n");
let compared = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new());
if marker.exists() {
ran.push("compare_paths");
}
let _ = std::fs::remove_file(&marker);
write(&sub, "src/move.txt", "less\n");
let patch = repos.at_root(&root).unwrap().working_tree_patch(None, None, 1 << 20, &CancellationToken::new());
if marker.exists() {
ran.push("working_tree_patch");
}
let _ = std::fs::remove_file(&marker);
write(&sub, "src/move.txt", "same\n");
let _ = Command::new("git").current_dir(&root).args(["status", "--porcelain"]).output();
assert!(marker.exists(), "control: plain `git status` did not run the filter, fixture is not live");
assert!(ran.is_empty(), "Restricted Mode ran the submodule's filter via: {ran:?}");
assert!(compared.unwrap().repositories.iter().all(|r| r.error.is_none()));
patch.unwrap();
}
/// git runs the hooks a config names for `post-index-change` when a command writes the index
/// back, as `git diff` does after refreshing it.
#[test]
fn restricted_reads_do_not_run_hooks_of_the_repository_s_config() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path()).join("repo");
let marker = root.with_file_name("marker");
init(&root, "main");
git(&root, &["config", "hook.marker.event", "post-index-change"]);
let command = format!("echo x > '{}'", marker.to_str().unwrap().replace('\\', "/"));
git(&root, &["config", "hook.marker.command", &command]);
let listed = Command::new("git").current_dir(&root).args(["hook", "list", "post-index-change"]).output();
if !String::from_utf8_lossy(&listed.unwrap().stdout).contains("marker") {
return; // This git has no hooks named by a config.
}
// The content it has in the index, with a time of its own that is not the index's.
let touch = |seconds: u64| {
let file = std::fs::File::options().write(true).open(root.join("src/move.txt")).unwrap();
file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(seconds)).unwrap();
};
let repos = Repos::discover(&root, &|_| false).unwrap();
let repo = repos.at_root(&root).unwrap();
repo.set_execution_allowed(false);
let mut ran = Vec::new();
touch(60);
let compared = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new());
if marker.exists() {
ran.push("compare_paths");
}
let _ = std::fs::remove_file(&marker);
touch(120);
let patch = repo.working_tree_patch(None, None, 1 << 20, &CancellationToken::new());
if marker.exists() {
ran.push("working_tree_patch");
}
let _ = std::fs::remove_file(&marker);
touch(180);
let _ = Command::new("git").current_dir(&root).args(["--no-pager", "diff"]).output();
assert!(marker.exists(), "control: plain `git diff` did not run the hook, fixture is not live");
assert!(ran.is_empty(), "Restricted Mode ran a hook of the repository's config via: {ran:?}");
assert!(compared.unwrap().repositories[0].error.is_none());
assert_eq!(patch.unwrap(), "");
}
/// `diff.submodule=diff` has git run `git diff` in the submodule, under the submodule's config.
#[test]
fn restricted_patch_does_not_run_a_diff_driver_of_a_submodule() {
let temp = tempfile::tempdir().unwrap();
let (root, sub, marker) = with_submodule(temp.path(), "*.txt diff=marker\n");
write(&sub, "src/move.txt", "move\nagain\n");
git(&sub, &["commit", "-qam", "second"]);
let command = format!("echo x > '{}'; cat", marker.to_str().unwrap().replace('\\', "/"));
git(&sub, &["config", "diff.marker.command", &command]);
git(&root, &["config", "diff.submodule", "diff"]);
let repos = Repos::discover(&root, &|_| false).unwrap();
for repo in repos.all() {
repo.set_execution_allowed(false);
}
let patch = repos.at_root(&root).unwrap().working_tree_patch(None, None, 1 << 20, &CancellationToken::new());
let ran = marker.exists();
let _ = std::fs::remove_file(&marker);
let _ = Command::new("git").current_dir(&root).args(["--no-pager", "diff", "HEAD"]).output();
assert!(marker.exists(), "control: plain `git diff` did not run the diff driver, fixture is not live");
assert!(!ran, "Restricted Mode ran the submodule's diff driver");
let patch = patch.unwrap();
assert!(patch.contains("Subproject commit"), "{patch}");
}
/// Replaces HEAD with a copy of itself carrying a (fake) gpgsig header. `armor` labels the
/// signature and so selects git's verifier: `PGP SIGNATURE`, `SSH SIGNATURE` or
/// `SIGNED MESSAGE` (X.509).
fn sign_head(root: &Path, armor: &str) {
use std::io::Write;
let out = Command::new("git").current_dir(root).args(["cat-file", "commit", "HEAD"]).output().unwrap();
let body = String::from_utf8(out.stdout).unwrap();
let (headers, message) = body.split_once("\n\n").unwrap();
// Drop the signature of an earlier call so only the new one remains.
let mut kept = Vec::new();
let mut in_sig = false;
for line in headers.lines() {
if line.starts_with("gpgsig") {
in_sig = true;
} else if !(in_sig && line.starts_with(' ')) {
in_sig = false;
kept.push(line);
}
}
let headers = kept.join("\n");
let signed = format!(
"{headers}\ngpgsig -----BEGIN {armor}-----\n \n iQEzBAABCAAdFiEEfake\n -----END {armor}-----\n\n{message}"
);
let mut child = Command::new("git")
.current_dir(root)
.args(["hash-object", "-t", "commit", "-w", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(signed.as_bytes()).unwrap();
let id = String::from_utf8(child.wait_with_output().unwrap().stdout).unwrap();
git(root, &["update-ref", "HEAD", id.trim()]);
}
/// Writes a script into `.git` that records its own execution. Returns the marker file it
/// creates and its own path, spelled the way git config takes it.
fn verifier_script(root: &Path) -> (PathBuf, String) {
let marker = root.join("executed-marker");
let script = root.join(".git/verifier.sh");
std::fs::write(
&script,
format!("#!/bin/sh\necho x > '{}'\nexit 1\n", marker.to_str().unwrap().replace('\\', "/")),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
(marker, script.to_str().unwrap().replace('\\', "/"))
}
/// Security audit lead 2: Restricted Mode `git log` must not run the repository's signature verifier.
#[test]
fn restricted_log_does_not_execute_signature_verifier() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
write(&root, "src/move.txt", "move\nsigned change\n");
git(&root, &["commit", "-qam", "signed"]);
sign_head(&root, "PGP SIGNATURE");
let (marker, script) = verifier_script(&root);
git(&root, &["config", "log.showSignature", "true"]);
git(&root, &["config", "gpg.program", &script]);
let repos = Repos::discover(&root, &|_| false).unwrap();
let repo = repos.all().first().unwrap();
repo.set_execution_allowed(false);
let file = root.join("src/move.txt");
type Call<'a> = (&'static str, Box<dyn Fn() + 'a>);
let calls: [Call; 4] = [
("log_users", Box::new(|| assert_eq!(repo.log_users().unwrap(), ["Test <test@example.com>"]))),
// Restricted Mode does not ask git to verify here: it reports E without starting it.
(
"log_details",
Box::new(|| assert_eq!(repo.log_details("HEAD").unwrap().signature, "E", "signed, never verified")),
),
// Passes --no-show-signature itself, so it never ran the verifier.
(
"file history",
Box::new(|| {
let query = crate::LogQuery { path: Some(file.clone().into()), ..Default::default() };
assert!(!repo.log(&query).unwrap().is_empty(), "file history is empty");
}),
),
(
"block_history",
Box::new(|| {
let history =
repo.block_history(&file, None, (0, 1), encoding_rs::UTF_8, &CancellationToken::new()).unwrap();
assert!(!history.revisions.is_empty(), "block history is empty");
}),
),
];
let mut ran = Vec::new();
for (name, call) in &calls {
let _ = std::fs::remove_file(&marker);
call();
if marker.exists() {
ran.push(*name);
}
}
// Control: the fixture's verifier really runs when the same git is used directly.
let _ = std::fs::remove_file(&marker);
let git = cli::detected_git_executable().unwrap();
let _ = Command::new(git).current_dir(&root).args(["log", "-1", "--format=%H"]).output();
assert!(marker.exists(), "control: plain `git log` did not run gpg.program, fixture is not live");
assert!(ran.is_empty(), "Restricted Mode ran the repository's gpg.program via: {ran:?}");
}
/// Security audit lead 2: turning signature display off is not enough. A git that is asked to
/// verify (`%G?`, `--show-signature`, or a `format.pretty` the repository sets) runs the
/// verifier of the signature's format (OpenPGP, SSH and X.509 each have a program key), so
/// Restricted Mode must leave none of them pointing at the repository's program. And when
/// nothing asks, the repository's `log.showSignature` must not make git look for one.
#[test]
fn restricted_git_does_not_execute_signature_verifier_when_asked_to_verify() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
let (marker, script) = verifier_script(&root);
for key in ["gpg.program", "gpg.ssh.program", "gpg.x509.program"] {
git(&root, &["config", key, &script]);
}
// git starts the SSH verifier only when an allowed-signers file exists.
let signers = root.join(".git/allowed-signers");
std::fs::write(&signers, "").unwrap();
git(&root, &["config", "gpg.ssh.allowedSignersFile", &signers.to_str().unwrap().replace('\\', "/")]);
git(&root, &["config", "format.pretty", "%H %G?"]);
git(&root, &["config", "log.showSignature", "true"]);
let unasked: &[&str] = &["log", "-1", "--format=%H"];
let requests: [&[&str]; 4] = [
&["log", "-1", "--format=%G?"],
&["log", "-1", "--show-signature", "--format=%H"],
&["log", "-1"],
unasked,
];
let mut ran = Vec::new();
for armor in ["PGP SIGNATURE", "SSH SIGNATURE", "SIGNED MESSAGE"] {
sign_head(&root, armor);
for args in requests {
for allowed in [false, true] {
let _ = std::fs::remove_file(&marker);
let out = {
let _scope = crate::ExecutionScope::enter(allowed);
cli::run(&root, args, None).unwrap()
};
assert!(!out.stdout.is_empty(), "{armor} {args:?}: git printed nothing");
if allowed {
// Control: the same request runs the fixture's verifier in a trusted project.
assert!(marker.exists(), "control: {armor} {args:?} did not run the verifier");
} else if marker.exists() {
ran.push(format!("{armor} {args:?}"));
} else if args == unasked {
// A git that looked for a verifier and found none says so here.
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.is_empty(), "{armor} {args:?}: git looked for a verifier: {stderr}");
}
}
}
}
assert!(ran.is_empty(), "Restricted Mode ran the repository's signature verifier via: {ran:?}");
}
/// A repository with a signed HEAD whose config has `git log` verify it with a program of
/// its own. Returns the marker that program creates.
fn signed_log_fixture(root: &Path) -> PathBuf {
init(root, "main");
git(root, &["config", "commit.gpgsign", "false"]);
write(root, "src/move.txt", "move\nsigned change\n");
git(root, &["commit", "-qam", "signed"]);
sign_head(root, "PGP SIGNATURE");
let (marker, script) = verifier_script(root);
git(root, &["config", "log.showSignature", "true"]);
git(root, &["config", "gpg.program", &script]);
marker
}
/// Security audit lead 2: a repository nobody has vouched for is restricted. Execution is
/// something a caller grants, not something it has to remember to take away.
#[test]
fn discovered_repository_runs_no_repository_program_until_execution_is_granted() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
let marker = signed_log_fixture(&root);
let repo = crate::Repo::discover(&root).unwrap().unwrap();
let users = repo.log_users().unwrap();
assert!(!marker.exists(), "a freshly discovered repository ran its gpg.program");
assert_eq!(users, ["Test <test@example.com>"]);
// Control: the same call runs the fixture's verifier once execution is granted.
repo.set_execution_allowed(true);
repo.log_users().unwrap();
assert!(marker.exists(), "control: granting execution did not run gpg.program, fixture is not live");
}
/// Security audit lead 2: status is read through gitoxide, which runs the clean filter of
/// the repository's config to compare a touched file with the index. A repository nobody
/// has vouched for is opened without that config.
#[test]
fn discovered_repository_status_runs_no_repository_filter_until_execution_is_granted() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
let marker = root.join("filter-marker");
let clean = format!("echo x > '{}'; cat", marker.to_str().unwrap().replace('\\', "/"));
git(&root, &["config", "filter.marker.clean", &clean]);
write(&root, ".gitattributes", "*.txt filter=marker\n");
// Same length as the committed "move\n": only reading the content tells them apart.
write(&root, "src/move.txt", "MOVE\n");
let repo = crate::Repo::discover(&root).unwrap().unwrap();
let status = repo.status();
assert!(!marker.exists(), "a freshly discovered repository ran its clean filter");
assert!(status.unwrap().iter().any(|f| f.path.0.ends_with("move.txt")), "the touched file is not reported");
// Control: the same call runs the fixture's filter once execution is granted.
repo.set_execution_allowed(true);
repo.status().unwrap();
assert!(marker.exists(), "control: granting execution did not run the clean filter, fixture is not live");
}
/// Security audit lead 2: git started by code that declared no mode at all is restricted.
#[test]
fn git_started_outside_an_execution_scope_is_restricted() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
let marker = signed_log_fixture(&root);
assert!(!cli::run(&root, &["log", "-1", "--format=%H"], None).unwrap().stdout.is_empty());
assert!(!marker.exists(), "git outside an execution scope ran the repository's gpg.program");
// Control: the same request runs the fixture's verifier inside a scope that allows it.
{
let _scope = crate::ExecutionScope::enter(true);
cli::run(&root, &["log", "-1", "--format=%H"], None).unwrap();
}
assert!(marker.exists(), "control: an allowing scope did not run gpg.program, fixture is not live");
}
/// Security audit lead 2: git fetches an object it misses from a partial clone's promisor,
/// running the `uploadpack` program the repository names. Listing a revision's directory in
/// Restricted Mode must not.
#[test]
fn restricted_revision_listing_does_not_run_the_promisor_of_the_repository() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
let marker = root.join("lazy-fetch-marker");
let spelled = |p: &Path| p.to_str().unwrap().replace('\\', "/");
let out = Command::new("git").current_dir(&root).args(["rev-parse", "HEAD^{tree}"]).output().unwrap();
let tree = String::from_utf8(out.stdout).unwrap().trim().to_owned();
git(&root, &["config", "extensions.partialClone", "origin"]);
git(&root, &["config", "remote.origin.url", &spelled(&root)]);
git(&root, &["config", "remote.origin.promisor", "true"]);
git(&root, &["config", "remote.origin.partialclonefilter", "blob:none"]);
git(
&root,
&["config", "remote.origin.uploadpack", &format!("echo ran > '{}'; git-upload-pack", spelled(&marker))],
);
// The listing needs HEAD's tree; with it gone, git asks the promisor for it.
std::fs::remove_file(root.join(".git/objects").join(&tree[..2]).join(&tree[2..])).unwrap();
let repo = crate::Repo::discover(&root).unwrap().unwrap();
repo.set_execution_allowed(false);
assert!(repo.revision_children("HEAD", "").is_err(), "the tree is missing and must stay missing");
assert!(!marker.exists(), "Restricted Mode ran the repository's uploadpack");
// Control: in a trusted project the same listing asks the promisor.
repo.set_execution_allowed(true);
let _ = repo.revision_children("HEAD", "");
assert!(marker.exists(), "control: a trusted listing did not run uploadpack, fixture is not live");
}
}
+26 -8
View File
@@ -638,8 +638,8 @@ mod text_encoding;
mod write;
pub use cli::{
GitCommandObserver, detected_git_executable, git_version, set_askpass_env, set_git_command_observer,
set_git_executable,
GitCommandObserver, detected_git_executable, filter_overrides_from, git_version, restrict, set_askpass_env,
set_git_command_observer, set_git_executable,
};
pub use diff::build_partial_patch;
pub use ignore::{IgnoreDestination, add_to_ignore};
@@ -674,6 +674,8 @@ pub fn clone(
let dest = dest.to_string_lossy();
// Cloning must not inherit an enclosing project's executable Git settings.
let cwd = tempfile::tempdir()?;
// No repository's config applies in that empty folder, so the user's git runs as it is.
let _scope = ExecutionScope::enter(true);
let args = clone_args(url, &dest, depth)?;
let args: Vec<&str> = args.iter().map(String::as_str).collect();
cli::run_streaming(cwd.path(), &args, cancel, on_output)
@@ -731,7 +733,8 @@ pub struct Repo {
}
impl Repo {
/// Discovers the repository containing `path`; `Ok(None)` when not under git.
/// Discovers the repository containing `path`; `Ok(None)` when not under git. The
/// repository is in Restricted Mode until [`Repo::set_execution_allowed`] says otherwise.
pub fn discover(path: &Path) -> IdeResult<Option<Self>> {
repo::discover(path)
}
@@ -986,6 +989,7 @@ impl Repo {
/// Lists one immutable revision directory. Directory names end in `/`.
pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let directory = self.rel_path(std::path::Path::new(directory))?;
@@ -1056,7 +1060,9 @@ impl Repo {
let rel = path.map(|p| self.rel_path(p)).transpose()?;
let overrides = cli::filter_overrides(self.root(), cancel)?;
let mut args: Vec<&str> = overrides.iter().map(String::as_str).collect();
args.extend(["--literal-pathspecs", "--no-pager", "diff", "--no-ext-diff", "--no-textconv", rev, "--"]);
args.extend(["--literal-pathspecs", "--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
args.extend_from_slice(cli::submodule_guards());
args.extend([rev, "--"]);
if let Some(rel) = &rel {
args.push(rel);
}
@@ -1175,11 +1181,13 @@ impl Repo {
/// Edit Commit Message without touching the index or the working tree; returns HEAD
/// before and after.
pub fn reword(&self, commit: &str, message: &str) -> IdeResult<HeadChange> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::reword(self, commit, message).map(|(before, after)| HeadChange { before, after })
}
/// Undoes a history rewrite: HEAD goes from `after` back to `before`, local changes kept.
pub fn restore_head(&self, before: &str, after: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::restore_head(self, before, after)
}
@@ -1301,6 +1309,7 @@ impl Repo {
/// Whether the repository has an executable hook `name` (e.g. `pre-push`), wherever
/// `core.hooksPath` puts it: the Push dialog then offers to skip it.
pub fn has_hook(&self, name: &str) -> IdeResult<bool> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::has_hook(self, name)
}
/// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log
@@ -1551,11 +1560,17 @@ impl Repo {
}
}
thread_local! { static EXECUTION_ALLOWED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) }; }
struct ExecutionScope(bool);
// `None`: nobody has decided yet. Git started now is restricted, and the first scope entered
// decides. The flag governs the git CLI only: gitoxide follows the repository's own flag.
thread_local! { static EXECUTION_ALLOWED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) }; }
struct ExecutionScope(Option<bool>);
impl ExecutionScope {
/// The outermost scope decides; a scope inside it can only restrict.
fn enter(allowed: bool) -> Self {
Self(EXECUTION_ALLOWED.with(|current| current.replace(current.get() && allowed)))
Self(
EXECUTION_ALLOWED
.with(|current| current.replace(Some(current.get().map_or(allowed, |outer| outer && allowed)))),
)
}
}
impl Drop for ExecutionScope {
@@ -1564,9 +1579,12 @@ impl Drop for ExecutionScope {
}
}
pub(crate) fn execution_allowed() -> bool {
EXECUTION_ALLOWED.with(|current| current.get())
EXECUTION_ALLOWED.with(|current| current.get().unwrap_or(false))
}
impl Repo {
/// Whether git may run what the repository's config names (hooks, filters, the fsmonitor,
/// signature verifiers, a promisor) and use the configured git executable. A repository
/// starts without it: only a caller that knows the project is trusted grants it.
pub fn set_execution_allowed(&self, allowed: bool) {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
self.execution_allowed.store(allowed, std::sync::atomic::Ordering::Release);
+1 -1
View File
@@ -71,7 +71,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let pack_dir = common_dir.join("objects").join("pack");
let cache = Cache { stamp: stamp(&files), files, repo: ts, pack_stamp: pack_stamp(&pack_dir), pack_dir };
Ok(Some(Repo {
execution_allowed: std::sync::atomic::AtomicBool::new(true),
execution_allowed: std::sync::atomic::AtomicBool::new(false),
root,
git_dir,
inner: std::sync::Mutex::new(cache),
+2
View File
@@ -200,6 +200,7 @@ pub(crate) fn unshelve(r: &Repo, shelf: &Path, id: &str, remove: bool) -> IdeRes
}
pub fn unshelve_path(r: &Repo, shelf: &Path, id: &str, remove: bool, path: Option<&str>) -> IdeResult<ApplyOutcome> {
let _scope = crate::ExecutionScope::enter(r.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let patch = selected_patch(shelf, id, path)?;
let outcome = crate::patch::apply(r, &patch)?;
if remove && outcome.conflicts.is_empty() {
@@ -291,6 +292,7 @@ pub fn delete_path(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult<()>
/// Reconstruct each side in a throwaway index; neither HEAD nor the working files are used.
pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult<Vec<ShelfDiffFile>> {
let _scope = crate::ExecutionScope::enter(r.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let patch = patch_of(shelf, id)?;
let mut out = Vec::new();
for (section, file) in sections(&patch) {
+4 -1
View File
@@ -40,8 +40,11 @@ impl Fixture {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
+4 -1
View File
@@ -103,8 +103,11 @@ impl Fixture {
fn subjects(&self) -> Vec<String> {
self.git(&["log", "--format=%s"]).lines().map(str::to_string).collect()
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
+4 -1
View File
@@ -58,8 +58,11 @@ impl Fixture {
self.git(&["commit", "-q", "-m", msg]);
self.git(&["rev-parse", "HEAD"])
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
+5 -1
View File
@@ -65,8 +65,11 @@ impl Fixture {
self.git(&["commit", "-q", "-m", msg]);
self.git(&["rev-parse", "HEAD"])
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
@@ -724,6 +727,7 @@ fn file_at_in_a_blobless_clone() {
let url = format!("file://{}", fx.dir.display());
git_in(fx.dir.parent().unwrap(), &["clone", "-q", "--filter=blob:none", &url, "clone"]);
let repo = Repo::discover(&clone).unwrap().unwrap();
repo.set_execution_allowed(true);
// v1's blob was never fetched; reading it fetches it on demand.
assert_eq!(repo.file_at(&clone.join("f.txt"), Some(&c1), encoding_rs::UTF_8).unwrap().as_deref(), Some("v1\n"));
}
+184 -4
View File
@@ -97,8 +97,80 @@ pub fn image_mime(path: &Path) -> Option<&'static str> {
})
}
/// The whole image as a `data:` URL (allowed by the webview's CSP, unlike file URLs).
pub fn read_data_url(path: &Path) -> IdeResult<String> {
/// The server and share of a Windows network path (`\\server\share\…`, also written with `/`
/// or as `\\?\UNC\…`).
#[cfg(windows)]
fn share(path: &Path) -> Option<(&std::ffi::OsStr, &std::ffi::OsStr)> {
use std::path::{Component, Prefix};
let mut components = path.components();
let Component::Prefix(prefix) = components.next()? else { return None };
match prefix.kind() {
// `//?/UNC/server/share`, the spelling a canonical share path crosses the IPC boundary
// in (`ide_core::ipc_path`): with `/`, `std` reads the share `UNC` of a server `?`.
Prefix::UNC(device, unc) if device == "?" => {
let names = (components.next()?, components.next()?, components.next()?);
match names {
(Component::RootDir, Component::Normal(server), Component::Normal(share))
if unc.eq_ignore_ascii_case("UNC") =>
{
Some((server, share))
}
_ => None,
}
}
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => Some((server, share)),
_ => None,
}
}
/// Whether `path` names a file on one of this machine's drives. On Windows that is an absolute
/// path starting at a drive letter. Every other spelling can lead to a server, and opening a
/// share signs in to its server with the user's credentials: `\\server\share`, `\\?\UNC\…`,
/// device paths, and `\??\UNC\…`, which carries no prefix `std` knows and which Windows opens
/// as written.
pub fn on_local_drive(path: &Path) -> bool {
#[cfg(windows)]
{
use std::path::{Component, Prefix};
path.is_absolute()
&& matches!(
path.components().next(),
Some(Component::Prefix(prefix)) if matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_))
)
}
#[cfg(not(windows))]
{
let _ = path;
true
}
}
/// Whether two network paths are on the same share of the same server.
pub fn same_share(a: &Path, b: &Path) -> bool {
#[cfg(windows)]
{
let same = |x: &std::ffi::OsStr, y: &std::ffi::OsStr| x.eq_ignore_ascii_case(y);
matches!((share(a), share(b)), (Some(a), Some(b)) if same(a.0, b.0) && same(a.1, b.1))
}
#[cfg(not(windows))]
{
let _ = (a, b);
false
}
}
/// Whether a path written in content (Markdown, a page, an agent's reply) may be opened: one
/// on a local drive, or on the share one of `opened` (what the user opened) is on.
pub fn content_path_allowed<P: AsRef<Path>>(path: &Path, opened: &[P]) -> bool {
on_local_drive(path) || opened.iter().any(|o| same_share(o.as_ref(), path))
}
/// The whole image as a `data:` URL (allowed by the webview's CSP, unlike file URLs). The path
/// often comes from text someone else wrote (Markdown): see [`content_path_allowed`].
pub fn read_data_url(path: &Path, opened: &[std::path::PathBuf]) -> IdeResult<String> {
if !content_path_allowed(path, opened) {
return Err(IdeError::invalid(format!("{} is not on a local drive or an opened share", path.display())));
}
let mime =
image_mime(path).ok_or_else(|| IdeError::invalid(format!("{} is not a supported image", path.display())))?;
if let Some(bytes) = archive_bytes(path, MAX_IMAGE_SIZE)? {
@@ -138,6 +210,114 @@ pub fn base64(bytes: &[u8]) -> String {
mod tests {
use super::*;
/// Security audit lead 3: an image path from agent markdown must not make the host open a
/// network share (on Windows that authenticates to the named host with the user's credentials).
#[cfg(windows)]
#[test]
fn read_data_url_refuses_network_paths() {
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join("dummy.png");
std::fs::write(&local, b"\x89PNG dummy").unwrap();
// The same file reached over SMB through the loopback admin share.
let s = local.to_str().unwrap();
let back = format!(r"\\127.0.0.1\{}${}", &s[..1], &s[2..]);
// The webview hands the host the forward-slash form (`file://host/share` -> `//host/share`).
let forward = back.replace('\\', "/");
// The NT spelling: no `\\` in front, and Windows opens it all the same.
let nt = format!(r"\??\UNC{}", &back[1..]);
for unc in [forward, back, nt].map(std::path::PathBuf::from) {
assert!(
std::fs::read(&unc).is_ok(),
"control: {} is not reachable over SMB, fixture not live",
unc.display()
);
let read = read_data_url(&unc, &[]);
assert!(read.is_err(), "read_data_url opened {} over SMB: {:?}", unc.display(), read.map(|s| s.len()));
}
}
/// The exception to the above: an image on the share a project is open on, and only there.
#[cfg(windows)]
#[test]
fn read_data_url_reads_the_share_a_project_is_on() {
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join("dummy.png");
std::fs::write(&local, b"\x89PNG dummy").unwrap();
let s = local.to_str().unwrap();
let on = |server: &str| std::path::PathBuf::from(format!(r"\\{server}\{}${}", &s[..1], &s[2..]));
let image = on("127.0.0.1");
let wanted = read_data_url(&local, &[]).unwrap();
// A project root as typed, and as `canonicalize` returns it.
for root in [r"\\127.0.0.1\C$\work", r"\\?\UNC\127.0.0.1\c$\work"].map(std::path::PathBuf::from) {
let opened = [std::path::PathBuf::from(r"D:\other"), root];
assert_eq!(read_data_url(&image, &opened).unwrap(), wanted);
assert_eq!(read_data_url(Path::new(&image.to_str().unwrap().replace('\\', "/")), &opened).unwrap(), wanted);
// The spelling paths of a project on a share cross the IPC boundary in.
let wire = |path: &Path| std::path::PathBuf::from(format!("//?/UNC/{}", &path.to_str().unwrap()[2..]));
assert_eq!(read_data_url(&wire(&image), &opened).unwrap(), wanted);
assert_eq!(read_data_url(&image, &[wire(Path::new(r"\\127.0.0.1\C$\work"))]).unwrap(), wanted);
// The same file through another name of this machine: a server the user did not open.
let other = on("localhost");
assert!(std::fs::read(&other).is_ok(), "control: {} is not reachable over SMB", other.display());
for other in [wire(&other), other] {
let read = read_data_url(&other, &opened);
assert!(
read.is_err(),
"read_data_url opened {} over SMB: {:?}",
other.display(),
read.map(|s| s.len())
);
}
}
}
#[cfg(windows)]
#[test]
fn only_absolute_paths_starting_at_a_drive_letter_are_local() {
for path in [r"C:\a.png", "C:/a.png", r"\\?\C:\a.png", r"z:\share\a.png"] {
assert!(on_local_drive(Path::new(path)), "{path}");
}
for path in [
r"\\server\share\a.png",
"//server/share/a.png",
r"/\server/share/a.png",
r"\\?\UNC\server\share\a.png",
"//?/UNC/server/share/a.png",
r"\\.\UNC\server\share\a.png",
r"\\?\GLOBALROOT\Device\Mup\server\share\a.png",
r"\??\UNC\server\share\a.png",
r"\??\C:\a.png",
r"\a.png",
r"C:a.png",
"a.png",
r"..\a.png",
] {
assert!(!on_local_drive(Path::new(path)), "{path}");
}
let project = Path::new(r"\\nas\work\app");
assert!(same_share(project, Path::new("//NAS/Work/app/docs/a.png")));
assert!(same_share(project, Path::new(r"\\?\UNC\nas\work\other\a.png")));
assert!(!same_share(project, Path::new(r"\\nas\private\a.png")));
assert!(!same_share(project, Path::new(r"\\evil\work\app\a.png")));
assert!(!same_share(project, Path::new(r"\??\UNC\nas\work\app\a.png")));
assert!(!same_share(Path::new(r"C:\app"), Path::new(r"C:\app\a.png")));
// Read by `std` as the share `UNC` of a server named `?`: the spelling a share's paths
// cross the IPC boundary in.
assert!(!same_share(Path::new("//?/UNC/nas/work/app"), Path::new("//?/UNC/evil/share/a.png")));
assert!(same_share(Path::new("//?/UNC/nas/work/app"), Path::new("//?/unc/NAS/Work/docs/a.png")));
assert!(same_share(project, Path::new("//?/UNC/nas/work/docs/a.png")));
assert!(same_share(Path::new(r"\\?\UNC\nas\work\app"), Path::new("//?/UNC/nas/work/docs/a.png")));
for device in ["//?/UNC/nas", "//?/UNC/../nas/work/a.png", "//?/C:/a.png", "//?/nas/work/a.png"] {
assert!(!same_share(Path::new(device), Path::new(device)), "{device}");
}
assert!(content_path_allowed(Path::new(r"C:\app\a.png"), &[project]));
assert!(content_path_allowed(Path::new(r"\\nas\work\docs\a.png"), &[Path::new(r"C:\app"), project]));
assert!(!content_path_allowed(Path::new(r"\\nas\private\a.png"), &[project]));
assert!(!content_path_allowed(Path::new(r"\\nas\work\docs\a.png"), &[Path::new(r"C:\app")]));
assert!(!content_path_allowed(Path::new(r"\\nas\work\docs\a.png"), &[] as &[&Path]));
}
#[test]
fn base64_matches_rfc4648_vectors() {
for (input, expected) in [
@@ -190,12 +370,12 @@ mod tests {
let png = dir.path().join("x.PNG");
fs::write(&png, [0x89, b'P', b'N', b'G']).unwrap();
assert_eq!(
read_data_url(&png).unwrap(),
read_data_url(&png, &[]).unwrap(),
format!("data:image/png;base64,{}", base64(&[0x89, b'P', b'N', b'G']))
);
let txt = dir.path().join("x.txt");
fs::write(&txt, "x").unwrap();
assert!(read_data_url(&txt).is_err());
assert!(read_data_url(&txt, &[]).is_err());
assert_eq!(image_mime(Path::new("a/b.svg")), Some("image/svg+xml"));
}
}
+4 -1
View File
@@ -21,7 +21,10 @@ pub use archive::{
ARCHIVE_EXTENSIONS, archive_entry_path, archive_root, is_archive_file, is_archive_path, read_archive_dir,
read_archive_entry, split_archive_path,
};
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
pub use bytes::{
ByteRange, FileProbe, MAX_RANGE_LEN, base64, content_path_allowed, image_mime, on_local_drive, probe,
read_data_url, read_range, same_share,
};
pub use compare::{
DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge,
};
+1 -1
View File
@@ -7,8 +7,8 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dirs.workspace = true
open = "5"
parking_lot.workspace = true
percent-encoding = "2"
serde.workspace = true
specta.workspace = true
+7 -4
View File
@@ -2,10 +2,13 @@
//!
//! [`WebServer`] is the built-in server: it serves project files read-only on
//! `127.0.0.1`, so pages opened from the IDE load relative scripts, styles and
//! ES modules the way they would from a real server. Every URL starts with a
//! random per-run token, and requests whose `Host` is not the server itself are
//! refused (DNS rebinding). [`installed`] lists the browsers found on this
//! machine and [`open`] launches one, or the system default.
//! ES modules the way they would from a real server. Each served root has a
//! port, and so an origin, of its own, and its URLs start with a random token
//! of its own, new on every run. A file outside the project is served with
//! what lies below its folder, dot names left out, or alone when that folder
//! is the home folder or holds it. Requests whose `Host` is not the server
//! itself are refused (DNS rebinding). [`installed`] lists the browsers found
//! on this machine and [`open`] launches one, or the system default.
mod browsers;
mod server;
+407 -74
View File
@@ -1,5 +1,4 @@
use ide_core::{IdeError, IdeResult};
use parking_lot::RwLock;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -8,8 +7,6 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
/// IDEA's built-in server port, tried first so bookmarked URLs keep working.
const PREFERRED_PORT: u16 = 63342;
const MAX_HEAD: usize = 16 * 1024;
const READ_TIMEOUT: Duration = Duration::from_secs(10);
@@ -32,16 +29,41 @@ const SEGMENT: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
struct Shared {
/// A served directory, at `http://127.0.0.1:<port>/<token>/<key>/…`. The port and the token
/// are its own. The port makes it another origin to a browser, which does not let a page of
/// one root read a response of another. The token keeps such a page from naming a file of
/// another root at all: a port can be found by scanning and a key guessed, a token cannot.
///
/// Storage is not isolated for good: cookies are per host, not per port, and a port may
/// serve another root on a later run.
struct Root {
key: String,
token: String,
/// URL key (`/<token>/<key>/…`) → served directory.
roots: RwLock<HashMap<String, PathBuf>>,
dir: PathBuf,
port: u16,
serves: Serves,
}
/// The built-in web server; started on the first [`WebServer::url_for`].
/// What of its directory a root serves.
#[derive(PartialEq)]
enum Serves {
/// A project: all of it.
All,
/// Everything below the folder of a file outside the project, so the page loads its
/// scripts and styles, except what has a dot name on the way to it (`.ssh`, `.env`).
Folder,
/// This one file outside the project, resolved. Its folder is the home folder or lies
/// above it, where credentials have names anyone knows, with a dot or without
/// (`AppData`, `Library`); or that cannot be told; or its own name starts with a dot.
File(PathBuf),
}
/// The built-in web server; a root starts being served on the first [`WebServer::url_for`] in it.
pub struct WebServer {
shared: Arc<Shared>,
port: tokio::sync::OnceCell<u16>,
/// URL key → what is served under it.
roots: tokio::sync::Mutex<HashMap<String, Arc<Root>>>,
/// The home folder, resolved.
home: Option<PathBuf>,
}
impl Default for WebServer {
@@ -52,23 +74,30 @@ impl Default for WebServer {
impl WebServer {
pub fn new() -> Self {
Self {
shared: Arc::new(Shared { token: uuid::Uuid::new_v4().simple().to_string(), roots: RwLock::default() }),
port: tokio::sync::OnceCell::new(),
let home = dirs::home_dir().and_then(|home| std::fs::canonicalize(home).ok());
if home.is_none() {
tracing::warn!("built-in web server: no home folder, so files outside a project are served alone");
}
Self { roots: Default::default(), home }
}
/// The URL serving `file`: under `root` (the project) when it contains the
/// file, else under the file's directory. Starts the server if needed.
///
/// A project that is the home folder, or holds it, is no boundary to serve whole: its
/// files are served like files outside a project.
pub async fn url_for(&self, root: Option<&Path>, file: &Path) -> IdeResult<String> {
let base = match root {
Some(r) if file.starts_with(r) && file != r => r,
_ => file.parent().ok_or_else(|| IdeError::invalid(format!("no directory: {}", file.display())))?,
let (base, serves) = match root {
Some(r) if file.starts_with(r) && file != r && self.holds_home(r).await != Some(true) => (r, Serves::All),
_ => {
let dir =
file.parent().ok_or_else(|| IdeError::invalid(format!("no directory: {}", file.display())))?;
(dir, self.outside(dir, file).await?)
}
};
let rel = file.strip_prefix(base).map_err(|e| IdeError::internal(e.to_string()))?;
let port = self.port().await?;
let key = self.register(base);
let mut url = format!("http://127.0.0.1:{port}/{}/{}", self.shared.token, enc(&key));
let root = self.root(base, serves).await?;
let mut url = format!("http://127.0.0.1:{}/{}/{}", root.port, root.token, enc(&root.key));
for c in rel.components() {
url.push('/');
url.push_str(&enc(&c.as_os_str().to_string_lossy()));
@@ -76,11 +105,29 @@ impl WebServer {
Ok(url)
}
/// The key `dir` is served under: its name, made unique.
fn register(&self, dir: &Path) -> String {
let mut roots = self.shared.roots.write();
if let Some((k, _)) = roots.iter().find(|(_, d)| d.as_path() == dir) {
return k.clone();
/// Whether `dir` is the home folder or lies above it; `None` when that cannot be told.
async fn holds_home(&self, dir: &Path) -> Option<bool> {
let dir = tokio::fs::canonicalize(dir).await.ok()?;
Some(self.home.as_ref()?.starts_with(dir))
}
/// How much of `dir` is served for `file` in it, a file outside the project: the folder
/// only when it is known not to hold the home folder.
async fn outside(&self, dir: &Path, file: &Path) -> IdeResult<Serves> {
let dot_file = file.file_name().is_some_and(|name| name.to_string_lossy().starts_with('.'));
if self.holds_home(dir).await == Some(false) && !dot_file {
return Ok(Serves::Folder);
}
let resolved = tokio::fs::canonicalize(file).await;
Ok(Serves::File(resolved.map_err(|e| IdeError::invalid(format!("cannot serve {}: {e}", file.display())))?))
}
/// `dir` as a served root, listening from now on if it was not yet. Its key is its name,
/// made unique.
async fn root(&self, dir: &Path, serves: Serves) -> IdeResult<Arc<Root>> {
let mut roots = self.roots.lock().await;
if let Some(root) = roots.values().find(|r| r.dir == dir && r.serves == serves) {
return Ok(root.clone());
}
let name: String = dir
.file_name()
@@ -94,43 +141,42 @@ impl WebServer {
.map(|i| if i == 1 { name.clone() } else { format!("{name}-{i}") })
.find(|k| !roots.contains_key(k))
.expect("unbounded");
roots.insert(key.clone(), dir.to_path_buf());
key
// No fixed port: it would hand the browser storage of whichever root took it on one
// run to whichever takes it on the next.
let unavailable =
|e: std::io::Error| IdeError::unavailable(format!("cannot start the built-in web server: {e}"));
let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(unavailable)?;
let port = listener.local_addr().map_err(unavailable)?.port();
let root = Arc::new(Root {
key: key.clone(),
token: uuid::Uuid::new_v4().simple().to_string(),
dir: dir.to_path_buf(),
port,
serves,
});
tokio::spawn(accept(listener, root.clone()));
tracing::info!("built-in web server on 127.0.0.1:{} for {}", root.port, dir.display());
roots.insert(key, root.clone());
Ok(root)
}
}
async fn port(&self) -> IdeResult<u16> {
self.port
.get_or_try_init(|| async {
let listener = match TcpListener::bind(("127.0.0.1", PREFERRED_PORT)).await {
Ok(l) => l,
Err(_) => TcpListener::bind(("127.0.0.1", 0)).await?,
};
let port = listener.local_addr()?.port();
let shared = self.shared.clone();
async fn accept(listener: TcpListener, root: Arc<Root>) {
loop {
match listener.accept().await {
Ok((sock, _)) => {
let root = root.clone();
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((sock, _)) => {
let shared = shared.clone();
tokio::spawn(async move {
if let Err(e) = serve(sock, &shared, port).await {
tracing::debug!("web server: {e}");
}
});
}
Err(e) => {
tracing::warn!("web server accept: {e}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
if let Err(e) = serve(sock, &root).await {
tracing::debug!("web server: {e}");
}
});
tracing::info!("built-in web server on 127.0.0.1:{port}");
Ok::<_, std::io::Error>(port)
})
.await
.copied()
.map_err(|e| IdeError::unavailable(format!("cannot start the built-in web server: {e}")))
}
Err(e) => {
tracing::warn!("web server accept: {e}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
}
@@ -183,16 +229,16 @@ enum Response {
Error(u16, &'static str),
}
async fn serve(mut sock: TcpStream, shared: &Shared, port: u16) -> std::io::Result<()> {
async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
let Ok(head) = tokio::time::timeout(READ_TIMEOUT, read_head(&mut sock)).await else { return Ok(()) };
let Some(req) = head? else { return Ok(()) };
let head_only = req.method == "HEAD";
let response = if !matches!(req.method.as_str(), "GET" | "HEAD") {
Response::Error(405, "Method Not Allowed")
} else if !host_allowed(req.host.as_deref(), port) {
} else if !host_allowed(req.host.as_deref(), root.port) {
Response::Error(403, "Forbidden")
} else {
resolve(shared, &req.target).await
resolve(root, &req.target).await
};
let mut out = String::new();
match &response {
@@ -210,7 +256,10 @@ async fn serve(mut sock: TcpStream, shared: &Shared, port: u16) -> std::io::Resu
));
}
}
out.push_str("Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n");
out.push_str("Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n");
// A page of another origin (another root's too) cannot load what is served here as a
// script, style or image. Browsers do not apply this to frames or navigations.
out.push_str("Cross-Origin-Resource-Policy: same-origin\r\nConnection: close\r\n\r\n");
sock.write_all(out.as_bytes()).await?;
if !head_only {
match response {
@@ -229,18 +278,20 @@ fn host_allowed(host: Option<&str>, port: u16) -> bool {
host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}"))
}
async fn resolve(shared: &Shared, target: &str) -> Response {
async fn resolve(root: &Root, target: &str) -> Response {
const NOT_FOUND: Response = Response::Error(404, "Not Found");
let path = target.split(['?', '#']).next().unwrap_or("");
let Some(rest) = path.strip_prefix('/') else { return NOT_FOUND };
let mut segments = rest.split('/');
if segments.next() != Some(shared.token.as_str()) {
// A listener answers for its own root only, to its own token only.
if segments.next() != Some(root.token.as_str()) {
return NOT_FOUND;
}
let Some(key) = segments.next().and_then(dec) else { return NOT_FOUND };
let Some(root) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND };
let Ok(real_root) = tokio::fs::canonicalize(&root).await else { return NOT_FOUND };
let mut file = root;
if segments.next().and_then(dec).as_deref() != Some(root.key.as_str()) {
return NOT_FOUND;
}
let Ok(real_root) = tokio::fs::canonicalize(&root.dir).await else { return NOT_FOUND };
let mut file = root.dir.clone();
// `/<token>/<key>` names a directory too: it redirects like any other.
let trailing_slash = path.ends_with('/');
for seg in segments {
@@ -252,7 +303,7 @@ async fn resolve(shared: &Shared, target: &str) -> Response {
_ => return NOT_FOUND,
}
}
let Some(real) = confined(&real_root, &file).await else { return NOT_FOUND };
let Some(real) = confined(root, &real_root, &file).await else { return NOT_FOUND };
let Ok(meta) = tokio::fs::metadata(&real).await else { return NOT_FOUND };
if meta.is_dir() {
// Relative URLs in an index page resolve against the directory.
@@ -260,7 +311,7 @@ async fn resolve(shared: &Shared, target: &str) -> Response {
return Response::Redirect(format!("{path}/"));
}
let index = file.join("index.html");
let Some(real_index) = confined(&real_root, &index).await else { return NOT_FOUND };
let Some(real_index) = confined(root, &real_root, &index).await else { return NOT_FOUND };
return match tokio::fs::metadata(&real_index).await {
Ok(m) if m.is_file() => Response::File { path: real_index, len: m.len(), mime: content_type(&index) },
_ => NOT_FOUND,
@@ -269,12 +320,20 @@ async fn resolve(shared: &Shared, target: &str) -> Response {
Response::File { path: real, len: meta.len(), mime: content_type(&file) }
}
/// `path` with every symlink resolved, if it still lies inside `real_root` (already
/// canonical). Rejecting `..` segments is not enough: a project can commit symlinks
/// that point anywhere on disk.
async fn confined(real_root: &Path, path: &Path) -> Option<PathBuf> {
/// `path` with every symlink resolved, if `root` serves it: it lies inside `real_root`
/// (already canonical) and is within what the root serves. Rejecting `..` segments is not
/// enough: a project can commit symlinks that point anywhere on disk. A dot is looked for
/// in the resolved path because a link or a Windows short name (`SSH~1`) names a dot folder
/// without the dot.
async fn confined(root: &Root, real_root: &Path, path: &Path) -> Option<PathBuf> {
let real = tokio::fs::canonicalize(path).await.ok()?;
real.starts_with(real_root).then_some(real)
let below = real.strip_prefix(real_root).ok()?;
let served = match &root.serves {
Serves::All => true,
Serves::Folder => !below.components().any(|c| c.as_os_str().to_string_lossy().starts_with('.')),
Serves::File(file) => real == *file,
};
served.then_some(real)
}
fn dec(s: &str) -> Option<String> {
@@ -317,6 +376,11 @@ fn content_type(path: &Path) -> &'static str {
mod tests {
use super::*;
/// A server whose home folder is nowhere near what a test serves.
fn server() -> WebServer {
WebServer { home: Some("no-such-home".into()), ..WebServer::new() }
}
async fn get(url: &str, host: Option<&str>) -> String {
let rest = url.strip_prefix("http://").unwrap();
let (authority, path) = rest.split_at(rest.find('/').unwrap());
@@ -356,7 +420,8 @@ mod tests {
assert!(get(&format!("{base}/%2e%2e/secret.txt"), None).await.starts_with("HTTP/1.1 404"));
assert!(get(&format!("{base}/web/..%2f..%2fsecret.txt"), None).await.starts_with("HTTP/1.1 404"));
// Wrong token, foreign Host (DNS rebinding).
let other = url.replacen(&server.shared.token, "0123", 1);
let token = url.split('/').nth(3).unwrap();
let other = url.replacen(token, "0123", 1);
assert!(get(&other, None).await.starts_with("HTTP/1.1 404"));
assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403"));
}
@@ -406,10 +471,278 @@ mod tests {
let (a, b) = (dir.path().join("x/site"), dir.path().join("y/site"));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let server = WebServer::new();
let server = server();
let ua = server.url_for(None, &a.join("p.html")).await.unwrap();
let ub = server.url_for(Some(&a), &b.join("p.html")).await.unwrap();
assert!(ua.ends_with("/site/p.html") && ub.ends_with("/site-2/p.html"), "{ua} {ub}");
assert_eq!(server.url_for(None, &a.join("q.html")).await.unwrap(), ua.replace("p.html", "q.html"));
}
/// Security audit lead 1: a page served from one root must not read another root.
#[tokio::test]
async fn a_page_in_one_root_cannot_read_another_root() {
let dir = tempfile::tempdir().unwrap();
let (untrusted, victim) = (dir.path().join("untrusted"), dir.path().join("victim"));
std::fs::create_dir_all(&untrusted).unwrap();
std::fs::create_dir_all(&victim).unwrap();
std::fs::write(untrusted.join("index.html"), "<script>/* attacker */</script>").unwrap();
std::fs::write(victim.join(".env"), "DUMMY_SECRET").unwrap();
std::fs::write(victim.join("other.html"), "<p>victim</p>").unwrap();
let server = WebServer::new();
let ua = server.url_for(Some(&untrusted), &untrusted.join("index.html")).await.unwrap();
server.url_for(Some(&victim), &victim.join("other.html")).await.unwrap();
// What a script in the untrusted page can compute from its own location.
let cross = ua.replace("/untrusted/index.html", "/victim/.env");
let page = get(&cross, None).await;
assert!(!page.ends_with("DUMMY_SECRET"), "the untrusted root's origin and token read the victim root: {page}");
assert!(page.starts_with("HTTP/1.1 404"), "{page}");
}
/// Security audit lead 1: the page can find the other root's port by scanning and guess its
/// key, the folder's name. The only token it has is its own.
#[tokio::test]
async fn a_page_cannot_ask_another_roots_listener_with_its_own_token() {
let dir = tempfile::tempdir().unwrap();
let (untrusted, victim) = (dir.path().join("untrusted"), dir.path().join("victim"));
std::fs::create_dir_all(&untrusted).unwrap();
std::fs::create_dir_all(&victim).unwrap();
std::fs::write(untrusted.join("index.html"), "<script>/* attacker */</script>").unwrap();
std::fs::write(victim.join(".env"), "DUMMY_SECRET").unwrap();
std::fs::write(victim.join("other.html"), "<p>victim</p>").unwrap();
let server = WebServer::new();
let ua = server.url_for(Some(&untrusted), &untrusted.join("index.html")).await.unwrap();
let uv = server.url_for(Some(&victim), &victim.join("other.html")).await.unwrap();
let token = |url: &str| url.split('/').nth(3).unwrap().to_string();
let forged = uv.replacen(&token(&uv), &token(&ua), 1).replace("/other.html", "/.env");
let page = get(&forged, None).await;
assert!(page.starts_with("HTTP/1.1 404"), "the victim's listener answered the untrusted root's token: {page}");
// Its own token still opens it.
assert!(get(&uv.replace("/other.html", "/.env"), None).await.ends_with("DUMMY_SECRET"));
}
/// Security audit lead 1: roots do not share an origin, which is what a browser isolates
/// (fetches, storage, service workers), and no other origin may load a served file as a
/// script, style or image.
#[tokio::test]
async fn each_root_is_its_own_origin_and_keeps_its_files_from_other_origins() {
let dir = tempfile::tempdir().unwrap();
let (a, b) = (dir.path().join("a"), dir.path().join("b"));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("index.html"), "<h1>a</h1>").unwrap();
std::fs::write(b.join("index.html"), "<h1>b</h1>").unwrap();
let server = WebServer::new();
let ua = server.url_for(Some(&a), &a.join("index.html")).await.unwrap();
let ub = server.url_for(Some(&b), &b.join("index.html")).await.unwrap();
let origin = |url: &str| url.split('/').take(3).collect::<Vec<_>>().join("/");
assert_ne!(origin(&ua), origin(&ub), "{ua} {ub}");
for (url, body) in [(&ua, "<h1>a</h1>"), (&ub, "<h1>b</h1>")] {
let page = get(url, None).await;
assert!(page.starts_with("HTTP/1.1 200") && page.ends_with(body), "{page}");
assert!(page.contains("Cross-Origin-Resource-Policy: same-origin\r\n"), "{page}");
}
// The other root's listener does not answer for this one.
let swapped = ub.replace(&origin(&ub), &origin(&ua));
assert!(get(&swapped, None).await.starts_with("HTTP/1.1 404"));
}
/// Security audit lead 7: a file outside the project is served together with the whole
/// folder it lies in, so a page saved to the home folder reads the keys kept there. The
/// same page opened as `file://` reads nothing but itself.
#[tokio::test]
async fn a_page_outside_the_project_cannot_read_the_dot_files_of_its_folder() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(home.join(".ssh")).unwrap();
std::fs::write(home.join("saved.html"), "<script>/* attacker */</script>").unwrap();
std::fs::write(home.join(".ssh/id_ed25519"), "DUMMY_KEY").unwrap();
let server = server();
let url = server.url_for(None, &home.join("saved.html")).await.unwrap();
// What a script in the page can compute from its own location.
let key = url.replace("/saved.html", "/.ssh/id_ed25519");
let page = get(&key, None).await;
assert!(!page.ends_with("DUMMY_KEY"), "a page outside the project read a key next to it: {page}");
assert!(page.starts_with("HTTP/1.1 404"), "{page}");
// A hidden folder is not a directory to redirect into either, however it is spelled.
for spelling in ["/.ssh", "/.ssh/", "/%2essh/id_ed25519", "/.SSH/id_ed25519"] {
let page = get(&url.replace("/saved.html", spelling), None).await;
assert!(page.starts_with("HTTP/1.1 404"), "{spelling}: {page}");
}
}
/// A link names a dot folder without the dot: the resolved path is what counts.
#[tokio::test]
async fn a_link_does_not_reach_the_dot_files_of_a_folder_outside_the_project() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("folder");
std::fs::create_dir_all(folder.join(".ssh")).unwrap();
std::fs::write(folder.join("saved.html"), "<script>/* attacker */</script>").unwrap();
std::fs::write(folder.join(".ssh/id_ed25519"), "DUMMY_KEY").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(".ssh", folder.join("keys")).unwrap();
#[cfg(windows)]
{
// A junction: unlike a symlink, Windows lets any user make one.
let made = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(folder.join("keys"))
.arg(folder.join(".ssh"))
.output()
.unwrap();
assert!(made.status.success(), "{made:?}");
}
assert_eq!(std::fs::read_to_string(folder.join("keys/id_ed25519")).unwrap(), "DUMMY_KEY");
let server = server();
let url = server.url_for(None, &folder.join("saved.html")).await.unwrap();
let page = get(&url.replace("/saved.html", "/keys/id_ed25519"), None).await;
assert!(page.starts_with("HTTP/1.1 404"), "{page}");
}
/// Security audit lead 7: under the home folder lie credentials whose names anyone knows
/// and that start with no dot (`AppData/Roaming/gcloud/credentials.db`, `Library/…`).
#[tokio::test]
async fn a_page_in_the_home_folder_or_above_it_is_served_alone() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(home.join("AppData/Roaming/gcloud")).unwrap();
std::fs::create_dir_all(home.join("Downloads")).unwrap();
std::fs::write(home.join("AppData/Roaming/gcloud/credentials.db"), "DUMMY_CREDENTIALS").unwrap();
std::fs::write(home.join("saved.html"), "<p>saved</p>").unwrap();
std::fs::write(home.join("notes.txt"), "DUMMY_NOTES").unwrap();
std::fs::write(dir.path().join("top.html"), "<p>top</p>").unwrap();
std::fs::write(home.join("Downloads/page.html"), "<p>page</p>").unwrap();
std::fs::write(home.join("Downloads/app.js"), "export {}").unwrap();
let server = WebServer { home: Some(std::fs::canonicalize(&home).unwrap()), ..WebServer::new() };
let saved = server.url_for(None, &home.join("saved.html")).await.unwrap();
assert!(get(&saved, None).await.ends_with("<p>saved</p>"));
for other in ["/AppData/Roaming/gcloud/credentials.db", "/notes.txt", "/", "/Downloads/app.js"] {
let page = get(&saved.replace("/saved.html", other), None).await;
assert!(page.starts_with("HTTP/1.1 404"), "{other}: {page}");
}
let top = server.url_for(None, &dir.path().join("top.html")).await.unwrap();
assert!(get(&top, None).await.ends_with("<p>top</p>"));
let page = get(&top.replace("/top.html", "/home/AppData/Roaming/gcloud/credentials.db"), None).await;
assert!(page.starts_with("HTTP/1.1 404"), "{page}");
// Below the home folder a page still loads what lies beside it.
let page = server.url_for(None, &home.join("Downloads/page.html")).await.unwrap();
assert!(get(&page.replace("/page.html", "/app.js"), None).await.ends_with("export {}"));
}
/// A file outside the project whose own name starts with a dot is not refused: it is
/// served alone.
#[tokio::test]
async fn a_dot_file_outside_the_project_is_served_alone() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("folder");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join(".draft.html"), "<p>draft</p>").unwrap();
std::fs::write(folder.join("app.js"), "export {}").unwrap();
let server = server();
let url = server.url_for(None, &folder.join(".draft.html")).await.unwrap();
assert!(get(&url, None).await.ends_with("<p>draft</p>"));
assert!(get(&url.replace("/.draft.html", "/app.js"), None).await.starts_with("HTTP/1.1 404"));
}
#[tokio::test]
async fn a_page_outside_the_project_loads_what_lies_beside_it() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
std::fs::create_dir_all(site.join("assets")).unwrap();
std::fs::write(site.join("page.html"), "<h1>hi</h1>").unwrap();
std::fs::write(site.join("app.js"), "export {}").unwrap();
std::fs::write(site.join("assets/style.css"), "h1 {}").unwrap();
let server = server();
let url = server.url_for(None, &site.join("page.html")).await.unwrap();
assert!(get(&url, None).await.ends_with("<h1>hi</h1>"));
assert!(get(&url.replace("/page.html", "/app.js"), None).await.ends_with("export {}"));
assert!(get(&url.replace("/page.html", "/assets/style.css"), None).await.ends_with("h1 {}"));
}
/// A project is served whole: build output often lies in a dot folder (`.vitepress/dist`).
/// The same folder asked for as a file outside another project keeps its hidden files.
#[tokio::test]
async fn the_project_serves_its_dot_folders() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("docs");
std::fs::create_dir_all(root.join(".vitepress/dist")).unwrap();
std::fs::write(root.join(".vitepress/dist/index.html"), "<h1>built</h1>").unwrap();
std::fs::write(root.join("readme.html"), "<h1>readme</h1>").unwrap();
let server = server();
let outside = server.url_for(None, &root.join("readme.html")).await.unwrap();
let built = server.url_for(Some(&root), &root.join(".vitepress/dist/index.html")).await.unwrap();
let page = get(&built, None).await;
assert!(page.starts_with("HTTP/1.1 200") && page.ends_with("<h1>built</h1>"), "{page}");
let hidden = get(&outside.replace("/readme.html", "/.vitepress/dist/index.html"), None).await;
assert!(hidden.starts_with("HTTP/1.1 404"), "{hidden}");
}
/// Security audit lead 7: a project that is the home folder, or holds it, is not served
/// whole. Its pages are served like pages outside a project.
#[tokio::test]
async fn a_project_that_holds_the_home_folder_is_not_served_whole() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(home.join(".ssh")).unwrap();
std::fs::create_dir_all(home.join("Downloads")).unwrap();
std::fs::write(home.join(".ssh/id_ed25519"), "DUMMY_KEY").unwrap();
std::fs::write(home.join("saved.html"), "<p>saved</p>").unwrap();
std::fs::write(home.join("notes.txt"), "DUMMY_NOTES").unwrap();
std::fs::write(home.join("Downloads/page.html"), "<p>page</p>").unwrap();
std::fs::write(home.join("Downloads/app.js"), "export {}").unwrap();
let server = WebServer { home: Some(std::fs::canonicalize(&home).unwrap()), ..WebServer::new() };
for project in [home.as_path(), dir.path()] {
let page = server.url_for(Some(project), &home.join("Downloads/page.html")).await.unwrap();
assert!(get(&page, None).await.ends_with("<p>page</p>"));
assert!(get(&page.replace("/page.html", "/app.js"), None).await.ends_with("export {}"));
// Where the key lies when the project is served whole.
let key = get(&page.replace("/Downloads/page.html", "/.ssh/id_ed25519"), None).await;
assert!(key.starts_with("HTTP/1.1 404"), "{}: {key}", project.display());
let saved = server.url_for(Some(project), &home.join("saved.html")).await.unwrap();
assert!(get(&saved, None).await.ends_with("<p>saved</p>"));
for other in ["/.ssh/id_ed25519", "/notes.txt"] {
let got = get(&saved.replace("/saved.html", other), None).await;
assert!(got.starts_with("HTTP/1.1 404"), "{} {other}: {got}", project.display());
}
}
}
/// With no home folder to compare with, a file outside the project is served alone.
#[tokio::test]
async fn a_page_outside_the_project_is_served_alone_when_the_home_folder_is_unknown() {
let dir = tempfile::tempdir().unwrap();
let (folder, project) = (dir.path().join("folder"), dir.path().join("project"));
std::fs::create_dir_all(&folder).unwrap();
std::fs::create_dir_all(&project).unwrap();
for at in [&folder, &project] {
std::fs::write(at.join("page.html"), "<p>page</p>").unwrap();
std::fs::write(at.join("app.js"), "export {}").unwrap();
}
let server = WebServer { home: None, ..WebServer::new() };
let page = server.url_for(None, &folder.join("page.html")).await.unwrap();
assert!(get(&page, None).await.ends_with("<p>page</p>"));
assert!(get(&page.replace("/page.html", "/app.js"), None).await.starts_with("HTTP/1.1 404"));
// A project is served whole all the same.
let page = server.url_for(Some(&project), &project.join("page.html")).await.unwrap();
assert!(get(&page.replace("/page.html", "/app.js"), None).await.ends_with("export {}"));
}
/// A file served alone is named by its resolved path, which a missing file has not.
#[tokio::test]
async fn a_missing_file_that_would_be_served_alone_has_no_url() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let server = WebServer { home: Some(std::fs::canonicalize(&home).unwrap()), ..WebServer::new() };
assert!(server.url_for(None, &home.join("missing.html")).await.is_err());
}
}
+57 -2
View File
@@ -93,11 +93,21 @@ pub fn base_dirs(path: &str, from: &Path, root: Option<&Path>, resource_roots: &
dirs
}
/// Whether a path written in `from` may be looked up: joining a share's path replaces the
/// directory it is joined to, and looking it up signs in to that share's server.
fn reachable(path: &Path, from: &Path) -> bool {
ide_vfs::content_path_allowed(path, &[from])
}
/// Paths `value` may point at, most likely first.
pub fn candidates(value: &str, from: &Path, root: Option<&Path>, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
let path = path_part(value);
let rel = path.trim_start_matches('/');
base_dirs(&path, from, root, resource_roots).iter().map(|d| normalize(&d.join(rel))).collect()
base_dirs(&path, from, root, resource_roots)
.iter()
.map(|d| normalize(&d.join(rel)))
.filter(|p| reachable(p, from))
.collect()
}
/// The existing file or directory `value` names, if any.
@@ -132,7 +142,11 @@ pub fn complete(prefix: &str, from: &Path, root: Option<&Path>, resource_roots:
if base == Path::new("/") && !out.is_empty() {
continue;
}
let Ok(entries) = std::fs::read_dir(normalize(&base.join(rel))) else { continue };
let dir = normalize(&base.join(rel));
if !reachable(&dir, from) {
continue;
}
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if (name.starts_with('.') && !show_hidden) || out.iter().any(|c| c.name == name) {
@@ -209,4 +223,45 @@ mod tests {
);
assert_eq!(names("/img/"), [("logo.svg".into(), false)]);
}
/// A path written in a page must not make the host open a share on another server: on
/// Windows that signs in to it with the user's credentials. The inspection of unresolved
/// references looks every path up as soon as the page opens.
#[cfg(windows)]
#[test]
fn references_reach_no_share_but_the_one_the_page_is_on() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/img")).unwrap();
for f in ["web/index.html", "web/img/a.png"] {
std::fs::write(root.join(f), "").unwrap();
}
// The same folder reached over SMB through the loopback admin share.
let s = root.to_str().unwrap();
let share = PathBuf::from(format!(r"\\127.0.0.1\{}${}", &s[..1], &s[2..]));
let image = share.join(r"web\img\a.png");
assert!(image.exists(), "control: {} is not reachable over SMB, fixture not live", image.display());
let unc = image.to_str().unwrap();
let from = root.join("web/index.html");
// The last one is the NT spelling `\??\UNC\…` (a bare `?` would start the query).
for value in [unc.to_owned(), unc.replace('\\', "%5C"), format!(r"/\%3F%3F\UNC{}", &unc[1..])] {
assert_eq!(resolve(&value, &from, Some(root), &[]), None, "{value}");
}
let typed = format!("{}/", share.join("web").display());
assert_eq!(complete(&typed, &from, Some(root), &[]), [], "{typed}");
// A page that is on the share names its own files.
let from = share.join(r"web\index.html");
assert_eq!(resolve("img/a.png", &from, Some(&share), &[]), Some(image.clone()));
assert_eq!(complete("img/", &from, Some(&share), &[]), [PathCompletion { name: "a.png".into(), dir: false }]);
// The same page as the webview names it: the canonical path, with `/`.
let from = PathBuf::from(ide_core::ipc_path::to_ipc(&dunce::canonicalize(&from).unwrap()));
assert!(from.starts_with("//?/UNC/127.0.0.1"), "{}", from.display());
assert_eq!(resolve("img/a.png", &from, None, &[]), Some(from.parent().unwrap().join("img/a.png")));
assert_eq!(resolve(unc, &from, None, &[]), Some(image.clone()));
let other = format!(r"\\localhost\{}", &unc[r"\\127.0.0.1\".len()..]);
assert!(Path::new(&other).exists(), "control: {other} is not reachable over SMB");
assert_eq!(resolve(&other, &from, None, &[]), None);
}
}
+61
View File
@@ -128,10 +128,24 @@ fn valid_name(name: &str) -> IdeResult<String> {
Ok(name.to_owned())
}
/// A workspace file can come with a cloned repository, and looking a folder up on a network
/// share signs in to that share's server: only local drives and the share `from` is on are
/// looked up.
fn reachable(path: &Path, from: &Path) -> IdeResult<()> {
if ide_vfs::content_path_allowed(path, &[from]) {
return Ok(());
}
Err(IdeError::invalid(format!(
"Project folder is not on a local drive or the workspace's share: {}",
path.display()
)))
}
fn normalize_projects(root: &Path, projects: Vec<PathBuf>) -> IdeResult<Vec<PathBuf>> {
let mut result = Vec::new();
for path in projects {
let path = if path.is_absolute() { path } else { root.join(path) };
reachable(&path, root)?;
let path = match dunce::canonicalize(&path) {
Ok(path) if path.is_dir() => path,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => path,
@@ -141,6 +155,9 @@ fn normalize_projects(root: &Path, projects: Vec<PathBuf>) -> IdeResult<Vec<Path
let mut nested: Workspace = serde_json::from_slice(&std::fs::read(path.join(FILE))?)
.map_err(|e| IdeError::invalid(format!("Invalid workspace: {e}")))?;
let primary = nested.projects.first().map(|p| if p.is_absolute() { p.clone() } else { path.join(p) });
if let Some(primary) = &primary {
reachable(primary, &path)?;
}
if let Some(primary) = primary.and_then(|p| dunce::canonicalize(p).ok()) {
nested.projects[0] = primary;
}
@@ -240,6 +257,50 @@ mod tests {
Workspace::save(&root, "Renamed", missing, false).unwrap();
}
/// A workspace file comes with a cloned repository, and its folders are looked up as soon
/// as the project opens: on Windows, looking one up on a network share signs in to that
/// share's server with the user's credentials.
#[cfg(windows)]
#[test]
fn folders_on_a_share_the_workspace_is_not_on_are_not_looked_up() {
const REFUSED: &str = "Project folder is not on a local drive or the workspace's share";
let tmp = tempfile::tempdir().unwrap();
let local = dunce::canonicalize(tmp.path()).unwrap();
for dir in ["root/.openidea", "root/sub/.openidea", "member"] {
std::fs::create_dir_all(local.join(dir)).unwrap();
}
// The same folders reached over SMB through the loopback admin share.
let s = local.to_str().unwrap();
let share = PathBuf::from(format!(r"\\127.0.0.1\{}${}", &s[..1], &s[2..]));
assert!(share.join("member").is_dir(), "control: {} is not reachable over SMB", share.display());
let write = |dir: &Path, projects: &[&Path]| {
let projects = projects.iter().map(|p| p.to_path_buf()).collect();
let workspace = Workspace { version: 1, name: "W".into(), projects };
std::fs::write(dir.join(FILE), serde_json::to_vec(&workspace).unwrap()).unwrap();
};
let root = local.join("root");
write(&root, &[&root, &local.join("member")]);
assert_eq!(Workspace::load(&root).unwrap().unwrap().projects, [root.clone(), local.join("member")]);
write(&root, &[&root, &share.join("member")]);
let error = Workspace::load(&root).unwrap_err();
assert!(error.message.starts_with(REFUSED), "{}", error.message);
// A linked folder's own workspace file names its primary folder.
write(&root.join("sub"), &[&share.join("member")]);
write(&root, &[&root, Path::new("sub")]);
let error = Workspace::load(&root).unwrap_err();
assert!(error.message.starts_with(REFUSED), "{}", error.message);
// A workspace that is on the share links that share's folders.
std::fs::remove_file(root.join("sub").join(FILE)).unwrap();
let root = dunce::canonicalize(share.join("root")).unwrap();
write(&root, &[&root, &share.join("member")]);
let projects = Workspace::load(&root).unwrap().unwrap().projects;
assert_eq!(projects, [root, dunce::canonicalize(share.join("member")).unwrap()]);
}
#[test]
fn invalid_location_does_not_create_a_folder_inside_a_project() {
let tmp = tempfile::tempdir().unwrap();
+42
View File
@@ -461,3 +461,45 @@ for (const theme of ["dark", "light"] as const) {
for (const { text, ratio } of contrasts) expect(ratio, `${theme}: ${text}`).toBeGreaterThanOrEqual(4.5);
});
}
test("draws a diagram without reaching the hosts its configuration names", async ({ page }) => {
const asked: string[] = [];
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
asked.push(new URL(route.request().url()).pathname);
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
});
const at = (name: string) => `https://images.example.com/${name}`;
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
const diagrams = [
fence(
`%%{init: {"themeCSS": ".nodeLabel{background-image:url(${at("theme-css")})} svg{background-image:url(${at("theme-css-root")})}"}}%%`,
"flowchart TD",
" A[One]-->B[Two]",
),
fence(
`%%{init: {"fontFamily": "x;background-image:url(${at("font-family")})"}}%%`,
"flowchart TD",
" A[One]-->B[Two]",
),
fence(
"---",
"config:",
` themeCSS: ".nodeLabel{background-image:url(${at("front-matter")})}"`,
"---",
"flowchart TD",
" A[One]-->B[Two]",
),
];
const reply = await openReply(page, `Diagrams:\n\n${diagrams.join("\n\n")}`);
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(diagrams.length);
await page.evaluate(
(src) =>
new Promise<void>((done) => {
const img = new Image();
img.onload = img.onerror = () => done();
img.src = src;
}),
"https://example.com/after",
);
expect(asked).toEqual(["/after"]);
});
+8 -4
View File
@@ -345,7 +345,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `ide.commands` | high | Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing. API 1.4; declared own commands are exempt. |
| `database.sources` | medium | API 1.4: manage only the plugin's own project database sources, write their secure credentials and request connection; trusted projects only. |
| `project.read` | low | `Project::root`, `read_file`, `read_bytes`, `find_files`, `symbols`, `search_text`, `host::projects`, `publish_diagnostics`, `problems`, editor opening and decorations |
| `project.write` | medium | `Project::write_file` — creates and replaces text files inside the open projects |
| `project.write` | high | `Project::write_file` — creates and replaces text files inside the open projects, the settings and build scripts a trusted project runs included |
| `fs.read:<path>` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory |
| `fs.write:<path>` | high | `host::fs::write_file`, `write_bytes` — files under that path; doesn't imply reading |
| `editor.read` | low | `TextEditor::document`, `selections`, `host::open_editors` |
@@ -388,7 +388,9 @@ keep the file's encoding and BOM (a new file is UTF-8), are capped at 16 MiB,
and are refused while the editor holds unsaved changes for the same file. The
path is resolved as a read's is, so a link inside a granted directory cannot be
used to write outside it. `project.write` does the same inside the open
projects, creating missing directories. Both are new in API 1.3
projects, creating missing directories. It never writes git's own files:
nothing inside a `.git` folder, nor the `.git` file of a linked worktree or
submodule, whichever name or link leads there. Both are new in API 1.3
(`engines.openidea = "^1.3"`).
`host::fs::plugin_dir()` (API 1.3) returns a private directory belonging only to
@@ -1006,7 +1008,8 @@ Then `register_plugin!(MyPlugin, providers)` and implement
`inline_completion`, `code_actions`, `definition`, `line_markers`. Each gets
the document (path, language, version, text). Results join the language
servers' (completion lists, ⌥⏎ quick fixes, ⌘B, gutter icons; a code action
carries edits for any files, applied as one change). A completion item's
carries edits for files in the project, applied as one change; an edit
anywhere else, or to git's own files, disables it). A completion item's
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon; it
can be a `snippet` (`$1`, `${1:name}`, `$0`), match on `filter_text` instead
of its label, and bring `additional_edits` elsewhere in the document (an
@@ -1023,7 +1026,8 @@ deletes files implements `file_actions::FileActions` too
covers both): a `FileAction` carries `operations` (`FileOperation::Create {
path, text, overwrite }`, `Rename`, `Delete`) and `edits`, applied as one
workspace edit, so a failure changes nothing and one undo reverses it. A path
outside the project root disables the action; `open` shows a file once the
outside the project root, or one of git's own files (a `.git` folder's, or the
`.git` file of a linked worktree), disables the action; `open` shows a file once the
action is applied. Like a code action, it can leave `operations` and `edits`
empty and set `data`: `resolve_file_action` computes them when the user picks
it, with the same prompts and editor window as `resolve_code_action`. File actions are new in API 1.3. Problems (`publish_diagnostics`) can name
+19 -2
View File
@@ -71,8 +71,25 @@ describe("links and fragments", () => {
const text = Text.of(['<a href="b.html#top">']);
const range = { start: { line: 0, col: 9 }, end: { line: 0, col: 19 } };
const link = { range, path: "/p/b.html", fragment: "top", url: null, tooltip: null };
expect(linkReference(text, [link], 12)).toMatchObject({ kind: "file", path: "/p/b.html", fragment: "top" });
expect(linkReference(text, [link], 2)).toBeNull();
expect(linkReference(text, [link], 12, "/p/a.html")).toMatchObject({
kind: "file",
path: "/p/b.html",
fragment: "top",
});
expect(linkReference(text, [link], 2, "/p/a.html")).toBeNull();
});
// On Windows, opening `//server/share` signs in to that server with the user's credentials.
it("follows no server link to a share the file is not on", () => {
const text = Text.of(['<a href="file:////evil/share/b.html">']);
const range = { start: { line: 0, col: 9 }, end: { line: 0, col: 35 } };
const link = { range, path: "//evil/share/b.html", fragment: null, url: null, tooltip: null };
expect(linkReference(text, [link], 12, "C:/p/a.html")).toBeNull();
expect(linkReference(text, [link], 12, "//nas/work/a.html")).toBeNull();
expect(linkReference(text, [{ ...link, path: "//nas/work/b.html" }], 12, "//NAS/Work/a.html")).toMatchObject({
kind: "file",
path: "//nas/work/b.html",
});
});
it("locates line, element id and Markdown heading fragments", () => {
+10 -2
View File
@@ -10,6 +10,7 @@ import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { whenSynced, type EditorDocContext } from "@/editor/api";
import { call, commands, isTauri, type DocumentLink } from "@/platform/ipc";
import { onOtherShare } from "@/platform/paths";
import { openExternalUrl } from "@/platform/openUrl";
import { executeAction } from "@/platform/actions";
import { editorService } from "@/workbench/contributions";
@@ -53,10 +54,17 @@ export function knownLinks(view: EditorView): DocumentLink[] | undefined {
return fetchedLinks.get(view.state.doc);
}
export function linkReference(text: Text, links: readonly DocumentLink[], offset: number): Reference | null {
/** The server's link around `offset` in the document at `path`; none to a network share that document is not on. */
export function linkReference(
text: Text,
links: readonly DocumentLink[],
offset: number,
path: string,
): Reference | null {
for (const l of links) {
const { from, to } = rangeToOffsets(text, l.range);
if (offset < from || offset > to) continue;
if (l.path && onOtherShare(l.path, path)) return null;
if (l.path) return { kind: "file", from, to, path: l.path, fragment: l.fragment };
if (l.url) return { kind: "url", from, to, url: l.url };
}
@@ -195,7 +203,7 @@ export async function followReference(ref: Reference): Promise<void> {
* named by a quoted path. Null when there is none.
*/
export async function referenceAt(view: EditorView, doc: EditorDocContext, offset: number): Promise<Reference | null> {
const link = linkReference(view.state.doc, await documentLinks(view, doc), offset);
const link = linkReference(view.state.doc, await documentLinks(view, doc), offset, doc.path);
const ref = textReference(view.state.doc, offset);
if (link?.kind !== "file") {
if (link) return link;
+25
View File
@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { ProjectInfo } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { sourceLink } from "./markdown";
// A fresh module per test: the parser is loaded lazily and cached module-wide.
async function freshMarkdown() {
@@ -56,3 +59,25 @@ describe("markdown rendering", () => {
expect(md.markupToHtml({ kind: "plaintext", value: "<b>" })).toBe('<p class="cm-lsp-plain">&#60;b&#62;</p>');
});
});
// On Windows, opening `//server/share` signs in to that server with the user's credentials.
describe("sourceLink", () => {
const project = (root: string | null) =>
useProject.setState({ project: root === null ? null : ({ root } as ProjectInfo) });
it("reads the file and position a documentation link names", () => {
project("C:/repo");
expect(sourceLink("file:///C:/repo/src/a.py#L12,5")).toEqual({ path: "/C:/repo/src/a.py", line: 11, col: 4 });
expect(sourceLink("https://example.com/a.py")).toBeNull();
});
it("names no share the project is not on", () => {
for (const root of ["C:/repo", "//nas/work/app", null]) {
project(root);
expect(sourceLink("file:////evil/share/a.py#L3"), String(root)).toBeNull();
expect(sourceLink("file:///%5C%5Cevil/share/a.py"), String(root)).toBeNull();
}
project("//nas/work/app");
expect(sourceLink("file:////NAS/work/lib/a.py#L3")).toEqual({ path: "//NAS/work/lib/a.py", line: 2, col: 0 });
});
});
+6 -1
View File
@@ -13,6 +13,8 @@ import { classHighlighter, highlightTree } from "@lezer/highlight";
import type { Markup } from "@/bindings";
import { maybeEditorService } from "@/workbench/contributions";
import { isTauri } from "@/platform/ipc";
import { onOtherShare } from "@/platform/paths";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { selectableText } from "@/ui/selectableText";
@@ -203,9 +205,12 @@ export function sourceLink(href: string): { path: string; line: number; col: num
} catch {
return null;
}
const path = decodeURIComponent(url.pathname);
// Documentation is text someone else wrote.
if (onOtherShare(path, useProject.getState().project?.root ?? "")) return null;
const m = /^#?L?(\d+)(?:[,:](\d+))?/.exec(url.hash.slice(1));
return {
path: decodeURIComponent(url.pathname),
path,
line: m ? Math.max(0, Number(m[1]) - 1) : 0,
col: m?.[2] ? Math.max(0, Number(m[2]) - 1) : 0,
};
+2 -1
View File
@@ -740,7 +740,8 @@ function linkAt(view: EditorView, x: number, y: number): { from: number; to: num
const pos = view.posAtCoords({ x, y }, false);
if (pos == null) return null;
const links = knownLinks(view);
const ref = (links && linkReference(view.state.doc, links, pos)) ?? textReference(view.state.doc, pos);
const ref =
(links && linkReference(view.state.doc, links, pos, docOf(view)?.path ?? "")) ?? textReference(view.state.doc, pos);
if (ref) return { from: ref.from, to: ref.to, link: true };
const word = navigationWord(view, pos);
return word && word.from < word.to ? { from: word.from, to: word.to, link: false } : null;
+33 -9
View File
@@ -155,13 +155,24 @@ it("loads raw HTML images without allowing alternate sources or forged paths", a
expect(container.querySelector("source, [srcset], [onerror]")).toBeNull();
});
it.each([PIXEL, "https://example.com/code.png", "http://localhost:9000/code.png"])(
"keeps an inline or web image: %s",
it("keeps an inline image: a data URL loads without a request", () => {
render(<Markdown text={`![QR](${PIXEL})`} cwd="/repo" />);
expect(screen.getByRole("img")).toHaveAttribute("src", PIXEL);
expect(screen.getByRole("img")).toHaveAttribute("referrerpolicy", "no-referrer");
expect(readImage).not.toHaveBeenCalled();
});
it.each(["https://example.com/code.png", "http://localhost:9000/code.png"])(
"loads a web image in a reply only on a click: %s",
(src) => {
render(<Markdown text={`![QR](${src})`} cwd="/repo" />);
expect(screen.getByRole("img")).toHaveAttribute("src", src);
expect(screen.getByRole("img")).toHaveAttribute("referrerpolicy", "no-referrer");
const { container } = render(<Markdown text={`![QR](${src})`} cwd="/repo" />);
const img = container.querySelector("img")!;
// Not fetched on render: a button stands in, the image is hidden with no src.
expect(img).not.toHaveAttribute("src");
expect(readImage).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: `Load image from ${new URL(src).host}` }));
expect(img).toHaveAttribute("src", src);
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
},
);
@@ -213,10 +224,23 @@ it.each(["javascript:alert(1)", "data:text/html,test", "/tmp/%ZZ.png", "/tmp/cod
);
it("reports network or decode failures without leaving a broken source", () => {
render(<Markdown text="![QR](https://example.com/gone.png)" cwd="/repo" />);
fireEvent.error(screen.getByRole("img"));
expect(screen.getByRole("img")).not.toHaveAttribute("src");
expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: https://example.com/gone.png");
const { container } = render(<Markdown text="![QR](https://example.com/gone.png)" cwd="/repo" />);
fireEvent.click(screen.getByRole("button", { name: "Load image from example.com" }));
const img = container.querySelector("img")!;
fireEvent.error(img);
expect(img).not.toHaveAttribute("src");
expect(img).toHaveAttribute("alt", "Unable to load image: https://example.com/gone.png");
});
it("drops a reply attribute that names another host, so nothing fetches on render", () => {
const { container } = render(
<Markdown
text={'<video poster="https://example.com/p.jpg"></video><p background="//evil.test/x">x</p>'}
cwd="/repo"
/>,
);
expect(container.querySelector("[poster]")).toBeNull();
expect(container.querySelector("[background]")).toBeNull();
});
describe("mermaid diagrams in a reply", () => {
+15 -4
View File
@@ -19,7 +19,7 @@ import { editorService } from "@/workbench/contributions";
import { hasMermaid, renderMermaidIn } from "@/features/viewers/markdown/mermaid";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { fileHref, pathLink, type PathLink } from "./conversation";
import { loadImages } from "./markdownImages";
import { loadImages, type MessageImages } from "./markdownImages";
import styles from "./Session.module.css";
const escapeHtml = (s: string) =>
@@ -42,9 +42,20 @@ const RENDERED_MAX = 500;
let purifier: ReturnType<typeof DOMPurify> | null = null;
/** Whether the webview would read `value` as an address on another host: it drops blanks and takes `\` for `/`. */
const namesHost = (value: string) =>
/^(https?:|[\\/]{2})/i.test([...value].filter((c) => c.charCodeAt(0) > 32).join(""));
function purify(): ReturnType<typeof DOMPurify> {
if (purifier) return purifier;
const p = DOMPurify(window);
// A reply reaches another host only when the user asks: a link they click, an image `loadImages` holds.
// `poster`, `background` and the `src` of other tags would fetch as soon as the reply renders.
p.addHook("uponSanitizeAttribute", (node, data) => {
if (data.attrName === "alt" || data.attrName === "title") return;
if (data.attrName === "href" && node.tagName === "A") return;
if (namesHost(data.attrValue)) data.keepAttr = false;
});
const sources = new WeakMap<Node, string>();
// Preserve image-only file URLs without relaxing URI sanitizing for links or other tags.
p.addHook("beforeSanitizeAttributes", (node) => {
@@ -176,7 +187,7 @@ export function openPathLink(link: PathLink) {
}
/** Highlighting, Copy buttons and file links over freshly rendered `root`. */
function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<string>>): () => void {
function enhance(root: HTMLElement, cwd: string, images: MessageImages): () => void {
let alive = true;
const disposeImages = loadImages(root, cwd, images);
const copies: HTMLButtonElement[] = [];
@@ -243,7 +254,7 @@ function paintBlocks(
painted: PaintedBlock[],
sources: string[],
cwd: string,
images: Map<string, Promise<string>>,
images: MessageImages,
) {
let same = 0;
while (same < painted.length && same < sources.length && painted[same]!.source === sources[same]) same++;
@@ -271,7 +282,7 @@ export function Markdown({
onDiagramDrawn?: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const images = useRef(new Map<string, Promise<string>>());
const images = useRef<MessageImages>({ reads: new Map(), asked: new Set() });
/** The blocks on screen, so a streamed chunk repaints only the blocks it changed. */
const painted = useRef<PaintedBlock[]>([]);
const paintedCwd = useRef(cwd);
+12
View File
@@ -540,6 +540,18 @@
.markdown :global(img) {
max-width: 100%;
}
.loadImage {
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-elev);
color: var(--fg-secondary);
font: inherit;
cursor: pointer;
}
.loadImage:hover {
background: var(--bg-hover);
}
.markdown :global(li:has(> input[type="checkbox"])) {
margin-left: -20px;
}
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { findAll, findRanges, formatDuration, pathLink, quoteText, withQuote } from "./conversation";
import { fileHref, findAll, findRanges, formatDuration, pathLink, quoteText, withQuote } from "./conversation";
describe("quoting", () => {
it("quotes each line and leaves a line to type on", () => {
@@ -37,6 +37,33 @@ describe("pathLink", () => {
});
});
describe("fileHref", () => {
const cwd = "C:/repo";
it("resolves a link against the task folder", () => {
expect(fileHref("docs/my%20notes.md:3", cwd)).toEqual({ path: "C:/repo/docs/my notes.md", line: 2 });
expect(fileHref("/abs/x.py#L7", cwd)).toEqual({ path: "/abs/x.py", line: 6 });
expect(fileHref("https://x.dev/a.md", cwd)).toBeNull();
expect(fileHref("#top", cwd)).toBeNull();
});
// On Windows, opening `//server/share` signs in to that server with the user's credentials.
it("names no network share, however the link spells it", () => {
for (const href of [
"//evil/share/x.md",
"%2F%2Fevil/share/x.md",
"/%2Fevil/share/x.md",
"/\\evil/share/x.md",
"/%5Cevil/share/x.md",
"%5C%5Cevil%5Cshare%5Cx.md",
]) {
expect(fileHref(href, cwd), href).toBeNull();
}
expect(fileHref("%5Cevil/share/x.md", ""), "no task folder").toBeNull();
});
it("follows links in a task that is on a share", () => {
expect(fileHref("docs/a.md", "//nas/work/app")).toEqual({ path: "//nas/work/app/docs/a.md" });
});
});
describe("formatDuration", () => {
it("scales the unit", () => {
expect(formatDuration(850)).toBe("850ms");
+4 -1
View File
@@ -1,5 +1,7 @@
/** Reading an agent conversation: quoting, file links, turn times and finding text. */
import { opensOtherShare } from "@/platform/paths";
/** `text` as a Markdown quote for the composer, followed by an empty line to type in. */
export function quoteText(text: string): string {
const lines = text.replace(/\s+$/, "").split("\n");
@@ -24,7 +26,7 @@ const PATH = /^(?:\.\/)?([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z][A-Za-z0-9]*)(?::(\d+)
/** Explicit Markdown links can also name files with spaces or non-ASCII names. */
export function fileHref(href: string, cwd: string): PathLink | null {
if (!href || href.startsWith("#") || href.startsWith("//") || /^[a-z][a-z\d+.-]*:/i.test(href)) return null;
if (!href || href.startsWith("#") || /^[a-z][a-z\d+.-]*:/i.test(href)) return null;
let decoded: string;
try {
decoded = decodeURIComponent(href);
@@ -34,6 +36,7 @@ export function fileHref(href: string, cwd: string): PathLink | null {
const match = /^(.*?)(?::(\d+)(?::(\d+))?|#L(\d+))?$/.exec(decoded);
if (!match?.[1] || [...decoded].some((char) => char.charCodeAt(0) < 32)) return null;
const path = match[1].startsWith("/") ? match[1] : `${cwd.replace(/\/$/, "")}/${match[1]}`;
if (opensOtherShare(path, match[1], cwd)) return null;
const link: PathLink = { path };
const line = match[2] ?? match[4];
if (line) link.line = Math.max(0, Number(line) - 1);
+32 -6
View File
@@ -1,6 +1,7 @@
import { call, commands } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { tx } from "@/platform/i18n";
import styles from "./Session.module.css";
const hasControl = (text: string) => [...text].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
@@ -37,9 +38,12 @@ export function imageTarget(source: string, cwd: string): ImageTarget | null {
}
}
/** Cache belongs to one message: streaming reuses reads, later replies can show a new QR at the same path. */
export function loadImages(root: HTMLElement, cwd: string, cache: Map<string, Promise<string>>): () => void {
/** What one message has loaded: streaming reuses it, later replies read a new QR at the same path and ask again. */
export type MessageImages = { reads: Map<string, Promise<string>>; asked: Set<string> };
export function loadImages(root: HTMLElement, cwd: string, { reads, asked }: MessageImages): () => void {
let alive = true;
const held: HTMLButtonElement[] = [];
const images = [...root.querySelectorAll<HTMLImageElement>("img")];
for (const img of images) {
const source = img.dataset.imageSrc ?? "";
@@ -55,13 +59,34 @@ export function loadImages(root: HTMLElement, cwd: string, cache: Map<string, Pr
if (!target) {
fail();
} else if ("url" in target) {
img.src = target.url;
const { url } = target;
if (/^data:/i.test(url) || asked.has(url)) {
img.hidden = false;
img.src = url;
} else {
// A reply can be steered into naming an address that carries data out: the user decides.
// That holds for this machine too, where a server may pass the request on.
const load = document.createElement("button");
load.type = "button";
load.className = styles.loadImage!;
load.textContent = tx`Load image from ${new URL(url).host}`;
load.title = url;
load.onclick = () => {
asked.add(url);
load.remove();
img.hidden = false;
img.src = url;
};
img.hidden = true;
img.before(load);
held.push(load);
}
} else {
let pending = cache.get(target.path);
let pending = reads.get(target.path);
if (!pending) {
pending = call(commands.fsReadImage(target.path));
cache.set(target.path, pending);
void pending.catch(() => cache.delete(target.path));
reads.set(target.path, pending);
void pending.catch(() => reads.delete(target.path));
}
void pending.then((url) => {
if (alive) img.src = url;
@@ -71,5 +96,6 @@ export function loadImages(root: HTMLElement, cwd: string, cache: Map<string, Pr
return () => {
alive = false;
for (const img of images) img.onerror = null;
for (const load of held) load.remove();
};
}
+2
View File
@@ -180,6 +180,8 @@ function configure(mermaid: Mermaid, key: string) {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
// These go into the diagram's stylesheet as written: a diagram setting them could fetch from any host.
secure: ["themeCSS", "fontFamily", "altFontFamily"],
suppressErrorRendering: true,
theme: "base",
// Each diagram type defaults to "neo": the start state is hard-coded
@@ -91,6 +91,31 @@ describe("markdown preview rendering", () => {
expect(root.textContent).not.toContain("outline");
});
it("lets only a link name another host", () => {
const at = (name: string) => `https://example.com/${name}`;
const root = document.createElement("div");
root.innerHTML = renderMarkdown(
[
`[A link](${at("link")})`,
`<img src="a.png" srcset="b.png 1x, ${at("srcset")} 2x">`,
`<picture><source srcset="${at("source")}"><img src="a.png"></picture>`,
`<input type="image" src="${at("input")}">`,
`<video poster="${at("poster")}" src="${at("video")}"></video>`,
`<table background="${at("table")}"><tr><td background="${at("cell")}">cell</td></tr></table>`,
'<input type="image" src="//example.com/no-scheme">',
String.raw`<input type="image" src="/\example.com/mixed">`,
].join("\n\n"),
);
const named = [...root.querySelectorAll("*")].flatMap((el) =>
[...el.attributes]
.filter((attr) => attr.value.includes("example.com"))
.map((attr) => `${el.localName}[${attr.name}]`),
);
expect(named).toEqual(["a[href]"]);
expect([...root.querySelectorAll("img")].map((img) => img.dataset.src)).toEqual(["a.png", "a.png"]);
expect(root.querySelector("td")?.textContent).toBe("cell");
});
it("keeps the raw HTML READMEs rely on", () => {
const root = document.createElement("div");
root.innerHTML = renderMarkdown(
@@ -204,6 +229,24 @@ describe("markdown preview rendering", () => {
expect(joinPath("/repo", "../../a.md")).toBe("/a.md");
});
// On Windows, opening `//server/share` signs in to that server with the user's credentials.
it("resolves no link to a network share, however the file spells it", () => {
const file = "C:/repo/docs/guide.md";
for (const href of [
"/\\evil/share/x.png",
"/%5Cevil/share/x.png",
"//evil/share/x.png",
"/%2Fevil/share/x.png",
"file:///%5Cevil/share/x.png",
"file:////evil/share/x.png",
"%5C%5Cevil%5Cshare%5Cx.png",
]) {
expect(resolveLink(file, href), href).toBeNull();
}
expect(resolveLink("/guide.md", "../%5Cevil/share/x.png"), "file at the root").toBeNull();
expect(resolveLink(file, "/docs/a.png")).toEqual({ kind: "file", path: "/docs/a.png" });
});
it("interpolates the preview offset between blocks", () => {
const blocks = [
{ line: 0, top: 0 },
+17 -2
View File
@@ -8,6 +8,7 @@
*/
import { Marked, type Token, type TokensList } from "marked";
import DOMPurify from "dompurify";
import { opensOtherShare } from "@/platform/paths";
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -26,9 +27,21 @@ const marked = new Marked({
// Own instance: hooks must not leak into the hover / documentation renderer.
let purifier: ReturnType<typeof DOMPurify> | null = null;
/** Whether the webview would read `value` as an address on another host: it drops blanks and takes `\` for `/`. */
const namesHost = (value: string) =>
/^(https?:|[\\/]{2})/i.test([...value].filter((c) => c.charCodeAt(0) > 32).join(""));
function purify(): ReturnType<typeof DOMPurify> {
if (purifier) return purifier;
const p = DOMPurify(window);
// The preview loads nothing from other hosts. Only a link, opened on a click, names one;
// an image keeps its source for the preview to refuse.
p.addHook("uponSanitizeAttribute", (node, data) => {
if (data.attrName === "alt" || data.attrName === "title") return;
if (data.attrName === "href" && node.tagName === "A") return;
if (data.attrName === "src" && node.tagName === "IMG") return;
if (namesHost(data.attrValue)) data.keepAttr = false;
});
p.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "IMG") {
const src = node.getAttribute("src");
@@ -165,8 +178,9 @@ export function renderMarkdown(text: string): string {
return purify().sanitize(html, {
USE_PROFILES: { html: true },
ADD_ATTR: ["data-src", "data-line", "data-front-matter"],
FORBID_TAGS: ["style", "form"],
FORBID_ATTR: ["style"],
// `srcset` and `<source>` would pick an image past the one the preview loads.
FORBID_TAGS: ["style", "form", "source"],
FORBID_ATTR: ["style", "srcset"],
});
}
@@ -203,6 +217,7 @@ export function resolveLink(file: string, href: string): LinkTarget | null {
const fragment = hash >= 0 ? decodeURIComponent(rest.slice(hash + 1)) : undefined;
const pathPart = decodeURIComponent((hash >= 0 ? rest.slice(0, hash) : rest).replace(/\?.*$/, ""));
const path = pathPart ? joinPath(dirOf(file), pathPart) : file;
if (pathPart && opensOtherShare(path, pathPart, file)) return null;
const lineMatch = fragment ? /^L(\d+)/.exec(fragment) : null;
if (lineMatch) return { kind: "file", path, line: Math.max(0, Number(lineMatch[1]) - 1) };
return { kind: "file", path, ...(fragment ? { anchor: fragment } : {}) };
+1
View File
@@ -349,6 +349,7 @@
"Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}",
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.",
"Unable to load image: {path}": "Unable to load image: {path}",
"Load image from {host}": "Load image from {host}",
"Updates": "Updates",
"Update source": "Update source",
"Current version": "Current version",
+1
View File
@@ -6820,6 +6820,7 @@
"Stage and Retry": "暂存并重试",
"Show Files": "显示文件",
"Unable to load image: {path}": "无法加载图片:{path}",
"Load image from {host}": "加载来自 {host} 的图片",
"Updates": "更新",
"Update source": "更新源",
"Current version": "当前版本",
+55 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ipcPath } from "./paths";
import { ipcPath, onOtherShare, opensOtherShare } from "./paths";
const raw = String.raw;
const APPDATA = raw`C:\Users\me\AppData\Roaming\ai.l0ng.openidea`;
@@ -22,3 +22,57 @@ describe("ipcPath", () => {
expect(ipcPath(raw`/home/me/a\b.txt`)).toBe(raw`/home/me/a\b.txt`);
});
});
describe("onOtherShare", () => {
it("is true for a share the base is not on", () => {
for (const base of ["C:/repo/a.md", "", "//nas/work/a.md", "//evil/other/a.md", "//evil"]) {
expect(onOtherShare("//evil/share/a.png", base), base).toBe(true);
expect(onOtherShare(raw`\\evil\share\a.png`, base), base).toBe(true);
}
// A server alone, and spellings with no server to compare.
for (const path of ["//evil", "//evil/", raw`/\\evil/share/a.png`]) {
expect(onOtherShare(path, "//evil/share/a.md"), path).toBe(true);
}
});
// A project on a share gets its paths from the host as `//?/UNC/server/share/…`.
it("reads the server and share of a device path", () => {
const base = "//?/UNC/nas/work/app/a.md";
expect(onOtherShare("//?/UNC/evil/share/a.png", base)).toBe(true);
expect(onOtherShare("//?/UNC/nas/private/a.png", base)).toBe(true);
expect(onOtherShare("//evil/share/a.png", base)).toBe(true);
expect(onOtherShare("//?/UNC", base)).toBe(true);
expect(onOtherShare("//?/C:/a.png", "//?/C:/a.md")).toBe(true);
expect(onOtherShare("//?/unc/NAS/Work/lib/a.png", base)).toBe(false);
expect(onOtherShare("//nas/work/lib/a.png", base)).toBe(false);
expect(onOtherShare("//?/UNC/nas/work/lib/a.png", "//nas/work/app/a.md")).toBe(false);
});
it("is false on the base's own share and off any share", () => {
expect(onOtherShare("//NAS/Work/lib/a.png", "//nas/work/app/a.md")).toBe(false);
expect(onOtherShare(raw`\\nas\work\lib\a.png`, "//nas/work/app/a.md")).toBe(false);
expect(onOtherShare("C:/repo/a.png", "//nas/work/app/a.md")).toBe(false);
expect(onOtherShare("/abs/a.png", "C:/repo/a.md")).toBe(false);
});
});
describe("opensOtherShare", () => {
it("is true for a share the text names itself, in any mix of slashes", () => {
for (const written of ["//evil/share/a.png", raw`\\evil\share\a.png`, raw`/\evil/share/a.png`, raw`\/evil/a.png`]) {
expect(opensOtherShare(written, written, "C:/repo"), written).toBe(true);
expect(opensOtherShare(written, written, "//nas/work"), written).toBe(true);
}
});
it("is true for a share that only appears once the path is resolved", () => {
expect(opensOtherShare(raw`/\evil/share/a.png`, raw`\evil/share/a.png`, "")).toBe(true);
expect(opensOtherShare(raw`/\evil/share/a.png`, raw`../\evil/share/a.png`, "/guide.md")).toBe(true);
expect(opensOtherShare("//evil/share/a.png", "a.png", "//nas/work")).toBe(true);
});
it("is false on a local drive and on the share the text is on", () => {
expect(opensOtherShare("C:/repo/docs/a.png", "docs/a.png", "C:/repo")).toBe(false);
expect(opensOtherShare("/abs/a.png", "/abs/a.png", "C:/repo")).toBe(false);
expect(opensOtherShare("//nas/work/docs/a.png", "docs/a.png", "//nas/work")).toBe(false);
});
});
+33
View File
@@ -21,6 +21,39 @@ export function ipcPath(p: string): string {
return DRIVE.test(path) || NETWORK.test(path) ? path.replace(/\\/g, "/") : path;
}
const SHARE = /^[\\/]{2}/;
const SHARE_NAME = /^[\\/]{2}(?:\?[\\/]UNC[\\/])?([^\\/]+)[\\/]([^\\/]+)/i;
/**
* `server/share` of a path on a network share, in lower case; null for any other path. The
* host hands out a share's paths as `//?/UNC/server/share/…`, the dialogs as `//server/share/…`.
*/
function shareOf(path: string): string | null {
const m = SHARE_NAME.exec(path);
// Any other device path (`//?/C:/…`, `//./…`) names no server to compare.
return m && m[1] !== "?" && m[1] !== "." ? `${m[1]}/${m[2]}`.toLowerCase() : null;
}
/**
* Whether `path` is on a network share that `base` is not on. Windows reads `//server/share`,
* `\\server\share` and mixes of the two as a share, and opening one signs in to its server
* with the user's credentials: a path that text someone else wrote leads to is followed to no
* share but the one that text is on.
*/
export function onOtherShare(path: string, base: string): boolean {
if (!SHARE.test(path)) return false;
const share = shareOf(path);
return share === null || share !== shareOf(base);
}
/**
* `onOtherShare` for a path resolved against `base`, the folder or file of the text naming
* it: also true when that text spells a share itself (`written`, decoded).
*/
export function opensOtherShare(path: string, written: string, base: string): boolean {
return SHARE.test(written) || onOtherShare(path, base);
}
/** True when `path` equals `ancestor` or lies below it. */
export function isSameOrInside(path: string, ancestor: string): boolean {
path = ipcPath(path);