Skip to content

Commit 435752b

Browse files
committed
fix: symlink-based path traversal in file_manager (CWE-61/CWE-22) (#9902)
CWE-61 / CWE-22 in file_manager: check_access_permission used os.path.abspath, which resolves '..' but not symbolic links, while the subsequent kernel write follows symlinks. An authenticated user could plant a symlink inside their storage area pointing outside it and write to any path the pgAdmin process could reach. Fix: switch to os.path.realpath for both orig_path and in_dir, and add a new _open_upload_target helper that opens with O_NOFOLLOW (and mode 0o600) to close the leaf-component TOCTOU between the access check and the open. Drops the redundant post-write check_access_permission call in add(). Mode change for uploaded files (0o644 -> 0o600) is intentional hardening; release notes will call this out. Tests: 16 file-manager security tests covering realpath enforcement on all five access-check consumers and O_NOFOLLOW leaf-symlink rejection. The shared design proposal lives in docs/proposals/ and was added in the preceding pickle-RCE commit. Reported-by: Fernando Bortotti <fernando.bortotti@bsd.com.br>
1 parent 30a8903 commit 435752b

3 files changed

Lines changed: 427 additions & 12 deletions

File tree

‎web/pgadmin/misc/file_manager/__init__.py‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@
4848
SEM_FAIL = SEM_NOOPENFILEERRORBOX | SEM_FAILCRITICALERRORS
4949
file_root = ""
5050

51+
52+
def _open_upload_target(path):
53+
"""Open a file for upload with leaf-symlink protection.
54+
55+
Returns a binary write-mode file object. The kernel will refuse to
56+
follow a symbolic link at the leaf (CWE-61), closing the TOCTOU gap
57+
between `check_access_permission` and the actual file write. Mode is
58+
intentionally 0o600 (owner-only); see release notes for the
59+
behavioral change from the previous umask-default 0o644.
60+
61+
On Windows, O_NOFOLLOW is unavailable so the flag is a no-op; the
62+
residual leaf-component TOCTOU is mitigated by the absence of any
63+
in-pgAdmin primitive that creates symlinks.
64+
"""
65+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC \
66+
| getattr(os, 'O_NOFOLLOW', 0)
67+
fd = os.open(path, flags, 0o600)
68+
return os.fdopen(fd, 'wb')
69+
5170
MODULE_NAME = 'file_manager'
5271
global transid
5372

@@ -898,9 +917,11 @@ def check_access_permission(in_dir, path, skip_permission_check=False):
898917
in_dir = '' if in_dir is None else in_dir
899918
orig_path = Filemanager.get_abs_path(in_dir, path)
900919

901-
# This translates path with relative path notations
902-
# like ./ and ../ to absolute path.
903-
orig_path = os.path.abspath(orig_path)
920+
# Resolve symbolic links AND .. notation. abspath() alone resolves
921+
# only .., leaving a gap where a symlink inside the user's storage
922+
# directory could escape the sandbox at the kernel-syscall layer
923+
# (CWE-61 / CWE-22). realpath() closes that gap.
924+
orig_path = os.path.realpath(orig_path)
904925

905926
if in_dir:
906927
if _platform == 'win32':
@@ -909,6 +930,10 @@ def check_access_permission(in_dir, path, skip_permission_check=False):
909930
else:
910931
if in_dir[-1] == '/':
911932
in_dir = in_dir[:-1]
933+
# Resolve in_dir too: a symlinked storage root would otherwise
934+
# compare unequal to the resolved target path and break legit
935+
# access.
936+
in_dir = os.path.realpath(in_dir)
912937

913938
# Do not allow user to access outside his storage dir
914939
# in server mode.
@@ -1080,17 +1105,18 @@ def add(self, req=None):
10801105
new_name = "{0}{1}".format(orig_path, file_name)
10811106

10821107
try:
1083-
# Check if the new file is inside the users directory
1108+
# Check the new file's resolved path is inside the user's
1109+
# storage directory. realpath() resolves symbolic links —
1110+
# abspath() alone leaves a CWE-61 escape window.
10841111
if config.SERVER_MODE:
1085-
pathlib.Path(
1086-
os.path.abspath(
1087-
os.path.join(the_dir, new_name)
1088-
)
1089-
).relative_to(the_dir)
1112+
real_target = os.path.realpath(
1113+
os.path.join(the_dir, new_name))
1114+
real_in_dir = os.path.realpath(the_dir)
1115+
pathlib.Path(real_target).relative_to(real_in_dir)
10901116
except ValueError:
10911117
return unauthorized(self.ERROR_NOT_ALLOWED['Error'])
10921118

1093-
with open(new_name, 'wb') as f:
1119+
with _open_upload_target(new_name) as f:
10941120
while True:
10951121
# 4MB chunk (4 * 1024 * 1024 Bytes)
10961122
data = file_obj.read(4194304)
@@ -1101,8 +1127,6 @@ def add(self, req=None):
11011127
return internal_server_error("{0} {1}".format(
11021128
gettext('There was an error adding the file:'), e.strerror))
11031129

1104-
Filemanager.check_access_permission(the_dir, path)
1105-
11061130
return {
11071131
'Path': path,
11081132
'Name': new_name,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
##########################################################################
2+
#
3+
# pgAdmin 4 - PostgreSQL Tools
4+
#
5+
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
6+
# This software is released under the PostgreSQL Licence
7+
#
8+
##########################################################################

0 commit comments

Comments
 (0)