Critical: secret rm .. deletes the entire vault; rm/mv/import skip name validation
#33
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found during the 1.0 security survey. This was not on the old TODO list — it is strictly worse than the "dots in secret names risk path traversal" item that was, because it is an unvalidated destructive path rather than a read.
Threat
internal/vault/secrets.go:82-111definesisValidSecretName, and it is sound: it rejects"", leading.,..components, leading and trailing/,//, and anything outside^[a-zA-Z0-9\.\-\_\/]+$.AddSecret(:127),GetSecretObject(:358) andresolveSecretVersion(:625) all call it.The destructive commands do not.
internal/cli/secrets.go:651-689,RemoveSecret:The
/to%encoding is what normally collapses a name into a single path component and makes traversal structurally impossible. A name of..contains no/, so it passes through the encoding unchanged, andfilepath.Jointhen cleans the result:secret rm ..producesfilepath.Join(vaultDir, "secrets.d", "..")which cleans tovaultDir, soRemoveAlldeletes the entire vault — every secret, every version, every unlocker, the vault metadata, andlongterm.age.secret rm .cleans tosecrets.d, deleting every secret in the vault while leaving the unlockers.cobra.ExactArgs(1)atinternal/cli/secrets.go:192does not filter either value. There is no confirmation prompt and no--forcerequirement onsecret rm(see the separate destructive-confirmation issue), so a single argument destroys the vault with no second chance and no backup.The same unvalidated pattern is in
moveSecretWithinVault(internal/cli/secrets.go:770-771,:782-783), which doesRemoveAll(destDir)at:795andRenameat:801— sosecret mv .. anythingrenames the vault directory out from under itself.moveSecretCrossVault(:812) and the file-import path have the same gap.Realistic triggers, none of which require a malicious actor:
secret rm "$PREFIX/.."withPREFIXempty.secret rm...is two adjacent keys.Because unlockers and
longterm.ageare deleted along with the secrets, recovery aftersecret rm ..requires the BIP39 mnemonic. A user who never wrote the mnemonic down — which the tool permits, since passphrase unlockers work without it — has permanently lost every secret in that vault.Definition of done
isValidSecretName(or an exported equivalent) is called at the top of every command path that resolves a user-supplied name to a filesystem path, before any path construction:RemoveSecret,MoveSecret/moveSecretWithinVault/moveSecretCrossVault(both source and destination), andImportSecret.RemoveAll,Rename, orMkdirAllis reached.secret rm ..,secret rm .,secret rm ../../etc,secret mv .. x,secret mv x .., and the import equivalents all fail with a clear validation error and a non-zero exit, leaving the vault byte-for-byte unchanged.secrets.dentries, andunlockers.dall still exist with the same contents. A test that only checks the error message would pass even if the deletion had already happened.RemoveSecretand both move paths, and live alongside the existing traversal tests ininternal/vault/path_traversal_test.go/internal/vault/secrets_name_test.goor a CLI-level equivalent.make checkgreen.TODO.mdupdated in the same commit.Implementation requirements
isValidSecretNameis currently unexported ininternal/vault; export it or add a thin exported wrapper and call that frominternal/cli. Two copies of this rule will drift, and the drift will be silent./to%encoding, matching howAddSecretalready does it. Validating post-encoding would accept..since the encoding does not alter it...destination is just as destructive.filepath.Cleanand checking the prefix. Prefix checks on cleaned paths are a well-known source of bypasses, and the allowlist validator already exists and is correct — use it.Priority
Top of the 1.0.0 milestone. Unbounded, unrecoverable data loss in a tool whose entire purpose is not losing this data.
Implementation plan:
internal/vault/secrets.go: add exportedValidateSecretName(name string) error, a thin wrapper over the existing unexportedisValidSecretName, returning the wrappedErrInvalidSecretNamewith the messageAddSecretalready composes. RouteAddSecretandresolveSecretVersionthrough it so the rule has exactly one implementation and no second regex.internal/cli/secrets.go: callvault.ValidateSecretNameon the raw argument, before the/->%encoding and before anyfilepath.Join, at the top of:RemoveSecret(beforeDirExists/RemoveAll)ImportSecret(before the source file is even read)moveSecretWithinVault— bothsourceanddestmoveSecretCrossVault— bothsrcSecretNameanddestSecretName, afterMoveSecrethas resolved the defaulted destination nameOne call site per entry point. No
filepath.Clean/prefix checking, no confirmation prompt as a substitute.Regression tests, CLI-level (the gap is in
internal/cli), on an in-memory filesystem: snapshot the whole vault tree (paths + file contents, includingsecrets.dandunlockers.d) before the call, assert the command errors withErrInvalidSecretName, then assert the snapshot is unchanged byte-for-byte. Cases:rm ..,rm .,rm ../../etc,mv .. x,mv x .., and the import equivalent.TODO.mdupdated in the same commit. Gate onmake checkplusscript/cibuild(the containerized build, since the hostmemlocklimit aborts the memguard test locally).