Skip to content

Commit 21aa05c

Browse files
authored
fix: do not leak pg password to command line (#560)
1 parent 3cb81eb commit 21aa05c

File tree

3 files changed

+51
-25
lines changed

3 files changed

+51
-25
lines changed

dbbackup/db/postgresql.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,18 @@
77
logger = logging.getLogger("dbbackup.command")
88

99

10-
def create_postgres_uri(self):
10+
def create_postgres_dbname_and_env(self):
1111
host = self.settings.get("HOST", "localhost")
1212
dbname = self.settings.get("NAME", "")
1313
user = quote(self.settings.get("USER") or "")
14-
password = self.settings.get("PASSWORD", "")
15-
password = f":{quote(password)}" if password else ""
16-
if not user:
17-
password = ""
18-
else:
14+
if user:
1915
host = "@" + host
20-
2116
port = ":{}".format(self.settings.get("PORT")) if self.settings.get("PORT") else ""
22-
dbname = f"--dbname=postgresql://{user}{password}{host}{port}/{dbname}"
23-
return dbname
17+
dbname = f"--dbname=postgresql://{user}{host}{port}/{dbname}"
18+
env = {}
19+
if self.settings.get("PASSWORD"):
20+
env["PGPASSWORD"] = self.settings.get("PASSWORD")
21+
return dbname, env
2422

2523

2624
class PgDumpConnector(BaseCommandDBConnector):
@@ -38,7 +36,8 @@ class PgDumpConnector(BaseCommandDBConnector):
3836

3937
def _create_dump(self):
4038
cmd = f"{self.dump_cmd} "
41-
cmd = cmd + create_postgres_uri(self)
39+
dbname, pg_env = create_postgres_dbname_and_env(self)
40+
cmd = cmd + dbname
4241

4342
for table in self.exclude:
4443
cmd += f" --exclude-table-data={table}"
@@ -52,12 +51,13 @@ def _create_dump(self):
5251
cmd += " -n " + " -n ".join(self.schemas)
5352

5453
cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
55-
stdout, stderr = self.run_command(cmd, env=self.dump_env)
54+
stdout, stderr = self.run_command(cmd, env={**self.dump_env, **pg_env})
5655
return stdout
5756

5857
def _restore_dump(self, dump):
5958
cmd = f"{self.restore_cmd} "
60-
cmd = cmd + create_postgres_uri(self)
59+
dbname, pg_env = create_postgres_dbname_and_env(self)
60+
cmd = cmd + dbname
6161

6262
# without this, psql terminates with an exit value of 0 regardless of errors
6363
cmd += " --set ON_ERROR_STOP=on"
@@ -70,7 +70,9 @@ def _restore_dump(self, dump):
7070

7171
cmd += " {}".format(self.settings["NAME"])
7272
cmd = f"{self.restore_prefix} {cmd} {self.restore_suffix}"
73-
stdout, stderr = self.run_command(cmd, stdin=dump, env=self.restore_env)
73+
stdout, stderr = self.run_command(
74+
cmd, stdin=dump, env={**self.restore_env, **pg_env}
75+
)
7476
return stdout, stderr
7577

7678

@@ -117,7 +119,8 @@ class PgDumpBinaryConnector(PgDumpConnector):
117119

118120
def _create_dump(self):
119121
cmd = f"{self.dump_cmd} "
120-
cmd = cmd + create_postgres_uri(self)
122+
dbname, pg_env = create_postgres_dbname_and_env(self)
123+
cmd = cmd + dbname
121124

122125
cmd += " --format=custom"
123126
for table in self.exclude:
@@ -127,7 +130,7 @@ def _create_dump(self):
127130
cmd += " -n " + " -n ".join(self.schemas)
128131

129132
cmd = f"{self.dump_prefix} {cmd} {self.dump_suffix}"
130-
stdout, _ = self.run_command(cmd, env=self.dump_env)
133+
stdout, _ = self.run_command(cmd, env={**self.dump_env, **pg_env})
131134
return stdout
132135

133136
def _restore_dump(self, dump: str):
@@ -140,7 +143,7 @@ def _restore_dump(self, dump: str):
140143
Builds the command as a list.
141144
"""
142145

143-
dbname = create_postgres_uri(self)
146+
dbname, pg_env = create_postgres_dbname_and_env(self)
144147
cmd = []
145148

146149
# Flatten optional values
@@ -188,6 +191,8 @@ def _restore_dump(self, dump: str):
188191
)
189192

190193
cmd_str = " ".join(cmd)
191-
stdout, _ = self.run_command(cmd_str, stdin=dump, env=self.dump_env)
194+
stdout, _ = self.run_command(
195+
cmd_str, stdin=dump, env={**self.dump_env, **pg_env}
196+
)
192197

193198
return stdout

dbbackup/tests/test_connectors/test_postgresql.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,10 @@ def setUp(self):
2121
self.connector.settings["NAME"] = "dbname"
2222
self.connector.settings["HOST"] = "hostname"
2323

24-
def test_user_password_uses_special_characters(self, mock_dump_cmd):
25-
self.connector.settings["PASSWORD"] = "@!"
24+
def test_user_uses_special_characters(self, mock_dump_cmd):
2625
self.connector.settings["USER"] = "@"
27-
2826
self.connector.create_dump()
29-
30-
self.assertIn(
31-
"postgresql://%40:%40%21@hostname/dbname", mock_dump_cmd.call_args[0][0]
32-
)
27+
self.assertIn("postgresql://%40@hostname/dbname", mock_dump_cmd.call_args[0][0])
3328

3429
def test_create_dump(self, mock_dump_cmd):
3530
dump = self.connector.create_dump()
@@ -166,6 +161,17 @@ def test_restore_dump_schema(self, mock_dump_cmd, mock_restore_cmd):
166161
self.assertIn(" -n public", mock_restore_cmd.call_args[0][0])
167162
self.assertIn(" -n foo", mock_restore_cmd.call_args[0][0])
168163

164+
def test_create_dump_password_excluded(self, mock_dump_cmd):
165+
self.connector.settings["PASSWORD"] = "secret"
166+
self.connector.create_dump()
167+
self.assertNotIn("secret", mock_dump_cmd.call_args[0][0])
168+
169+
def test_restore_dump_password_excluded(self, mock_dump_cmd):
170+
self.connector.settings["PASSWORD"] = "secret"
171+
dump = self.connector.create_dump()
172+
self.connector.restore_dump(dump)
173+
self.assertNotIn("secret", mock_dump_cmd.call_args[0][0])
174+
169175

170176
@patch(
171177
"dbbackup.db.postgresql.PgDumpBinaryConnector.run_command",
@@ -289,6 +295,17 @@ def test_restore_dump_schema(self, mock_dump_cmd, mock_restore_cmd):
289295
self.assertIn(" -n public", mock_restore_cmd.call_args[0][0])
290296
self.assertIn(" -n foo", mock_restore_cmd.call_args[0][0])
291297

298+
def test_create_dump_password_excluded(self, mock_dump_cmd):
299+
self.connector.settings["PASSWORD"] = "secret"
300+
self.connector.create_dump()
301+
self.assertNotIn("secret", mock_dump_cmd.call_args[0][0])
302+
303+
def test_restore_dump_password_excluded(self, mock_dump_cmd):
304+
self.connector.settings["PASSWORD"] = "secret"
305+
dump = self.connector.create_dump()
306+
self.connector.restore_dump(dump)
307+
self.assertNotIn("secret", mock_dump_cmd.call_args[0][0])
308+
292309

293310
@patch(
294311
"dbbackup.db.postgresql.PgDumpGisConnector.run_command",
@@ -365,6 +382,8 @@ def test_run_command_with_password(self, mock_popen):
365382
connector.settings["PASSWORD"] = "foo"
366383
connector.create_dump()
367384
self.assertEqual(mock_popen.call_args[0][0][0], "pg_dump")
385+
self.assertNotIn("foo", " ".join(mock_popen.call_args[0][0]))
386+
self.assertEqual("foo", mock_popen.call_args[1]["env"]["PGPASSWORD"])
368387

369388
def test_run_command_with_password_and_other(self, mock_popen):
370389
connector = PgDumpConnector(env={"foo": "bar"})
@@ -374,3 +393,5 @@ def test_run_command_with_password_and_other(self, mock_popen):
374393
self.assertEqual(mock_popen.call_args[0][0][0], "pg_dump")
375394
self.assertIn("foo", mock_popen.call_args[1]["env"])
376395
self.assertEqual("bar", mock_popen.call_args[1]["env"]["foo"])
396+
self.assertNotIn("foo", " ".join(mock_popen.call_args[0][0]))
397+
self.assertEqual("foo", mock_popen.call_args[1]["env"]["PGPASSWORD"])

docs/changelog.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Changelog
44
Unreleased
55
----------
66

7-
* Nothing (yet)!
7+
* Use environment variable for PostgreSQL password to prevent password leakage in logs/emails
88

99
4.3.0 (2025-05-09)
1010
----------

0 commit comments

Comments
 (0)