wallet: Disallow . and .. from wallet names
What changed, and why it matters
This commit tightens the rules for what can be used as a Bitcoin Core wallet name. Previously, a wallet name could contain relative path tricks such as "." (current directory), ".." (parent directory), or start with "./" or "../". Such names could make the wallet software create, read, or overwrite files in unexpected places, especially during the wallet migration process. The change rejects these names outright and adds tests to make sure they are blocked. It is a hardening fix rather than a fix for a known active exploit.
Treat as a security hardening improvement. Review whether any production deployments or tooling rely on wallet names containing ".", "..", leading "./" or "../", or root paths, because those names will now be rejected. No immediate emergency response is indicated, but the change should be included in release notes so operators are aware of the new restriction.
Security signals we found
Path traversal / directory traversal hardening
Input validation added to filesystem path construction
Removal of test cases that exercised relative-path wallet behavior
New negative tests for non-normalized, relative, and root wallet names
Evidence from the diff
The patch adds validation in GetWalletPath() in src/wallet/wallet.cpp before a wallet path is joined with the wallet directory. It now rejects: (1) non-lexically-normalized paths (containing “.” or “..” as path elements), (2) relative paths beginning with “./” or “../”, and (3) paths that are exactly a root path (“/”, “C:", etc.). Functional tests are updated to expect these rejections and to remove tests that previously exercised relative-path wallets. The change prevents path-traversal-style confusion during wallet creation, loading, and migration.
Changed components
src/wallet/wallet.cpp:GetWalletPath()Wallet creation RPC (createwallet)Wallet loading / migration RPCs (loadwallet, migratewallet, etc.)Functional tests wallet_migration.py and wallet_multiwallet.pyInspect captured patch +38 / −105
diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
index d25c3912..49099bf7 100644
--- a/src/wallet/wallet.cpp
+++ b/src/wallet/wallet.cpp
@@ -2942,17 +2942,34 @@ bool CWallet::EraseAddressReceiveRequest(WalletBatch& batch, const CTxDestinatio
static util::Result<fs::path> GetWalletPath(const std::string& name)
{
+ const fs::path name_path = fs::PathFromString(name);
+
+ // 'name' must be a normalized path, i.e. no . or .. except at the root
+ if (name_path != name_path.lexically_normal()) {
+ return util::Error{Untranslated("Wallet name given as a path must be normalized")};
+ }
+
+ // 'name' cannot begin with ./ or ../
+ if (!name_path.empty() && (*name_path.begin() == fs::PathFromString(".") || *name_path.begin() == fs::PathFromString(".."))) {
+ return util::Error{Untranslated("Wallet name given as a relative path cannot begin with ./ or ../, for wallets not in the walletdir, please use an absolute path.")};
+ }
+
+ // Disallow path at root
+ if (name_path.has_root_path() && name_path.root_path() == name_path) {
+ return util::Error{Untranslated("Wallet name cannot be the root path")};
+ }
+
// Do some checking on wallet path. It should be either a:
//
// 1. Path where a directory can be created.
// 2. Path to an existing directory.
// 3. Path to a symlink to a directory.
// 4. For backwards compatibility, the name of a data file in -walletdir.
- const fs::path wallet_path = fsbridge::AbsPathJoin(GetWalletDir(), fs::PathFromString(name));
+ const fs::path wallet_path = fsbridge::AbsPathJoin(GetWalletDir(), name_path);
fs::file_type path_type = fs::symlink_status(wallet_path).type();
if (!(path_type == fs::file_type::not_found || path_type == fs::file_type::directory ||
(path_type == fs::file_type::symlink && fs::is_directory(wallet_path)) ||
- (path_type == fs::file_type::regular && fs::PathFromString(name).filename() == fs::PathFromString(name)))) {
+ (path_type == fs::file_type::regular && name_path.filename() == name_path))) {
return util::Error{Untranslated(strprintf(
"Invalid -wallet path '%s'. -wallet path should point to a directory where wallet.dat and "
"database/log.?????????? files can be stored, a location where such a directory could be created, "
diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py
index d4489eaa..e28cfd57 100755
--- a/test/functional/wallet_migration.py
+++ b/test/functional/wallet_migration.py
@@ -599,57 +599,6 @@ class WalletMigrationTest(BitcoinTestFramework):
assert_equal(bals, wallet.getbalances())
- def test_wallet_with_relative_path(self):
- self.log.info("Test migration of a wallet that isn't loaded, specified by a relative path")
-
- # Get the nearest common path of both nodes' wallet paths.
- common_parent = os.path.commonpath([self.master_node.wallets_path, self.old_node.wallets_path])
-
- # This test assumes that the relative path from each wallet directory to the common path is identical.
- assert_equal(os.path.relpath(common_parent, start=self.master_node.wallets_path), os.path.relpath(common_parent, start=self.old_node.wallets_path))
-
- wallet_name = "relative"
- absolute_path = os.path.abspath(os.path.join(common_parent, wallet_name))
- relative_name = os.path.relpath(absolute_path, start=self.master_node.wallets_path)
-
- wallet = self.create_legacy_wallet(relative_name)
- # listwalletdirs only returns wallets in the wallet directory
- assert {"name": relative_name} not in wallet.listwalletdir()["wallets"]
- assert relative_name in wallet.listwallets()
-
- default = self.master_node.get_wallet_rpc(self.default_wallet_name)
- addr = wallet.getnewaddress()
- txid = default.sendtoaddress(addr, 1)
- self.generate(self.master_node, 1)
- bals = wallet.getbalances()
- bals["mine"]["nonmempool"] = Decimal('0.0')
-
- migrate_res, wallet = self.migrate_and_get_rpc(relative_name)
-
- # Check that the wallet was migrated, knows the right txid, and has the right balance.
- assert wallet.gettransaction(txid)
- assert_equal(bals, wallet.getbalances())
-
- # The migrated wallet should not be in the wallet dir, but should be in the list of wallets.
- info = wallet.getwalletinfo()
-
- walletdirlist = wallet.listwalletdir()
- assert {"name": info["walletname"]} not in walletdirlist["wallets"]
-
- walletlist = wallet.listwallets()
- assert info["walletname"] in walletlist
-
- # Check that old node can restore from the backup.
- self.old_node.restorewallet("relative_restored", migrate_res['backup_path'])
- wallet = self.old_node.get_wallet_rpc("relative_restored")
- assert wallet.gettransaction(txid)
- del bals["mine"]["nonmempool"]
- assert_equal(bals, wallet.getbalances())
-
- info = wallet.getwalletinfo()
- assert_equal(info["descriptors"], False)
- assert_equal(info["format"], "bdb")
-
def test_wallet_with_path(self, wallet_path):
self.log.info("Test migrating a wallet with the following path/name: %s", wallet_path)
# the wallet data is actually inside of path/that/ends/
@@ -1122,53 +1071,6 @@ class WalletMigrationTest(BitcoinTestFramework):
# Check the wallet we tried to migrate is still BDB
self.assert_is_bdb("failed")
- def test_failed_migration_cleanup_relative_path(self):
- self.log.info("Test that a failed migration with a relative path is cleaned up")
-
- # Get the nearest common path of both nodes' wallet paths.
- common_parent = os.path.commonpath([self.master_node.wallets_path, self.old_node.wallets_path])
-
- # This test assumes that the relative path from each wallet directory to the common path is identical.
- assert_equal(os.path.relpath(common_parent, start=self.master_node.wallets_path), os.path.relpath(common_parent, start=self.old_node.wallets_path))
-
- wallet_name = "relativefailure"
- absolute_path = os.path.abspath(os.path.join(common_parent, wallet_name))
- relative_name = os.path.relpath(absolute_path, start=self.master_node.wallets_path)
-
- wallet = self.create_legacy_wallet(relative_name)
-
- # Make a copy of the wallet with the solvables wallet name so that we are unable
- # to create the solvables wallet when migrating, thus failing to migrate
- wallet.unloadwallet()
- solvables_path = os.path.join(common_parent, f"{wallet_name}_solvables")
-
- shutil.copytree(self.old_node.wallets_path / relative_name, solvables_path)
- original_shasum = sha256sum_file(os.path.join(solvables_path, "wallet.dat"))
-
- self.old_node.loadwallet(relative_name)
-
- # Add a multisig so that a solvables wallet is created
- wallet.addmultisigaddress(2, [wallet.getnewaddress(), get_generate_key().pubkey])
- wallet.importaddress(get_generate_key().p2pkh_addr)
-
- self.old_node.unloadwallet(relative_name)
- assert_raises_rpc_error(-4, "Failed to create database", self.master_node.migratewallet, relative_name)
-
- assert all(wallet not in self.master_node.listwallets() for wallet in [f"{wallet_name}", f"{wallet_name}_watchonly", f"{wallet_name}_solvables"])
-
- assert not (self.master_node.wallets_path / f"{wallet_name}_watchonly").exists()
- # Since the file in failed_solvables is one that we put there, migration shouldn't touch it
- assert os.path.exists(solvables_path)
- new_shasum = sha256sum_file(os.path.join(solvables_path , "wallet.dat"))
- assert_equal(original_shasum, new_shasum)
-
- # Check the wallet we tried to migrate is still BDB
- datfile = os.path.join(absolute_path, "wallet.dat")
- with open(datfile, "rb") as f:
- data = f.read(16)
- _, _, magic = struct.unpack("QII", data)
- assert_equal(magic, BTREE_MAGIC)
-
def test_blank(self):
self.log.info("Test that a blank wallet is migrated")
wallet = self.create_legacy_wallet("blank", blank=True)
@@ -1687,13 +1589,11 @@ class WalletMigrationTest(BitcoinTestFramework):
self.test_encrypted()
self.test_nonexistent()
self.test_unloaded_by_path()
- self.test_wallet_with_relative_path()
- self.test_wallet_with_path("path/to/mywallet/")
- self.test_wallet_with_path("path/that/ends/in/..")
+ self.test_wallet_with_path("path/to/trailing/")
+ self.test_wallet_with_path("path/to/mywallet")
migration_failure_cases = [
"",
- "../",
os.path.abspath(self.master_node.datadir_path / "absolute_path"),
"normallynamedwallet"
]
@@ -1708,7 +1608,6 @@ class WalletMigrationTest(BitcoinTestFramework):
self.test_conflict_txs()
self.test_hybrid_pubkey()
self.test_failed_migration_cleanup()
- self.test_failed_migration_cleanup_relative_path()
self.test_avoidreuse()
self.test_preserve_tx_extra_info()
self.test_blank()
diff --git a/test/functional/wallet_multiwallet.py b/test/functional/wallet_multiwallet.py
index 5b9a23b5..6240126c 100755
--- a/test/functional/wallet_multiwallet.py
+++ b/test/functional/wallet_multiwallet.py
@@ -87,6 +87,8 @@ class MultiWalletTest(BitcoinTestFramework):
assert_equal(node.listwalletdir(), {'wallets': [{'name': self.default_wallet_name, "warnings": []}]})
+ self.test_invalid_wallet_names()
+
# check wallet.dat is created
self.stop_nodes()
assert_equal(os.path.isfile(wallet_dir(node, self.default_wallet_name, self.wallet_data_filename)), True)
@@ -458,6 +460,21 @@ class MultiWalletTest(BitcoinTestFramework):
node.unloadwallet(wallet)
self.nodes[1].loadwallet(wallet)
+ def test_invalid_wallet_names(self):
+ self.log.info("Test weird paths are not allowed as wallet names")
+ NON_NORMALIZED = ["bad/./path", "bad/../path", "/bad/./path", "/bad/../path", "../", "./", "./wallet", "../wallets/../wallets/wallet"]
+ for name in NON_NORMALIZED:
+ assert_raises_rpc_error(-4, "Wallet name given as a path must be normalized", self.nodes[0].createwallet, name)
+
+ INVALID_RELPATH = ["../wallets/wallet", "..", "."]
+ for name in INVALID_RELPATH:
+ assert_raises_rpc_error(-4, "Wallet name given as a relative path cannot begin with ./ or ../", self.nodes[0].createwallet, name)
+
+ INVALID_ROOT = ["/"]
+ if platform.system() == "Windows":
+ INVALID_ROOT.extend(["C:\\", "C:"])
+ for name in INVALID_ROOT:
+ assert_raises_rpc_error(-4, "Wallet name cannot be the root path", self.nodes[0].createwallet, name)
if __name__ == '__main__':
MultiWalletTest(__file__).main()
Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.