hive-c0re: refuse an account name for the disk path, not just the store one
`token_path`'s doc claimed the compiler was the check and that nothing had to remember to perform one. True of `agent`, which is an `Ident`. Not true of `account`, a bare `&str` concatenated into the filename — safe only because `deliver` happened to validate it first, which is the caller-must-remember pattern the comment denied. Observably a no-op today: the one call site already rejects a bad account before reaching here. What changes is that the signature now enforces what the comment asserted, so a second caller cannot skip it. The check is `path::checked_segment`, made public rather than reimplemented. Two copies of a charset are two charsets: they agree until one is edited, and the day they diverge a name is legal in the store and not on disk. An account name cannot simply become an `Ident` the way an agent name is: it is an attribute name in `hyperhive.matrixAccounts`, so uppercase and underscore are already configurable, and narrowing that is a decision rather than a refactor. The new test's controls pin both. Found by argus reviewing the merged PR.
This commit is contained in:
parent
247ff4498d
commit
6de6bd5d87
2 changed files with 53 additions and 13 deletions
|
|
@ -32,13 +32,20 @@ const TOKEN_PREFIX: &str = "matrix-token";
|
|||
|
||||
/// Where `agent`'s credential for `account` is written.
|
||||
///
|
||||
/// Takes an [`Ident`] rather than a `&str` because the name arrives off the
|
||||
/// queue: `agent_state_dir` addresses a directory, and an unvalidated name
|
||||
/// there is a path-traversal argument. The compiler refusing the `&str` is the
|
||||
/// check — nothing here has to remember to perform one.
|
||||
#[must_use]
|
||||
pub fn token_path(agent: &Ident, account: &str) -> PathBuf {
|
||||
agent_state_dir(agent).join(format!("{TOKEN_PREFIX}-{account}"))
|
||||
/// Both names arrive off the queue and both become path components, so both
|
||||
/// are refused here rather than trusted: `agent` by its [`Ident`] type, and
|
||||
/// `account` by the same charset check the store path uses. They are checked
|
||||
/// by different mechanisms because only one of them has a newtype — an account
|
||||
/// name is an attribute name in `hyperhive.matrixAccounts`, so it is not an
|
||||
/// `Ident` and cannot become one without narrowing what an operator may
|
||||
/// configure.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`swarm_secret_client::Error::PathSegment`] when `account` is empty or
|
||||
/// holds anything outside `[A-Za-z0-9_-]`.
|
||||
pub fn token_path(agent: &Ident, account: &str) -> Result<PathBuf, swarm_secret_client::Error> {
|
||||
path::checked_segment("account", account)?;
|
||||
Ok(agent_state_dir(agent).join(format!("{TOKEN_PREFIX}-{account}")))
|
||||
}
|
||||
|
||||
/// Read the credential `notice` names and write it into the agent's state dir.
|
||||
|
|
@ -65,7 +72,7 @@ pub async fn deliver(notice: &CredentialNotice, cert_role: &str) -> Result<()> {
|
|||
.await
|
||||
.with_context(|| format!("reading {secret_path} from the store"))?;
|
||||
|
||||
write_token(&token_path(&agent, ¬ice.account), &value)
|
||||
write_token(&token_path(&agent, ¬ice.account)?, &value)
|
||||
}
|
||||
|
||||
/// Write `value` to `dest` at `0600`, atomically.
|
||||
|
|
@ -103,7 +110,8 @@ mod tests {
|
|||
// The other end of this agreement is an assertion in
|
||||
// `nix/agent-modules/matrix.nix` and a `systemd.paths` glob — neither
|
||||
// reachable from a Rust test, so the prefix is pinned here.
|
||||
let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc");
|
||||
let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc")
|
||||
.expect("a legal account name");
|
||||
let name = p.file_name().unwrap().to_str().unwrap();
|
||||
assert!(name.starts_with("matrix-token"), "got {name}");
|
||||
assert_eq!(name, "matrix-token-ccc");
|
||||
|
|
@ -113,7 +121,8 @@ mod tests {
|
|||
fn the_partial_file_cannot_match_that_glob() {
|
||||
// A temp name starting with `matrix-token` would be picked up
|
||||
// mid-write; the dot prefix is what stops it.
|
||||
let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc");
|
||||
let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc")
|
||||
.expect("a legal account name");
|
||||
let name = p.file_name().unwrap().to_str().unwrap();
|
||||
let tmp = format!(".{name}.partial");
|
||||
assert!(!tmp.starts_with("matrix-token"), "got {tmp}");
|
||||
|
|
@ -132,8 +141,7 @@ mod tests {
|
|||
fn an_agent_name_off_the_queue_must_pass_the_ident_parser_too() {
|
||||
// Two independent refusals, not one restated: `path::matrix_account`
|
||||
// guards the address in the *store*, `Ident` guards the address on
|
||||
// *disk*. `token_path` cannot even be called without the second,
|
||||
// which is why it takes an `Ident` rather than validating internally.
|
||||
// *disk*, and `token_path` cannot be called without the second.
|
||||
for bad in ["../argus", "dmatrix/../argus", "Dmatrix", "d matrix", ""] {
|
||||
assert!(Ident::parse(bad).is_err(), "{bad:?} must be refused");
|
||||
}
|
||||
|
|
@ -141,4 +149,26 @@ mod tests {
|
|||
// satisfy the loop above.
|
||||
assert!(Ident::parse("dmatrix").is_ok());
|
||||
}
|
||||
|
||||
/// The account half of that same rule, which the agent half's type does
|
||||
/// not cover: an account name is an attr name in `hyperhive.matrixAccounts`
|
||||
/// and so has no newtype to lean on. Without this, a second caller reaching
|
||||
/// `token_path` without going through `path::matrix_account` first would
|
||||
/// build a filename out of an unchecked name.
|
||||
#[test]
|
||||
fn an_account_name_off_the_queue_is_refused_for_the_disk_path_too() {
|
||||
let agent = Ident::parse("dmatrix").expect("a legal agent name");
|
||||
for bad in ["../argus", "a/b", "a b", "a.b", ""] {
|
||||
assert!(
|
||||
token_path(&agent, bad).is_err(),
|
||||
"account {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
// Controls: the legal charset stays reachable, so the loop above is
|
||||
// not passing because everything is refused. Uppercase and underscore
|
||||
// are deliberate — `matrixAccounts` is an attrset, so both are names
|
||||
// an operator can already write.
|
||||
assert!(token_path(&agent, "ops-relay").is_ok());
|
||||
assert!(token_path(&agent, "Ops_Relay9").is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue