diff --git a/web/pgadmin/utils/session.py b/web/pgadmin/utils/session.py index df9e83e7a..e4af1988d 100644 --- a/web/pgadmin/utils/session.py +++ b/web/pgadmin/utils/session.py @@ -68,6 +68,26 @@ def _compute_file_hmac(secret, body): return hmac.new(key, body, _FILE_HMAC).hexdigest().encode() +def _open_session_file(path): + """Open a session file for writing with mode 0o600 (owner-only). + + Session files contain OAuth access/refresh tokens, AWS/Google/Azure/ + BigAnimal cloud credentials, the Kerberos cache path, MFA OTP material, + and `pass_enc_key` (the symmetric KEK used to decrypt the user's saved + Postgres server passwords). The default `open(path, 'wb')` uses the + process umask, which on most systems leaves files world-readable + (0o644). Force 0o600 so that even if the directory permissions are + misconfigured (e.g., a volume mount in a container with shared uids), + individual session files remain owner-only. + """ + fd = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + return os.fdopen(fd, 'wb') + + sess_lock = Lock() LAST_CHECK_SESSION_FILES = None @@ -254,8 +274,8 @@ class FileBackedSessionManager(SessionManager): if fname is None: raise InternalServerError('Failed to create new session') - # touch the file - with open(fname, 'wb'): + # touch the file with mode 0o600 — see _open_session_file rationale. + with _open_session_file(fname): return ManagedSession(sid=sid) return ManagedSession(sid=sid) @@ -343,7 +363,7 @@ class FileBackedSessionManager(SessionManager): (session.randval, session.hmac_digest, dict(session)), -1 ) header = _compute_file_hmac(self.secret, body) - with open(fname, 'wb') as f: + with _open_session_file(fname) as f: f.write(header) f.write(body) diff --git a/web/pgadmin/utils/tests/test_session_file_format.py b/web/pgadmin/utils/tests/test_session_file_format.py index c0953096a..733d0040b 100644 --- a/web/pgadmin/utils/tests/test_session_file_format.py +++ b/web/pgadmin/utils/tests/test_session_file_format.py @@ -452,6 +452,48 @@ class TestRealisticSessionShapeRoundTrip(_SessionTestSetupMixin, BaseTestGenerat '/var/lib/pgadmin/storage/alice/') +class TestSessionFileMode0o600(_SessionTestSetupMixin, BaseTestGenerator): + """Session files must be created with mode 0o600 (owner-only). + + Session contents include OAuth access/refresh tokens, cloud + credentials (AWS/Google/Azure/BigAnimal), the Kerberos cache path, + MFA OTP material, and `pass_enc_key` (which decrypts the user's + saved Postgres server passwords). The default open(path, 'wb') uses + the process umask, which on most systems leaves files 0o644 + (world-readable). Verify both put() and new_session() create files + with mode 0o600. + """ + + scenarios = [('default', dict())] + + def runTest(self): + if os.name == 'nt': + self.skipTest("POSIX mode bits not meaningful on Windows") + + # put() path + sess = self.manager.new_session() + sess.sid = "60606060-6060-6060-6060-606060606060" + sess['k'] = 'v' + sess.sign(SECRET) + self.manager.put(sess) + put_path = os.path.join(self.tmpdir, sess.sid) + put_mode = os.stat(put_path).st_mode & 0o777 + self.assertEqual( + put_mode, 0o600, + "put() must create session file mode 0o600, got 0o%o" % put_mode) + + # new_session() touch path. Force a worktree where new_session + # actually writes the placeholder (default skip_paths is empty). + new_sess = self.manager.new_session() + new_path = os.path.join(self.tmpdir, new_sess.sid) + if os.path.exists(new_path): + new_mode = os.stat(new_path).st_mode & 0o777 + self.assertEqual( + new_mode, 0o600, + "new_session() touch must create file mode 0o600, " + "got 0o%o" % new_mode) + + class TestServerModeFalseDirectUpload(_SessionTestSetupMixin, BaseTestGenerator): """Spec T10: Scenario A chain closed at the session-read layer.