docs/design/secure-config.md
Author: Matt Stark
An attacker that has control over your jj configuration has full control over your system when you run specific commands. As an example, an attacker can have you enable the following repo config:
[fix.tools.foo]
command = ["malicious", "command"]
When a user then runs jj fix, this will run their malicious command and they
can gain full control over your system. This can be achieved via zipping up a
repo and sending it to the user, with the .jj/repo/config.toml file containing
the above config (hence why this is colloquially known as the “zip file
problem”).
There are plans to add features such as hooks to jj which will only make it easier for this to occur. For simplicity’s sake, we will assume that if an attacker has their configuration enabled on your system, it is compromised.
Assume any reference to repo config can equivalently be replaced with workspace configs. We will treat them in the same way.
This is not something that can be 100% defended against. Defense against all possible attack vectors is infeasible, so we will instead note all the attack vectors and what it would take to defend against them.
jj config set --repo fix.tools.foo ‘[“malicious”, “command”]’jj fixThis attack vector can be solved by ensuring that we can determine the user who created the repo.
jj config set --repo fix.tools.foo ‘[“malicious”, “command”]’jj fixThis attack vector can be solved by ensuring that we can determine the path that the repo was stored at.
/path/to/repojj config set --repo fix.tools.foo ‘[“malicious”, “command”]’/path/to/repo/path/to/repo, make some changes, then run
jj fixThis attack vector can be solved by making repository configuration untamperable.
jj config set --repo fix.tools.foo = [“$repo/format.py”]/path/to/repoformat.py to be maliciousjj fixThis attack vector cannot feasibly be dealt with. It would require a signature of the transitive closure of files that can be accessed via jj configs to solve.
Note that this design uses the word "repo" for everything, but we will use precisely the same technique for workspace configs.
We start by creating the concept of a "config ID".
For clarity's sake, we will call these "config IDs" for repos, and "workspace config IDs" for workspaces.
We will store per-repo configuration in
etcetera::BaseStrategy::config_dir().join(“jj”).join(“repos”).join(config_id).
The filesystem structure will look like:
$HOME/.config/jj/
repos/
abc123/
metadata.binpb
config.toml
workspaces/
def456/
config.toml
metadata.binpb
my-repo/.jj/
workspace-config-id (contains "def456")
workspace-config.toml (unused by jj, details below)
repo/
config-id (contains "abc123")
config.toml (unused by jj, details below)
metadata.binpb will refer to the following protobuf:
message Metadata {
// This is used to distinguish between copies and moves.
string path = 1;
}
The function to load repository configuration, will roughly speaking, look like:
enum ConfigLoadError { NoRepoId, NoConfig, PathMismatch, }
fn load_repo_config_path(repo: &Path) -> Result<PathBuf, ConfigLoadError> {
let config_id = std::fs::read_to_string(repo.join("config-id"))
.map_err(|_|Err(NoRepoId))?;
let repo_config_dir = config_dir.join("repos").join(config_id);
let metadata = Metadata::decode(
std::fs::read(repo_config_dir.join("metadata.binpb")) .map_err(|_|
Err(NoConfig))? )?;
if metadata.path != repo {
return Err(PathMismatch)
}
Ok(repo_config_dir.join("config.toml"))
}
Normally we will simply:
config-id file$HOME/.config/jj/repos/$CONFIG_ID exists$HOME/.config/jj/repos/$CONFIG_ID/metadata.binpb
matches the repo's path$HOME/.config/jj/repos/$CONFIG_ID/config.tomlHowever, these steps can fail. The following sections are how we will handle the errors.
If $repo/.jj/repo/config.toml does not exist, the repo doesn't have any config,
and thus requires no config ID.
Note that the expected way to generate a config-ID would be for the user to either
run jj config edit or jj config path.
If the config file exists, then this corresponds to a legacy repo. To preserve backwards compatibility, we will introduce a period of auto-migration. The current plan is 12 jj versions (approximately 1 year). During this period, if a config ID has not yet been generated, we will silently perform the following (order matters, to ensure failure halfway through doesn’t affect things):
abc123$HOME/.config/jj/repo/abc123/metadata.binpb$HOME/.config/jj/repo/abc123/config.toml as a copy of the original
config fileconfig-id file containing abc123After the migration period is over, we will:
zip follows symlinks by default, so this would reveal the content of your
config to an attacker if they convinced you to send them your repo.This could occur, for example, if the user created a repository in linux, rebooted into windows on the same computer, and attempted to access that repo.
In this event, the user probably expects their config to be attached to the repository, and they expect it to still work on linux, so we will:
$HOME/.config/jj/repo/$CONFIG_ID directory for the same config
ID (to ensure that the config still works on windows)A user's expectation is that if they run cp -r old_repo new_repo, then modify
the old repo's config, the new repo's config is not affected. Thus, we need to
make sure that the repo remains in a 1:1 relationship with the config. Multiple
repos should not point to the same config.
To achieve this, we point the repo at the config, and the config back at the repo. If the path stored in the config doesn't match, we know that something has happened. To decide precisely what happened:
metadata.path to point to the new path$NEW_REPO/.jj/repo.
Note that with a copy, we only actually copy the config when the user runs a
jj command. This means that you can end up in a situation where you:
cp -r old_repo new_repoold_repo, jj config set --repoHowever, this is inherent to storing config out of the repo and is thus unavoidable.
We could, in the future, add a gc command to garbage-collect configs to
deleted repo configs. However, there are some things to consider before doing
so:
Unfortunately, there is no way to distinguish copying / moving from a replay attack. The attacker, if they know a config ID that exists on your system, can create a repo with the same config ID. However, the fact that the config itself is stored out of repo inherently prevents simple replay attacks. In order for the attacker to exploit this, they would need to:
$repo/formatterGiven the impossibility of distinguishing copying / moving from a replay attack, any security measures we come up with to deal with this would have false positives whenever you do a copy / move, creating a significant UX cost. Thus, we intentionally choose not to deal with this kind of attack in the initial version, and have no current intention to solve it in future versions either.
We could potentially deal with this attack vector as an opt-in feature in the future, but it has dubious benefit, as the kind of user who would opt in to something like this is also the kind of user who would never upload their repo as a zip file.
Because only the config-id is stored in-repo, the only attack vector remaining is the replay attack I mentioned above.
$HOME/.config/jj to
%APPDATA%/jj (or vice versa) to solve this issue. You were probably
doing this anyway with specifically the user config file instead of the
directory.~/.config.There are a few questions we would need to resolve here, all with significant drawbacks:
If we do, we introduce a whole bunch of additional annoying UX to the user when they move repos around.
If we don't, we leave ourselved exposed to additional attack vectors
All of these options were discussed in the original PR (#7761), which, unlike the current approach, introduced user interventions and a review process. The current approach, on the other hand, while it does have some extremely minor UX weirdness, has no such issues.