fix(storage): transactional migrations, purge legacy plaintext secrets, private DB files, stricter ids and hosts
- Each migration runs in a transaction. Several rebuild the profiles
table (create, copy, DROP, rename); a crash between the DROP and the
rename left a database that failed to open on every later launch.
- Migration 009 removes the Go-era proxyPassword/totpSecret keys still
sitting in old ssh_config JSON, then VACUUMs and checkpoints the WAL so
the old pages leave the file. secure_delete is on from now on.
- nexus.db (and -wal/-shm) are created and kept 0600.
- Profile ids are limited to [A-Za-z0-9._-]{1,64}: ids name keychain and
vault entries (profile:<id>:<kind>), so a crafted id containing ':'
could shadow or purge another profile's credentials on import/sync.
- Hosts reject control characters (NUL got through) and >253 bytes; jump
hosts and forward destination/bind addresses get the same host check.
This commit is contained in:
@@ -60,6 +60,10 @@ pub fn validate_profile(p: &Profile) -> Result<(), ValidationErrors> {
|
||||
let mut errs = ValidationErrors::default();
|
||||
if p.id.trim().is_empty() {
|
||||
errs.add("id", "profile ID must not be empty");
|
||||
} else if p.id.len() > 64 || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) {
|
||||
// Ids name keychain/vault entries (`profile:<id>:<kind>`): a crafted id containing `:`
|
||||
// could shadow or purge another profile's credentials.
|
||||
errs.add("id", "profile ID may only contain letters, digits, '-', '_' and '.'");
|
||||
}
|
||||
if p.display_name.trim().is_empty() {
|
||||
errs.add("displayName", "display name must not be empty");
|
||||
@@ -155,6 +159,12 @@ pub fn validate_host(host: &str) -> Result<(), ValidationError> {
|
||||
if h.contains([' ', '\t', '\n', '\r']) {
|
||||
return err("host", "host must not contain whitespace");
|
||||
}
|
||||
if h.chars().any(is_control) {
|
||||
return err("host", "host must not contain control characters");
|
||||
}
|
||||
if h.len() > 253 {
|
||||
return err("host", "host is too long");
|
||||
}
|
||||
if h.contains("://") {
|
||||
return err("host", "host must not include a URI scheme");
|
||||
}
|
||||
@@ -206,8 +216,8 @@ pub fn validate_ssh_config(cfg: &SshConfig) -> Result<(), ValidationErrors> {
|
||||
}
|
||||
for (i, jh) in cfg.jump_hosts.iter().enumerate() {
|
||||
let prefix = format!("ssh.jumpHosts[{i}]");
|
||||
if jh.host.trim().is_empty() {
|
||||
errs.add(format!("{prefix}.host"), "jump host address must not be empty");
|
||||
if let Err(e) = validate_host(&jh.host) {
|
||||
errs.add(format!("{prefix}.host"), format!("jump host: {}", e.message));
|
||||
}
|
||||
if !(1..=65535).contains(&jh.port) {
|
||||
errs.add(format!("{prefix}.port"), "jump host port must be between 1 and 65535");
|
||||
@@ -224,8 +234,13 @@ pub fn validate_ssh_config(cfg: &SshConfig) -> Result<(), ValidationErrors> {
|
||||
if !(1..=65535).contains(&pf.dest_port) {
|
||||
errs.add(format!("{prefix}.destPort"), "destination port must be between 1 and 65535");
|
||||
}
|
||||
if pf.dest_host.trim().is_empty() {
|
||||
errs.add(format!("{prefix}.destHost"), "destination host must not be empty");
|
||||
if let Err(e) = validate_host(&pf.dest_host) {
|
||||
errs.add(format!("{prefix}.destHost"), format!("destination: {}", e.message));
|
||||
}
|
||||
if !pf.bind_host.trim().is_empty()
|
||||
&& let Err(e) = validate_host(&pf.bind_host)
|
||||
{
|
||||
errs.add(format!("{prefix}.bindHost"), format!("bind address: {}", e.message));
|
||||
}
|
||||
}
|
||||
errs.into_result()
|
||||
@@ -626,4 +641,21 @@ mod tests {
|
||||
assert!(validate_irc_config(&c).is_err(), "{rooms:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_and_ids_reject_control_characters_and_separators() {
|
||||
assert!(validate_host("a\0b").is_err() && validate_host("a\x1bb").is_err());
|
||||
assert!(validate_host(&"a".repeat(254)).is_err() && validate_host("db.example.com").is_ok() && validate_host("::1").is_ok());
|
||||
let mut p = Profile::new(Protocol::Ssh);
|
||||
p.display_name = "x".into();
|
||||
p.host = "h".into();
|
||||
p.username = "u".into();
|
||||
assert!(validate_profile(&p).is_ok(), "a generated id is valid");
|
||||
for bad in ["victim:password", "../x", "a b", &"a".repeat(65)] {
|
||||
p.id = bad.into();
|
||||
assert!(validate_profile(&p).is_err(), "{bad:?}");
|
||||
}
|
||||
p.id = "rdp-test_1.a".into();
|
||||
assert!(validate_profile(&p).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user