Skip to content

Commit 0fd0ac3

Browse files
committed
fix: add SharedServer columns for feature parity and harden write guards
Add passexec_cmd, passexec_expiration, kerberos_conn, tags, and post_connection_sql columns to SharedServer so non-owners can have per-user customizations overlaid from their own record instead of inheriting (or suppressing) the owner's values. Security hardening: - Non-owners blocked from setting passexec_cmd, passexec_expiration, post_connection_sql, db_res, db_res_type via _owner_only_fields guard - Driver-level suppression retained for connection layer defense-in-depth - Re-suppression after manager.update() in connect() endpoint - SharedServer creation defaults dangerous fields to None/False Migration improvements: - Orphan cleanup: delete SharedServer rows referencing deleted Servers - Column-exists guard prevents failures on migration re-runs - Unique constraint idempotency guard via get_unique_constraints() Other fixes: - Null guard in get_shared_server_properties() for None sharedserver - Unit tests updated for overlay semantics with full field coverage
1 parent 9a76ed8 commit 0fd0ac3

File tree

5 files changed

+234
-35
lines changed

5 files changed

+234
-35
lines changed

web/migrations/versions/add_user_id_to_debugger_func_args_.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ def upgrade():
120120
if inspector.has_table(table_name):
121121
op.execute(stmt)
122122

123-
# --- Unique constraint on SharedServer(osid, user_id) ---
124-
# Prevents duplicate SharedServer records from TOCTOU race.
125-
# First remove duplicates (keep lowest id per osid+user_id).
123+
# --- SharedServer cleanup and constraints ---
126124
if inspector.has_table('sharedserver'):
125+
# Clean up orphaned SharedServer records whose osid
126+
# references a Server that no longer exists.
127+
conn.execute(sa.text(
128+
"DELETE FROM sharedserver WHERE osid NOT IN "
129+
"(SELECT id FROM server)"
130+
))
131+
132+
# Deduplicate SharedServer records that would violate
133+
# the unique constraint. Keep the record with the
134+
# lowest id (oldest).
127135
if dialect == 'sqlite':
128136
op.execute(
129137
'DELETE FROM sharedserver WHERE id NOT IN '
@@ -137,11 +145,51 @@ def upgrade():
137145
'AND s1.user_id = s2.user_id '
138146
'AND s1.id > s2.id'
139147
)
140-
with op.batch_alter_table('sharedserver') as batch:
141-
batch.create_unique_constraint(
142-
'uq_sharedserver_osid_user',
143-
['osid', 'user_id']
144-
)
148+
149+
# Add missing columns to sharedserver (guard against
150+
# re-runs where columns may already exist).
151+
existing_cols = {
152+
c['name'] for c in inspector.get_columns('sharedserver')
153+
}
154+
new_columns = [
155+
('passexec_cmd',
156+
sa.Column('passexec_cmd', sa.Text(),
157+
nullable=True)),
158+
('passexec_expiration',
159+
sa.Column('passexec_expiration', sa.Integer(),
160+
nullable=True)),
161+
('kerberos_conn',
162+
sa.Column('kerberos_conn', sa.Boolean(),
163+
nullable=False,
164+
server_default='false')),
165+
('tags',
166+
sa.Column('tags', sa.JSON(), nullable=True)),
167+
('post_connection_sql',
168+
sa.Column('post_connection_sql', sa.String(),
169+
nullable=True)),
170+
]
171+
cols_to_add = [
172+
col for name, col in new_columns
173+
if name not in existing_cols
174+
]
175+
if cols_to_add:
176+
with op.batch_alter_table('sharedserver') as batch_op:
177+
for col in cols_to_add:
178+
batch_op.add_column(col)
179+
180+
# Unique constraint prevents duplicate SharedServer
181+
# records from TOCTOU race conditions.
182+
existing_ucs = {
183+
uc['name']
184+
for uc in inspector.get_unique_constraints(
185+
'sharedserver')
186+
}
187+
if 'uq_sharedserver_osid_user' not in existing_ucs:
188+
with op.batch_alter_table('sharedserver') as batch:
189+
batch.create_unique_constraint(
190+
'uq_sharedserver_osid_user',
191+
['osid', 'user_id']
192+
)
145193

146194

147195
def downgrade():

web/pgadmin/browser/server_groups/servers/__init__.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,17 @@ def get_shared_server_properties(server, sharedserver):
172172
Return shared server properties.
173173
174174
Overlays per-user SharedServer values onto the owner's Server
175-
object. Security-sensitive fields that are absent from the
176-
SharedServer model (passexec_cmd, post_connection_sql) are
177-
suppressed for non-owners.
175+
object so each non-owner sees their own customizations.
178176
179177
The server is expunged from the SQLAlchemy session before
180178
mutation so that the owner's record is never dirtied.
181179
:param server:
182180
:param sharedserver:
183181
:return: shared server (detached)
184182
"""
183+
if sharedserver is None:
184+
return server
185+
185186
# Detach from session so in-place mutations are never
186187
# flushed back to the owner's Server row.
187188
sess = object_session(server)
@@ -224,13 +225,11 @@ def get_shared_server_properties(server, sharedserver):
224225
server.server_owner = sharedserver.server_owner
225226
server.password = sharedserver.password
226227
server.prepare_threshold = sharedserver.prepare_threshold
227-
228-
# Suppress owner-only fields that are absent from SharedServer
229-
# and dangerous when inherited (privilege escalation / code
230-
# execution).
231-
server.passexec_cmd = None
232-
server.passexec_expiration = None
233-
server.post_connection_sql = None
228+
server.passexec_cmd = sharedserver.passexec_cmd
229+
server.passexec_expiration = sharedserver.passexec_expiration
230+
server.kerberos_conn = sharedserver.kerberos_conn
231+
server.tags = sharedserver.tags
232+
server.post_connection_sql = sharedserver.post_connection_sql
234233

235234
return server
236235

@@ -477,7 +476,12 @@ def create_shared_server(data, gid):
477476
tunnel_prompt_password=0,
478477
shared=True,
479478
connection_params=safe_conn_params,
480-
prepare_threshold=data.prepare_threshold
479+
prepare_threshold=data.prepare_threshold,
480+
passexec_cmd=None,
481+
passexec_expiration=None,
482+
kerberos_conn=False,
483+
tags=None,
484+
post_connection_sql=None
481485
)
482486
db.session.add(shared_server)
483487
db.session.commit()
@@ -998,8 +1002,21 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server,
9981002
if not crypt_key_present:
9991003
raise CryptKeyMissing
10001004

1005+
# Fields that non-owners must never set on their
1006+
# SharedServer — they enable command/SQL execution
1007+
# or are owner-level concepts not on SharedServer.
1008+
_owner_only_fields = frozenset({
1009+
'passexec_cmd', 'passexec_expiration',
1010+
'post_connection_sql',
1011+
'db_res', 'db_res_type',
1012+
})
1013+
10011014
for arg in config_param_map:
10021015
if arg in data:
1016+
# Non-owners cannot set dangerous fields.
1017+
if _is_non_owner(server) and \
1018+
arg in _owner_only_fields:
1019+
continue
10031020
value = data[arg]
10041021
if arg == 'password':
10051022
value = encrypt(data[arg], crypt_key)
@@ -1161,12 +1178,10 @@ def properties(self, gid, sid):
11611178
'db_res_type': server.db_res_type,
11621179
'passexec_cmd':
11631180
server.passexec_cmd
1164-
if server.passexec_cmd and
1165-
not _is_non_owner(server) else None,
1181+
if server.passexec_cmd else None,
11661182
'passexec_expiration':
11671183
server.passexec_expiration
1168-
if server.passexec_expiration and
1169-
not _is_non_owner(server) else None,
1184+
if server.passexec_expiration else None,
11701185
'service': server.service if server.service else None,
11711186
'use_ssh_tunnel': use_ssh_tunnel,
11721187
'tunnel_host': tunnel_host,
@@ -1186,8 +1201,7 @@ def properties(self, gid, sid):
11861201
'connection_string': display_connection_str,
11871202
'prepare_threshold': server.prepare_threshold,
11881203
'tags': tags,
1189-
'post_connection_sql': server.post_connection_sql
1190-
if not _is_non_owner(server) else None,
1204+
'post_connection_sql': server.post_connection_sql,
11911205
}
11921206

11931207
return ajax_response(response)
@@ -1605,6 +1619,13 @@ def connect(self, gid, sid, is_qt=False, server=None):
16051619
# the API call is not made from SQL Editor or View/Edit Data tool
16061620
if not manager.connection().connected() and not is_qt:
16071621
manager.update(server)
1622+
# Re-suppress owner-only fields after update() which
1623+
# rebuilds them from the (overlaid) server object.
1624+
# Belt-and-suspenders: the overlay already defaults
1625+
# these to None, but this guards against direct DB edits.
1626+
if _is_non_owner(server):
1627+
manager.passexec = None
1628+
manager.post_connection_sql = None
16081629
conn = manager.connection()
16091630

16101631
# Get enc key

web/pgadmin/browser/server_groups/servers/tests/test_shared_server_unit.py

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ def _make_shared_server(**overrides):
8484
'sslcert': '/home/nonowner/.ssl/cert.pem',
8585
'connect_timeout': '10',
8686
},
87+
passexec_cmd=None,
88+
passexec_expiration=None,
89+
kerberos_conn=False,
90+
tags=None,
91+
post_connection_sql=None,
8792
)
8893
defaults.update(overrides)
8994
ss = MagicMock()
@@ -97,10 +102,12 @@ class TestGetSharedServerProperties(BaseTestGenerator):
97102
using mock objects."""
98103

99104
scenarios = [
100-
('Merge suppresses passexec_cmd',
101-
dict(test_method='test_suppresses_passexec')),
102-
('Merge suppresses post_connection_sql',
103-
dict(test_method='test_suppresses_post_sql')),
105+
('Merge overlays passexec_cmd from SharedServer',
106+
dict(test_method='test_overlays_passexec')),
107+
('Merge overlays post_connection_sql from SharedServer',
108+
dict(test_method='test_overlays_post_sql')),
109+
('Merge overlays kerberos_conn and tags',
110+
dict(test_method='test_overlays_kerberos_tags')),
104111
('Merge strips owner SSL paths not in SharedServer',
105112
dict(test_method='test_strips_owner_ssl_paths')),
106113
('Merge applies SharedServer SSL paths',
@@ -111,6 +118,8 @@ class TestGetSharedServerProperties(BaseTestGenerator):
111118
dict(test_method='test_overrides_tunnel')),
112119
('Merge handles None connection_params',
113120
dict(test_method='test_none_conn_params')),
121+
('Merge returns server unchanged when sharedserver is None',
122+
dict(test_method='test_null_guard')),
114123
]
115124

116125
@patch('pgadmin.browser.server_groups.servers.'
@@ -128,14 +137,41 @@ def _merge(self, server=None, ss=None):
128137
return ServerModule.get_shared_server_properties(
129138
server, ss)
130139

131-
def test_suppresses_passexec(self):
140+
def test_overlays_passexec(self):
141+
# SharedServer defaults have None - overlay copies that.
132142
result = self._merge()
133143
self.assertIsNone(result.passexec_cmd)
134144
self.assertIsNone(result.passexec_expiration)
135-
136-
def test_suppresses_post_sql(self):
145+
# If SharedServer has a value, it should appear.
146+
ss = _make_shared_server(
147+
passexec_cmd='/usr/bin/get-pw',
148+
passexec_expiration=120)
149+
result = self._merge(ss=ss)
150+
self.assertEqual(result.passexec_cmd, '/usr/bin/get-pw')
151+
self.assertEqual(result.passexec_expiration, 120)
152+
153+
def test_overlays_post_sql(self):
154+
# SharedServer defaults have None - overlay copies that.
137155
result = self._merge()
138156
self.assertIsNone(result.post_connection_sql)
157+
# If SharedServer has a value, it should appear.
158+
ss = _make_shared_server(
159+
post_connection_sql='SET role reader;')
160+
result = self._merge(ss=ss)
161+
self.assertEqual(
162+
result.post_connection_sql, 'SET role reader;')
163+
164+
def test_overlays_kerberos_tags(self):
165+
result = self._merge()
166+
self.assertFalse(result.kerberos_conn)
167+
self.assertIsNone(result.tags)
168+
# With values set on SharedServer
169+
ss = _make_shared_server(
170+
kerberos_conn=True,
171+
tags=[{'text': 'prod', 'color': '#f00'}])
172+
result = self._merge(ss=ss)
173+
self.assertTrue(result.kerberos_conn)
174+
self.assertEqual(len(result.tags), 1)
139175

140176
def test_strips_owner_ssl_paths(self):
141177
result = self._merge()
@@ -180,6 +216,18 @@ def test_none_conn_params(self):
180216
# Should not crash; connection_params becomes {}
181217
self.assertEqual(result.connection_params, {})
182218

219+
def test_null_guard(self):
220+
from pgadmin.browser.server_groups.servers import \
221+
ServerModule
222+
server = _make_server()
223+
# Call directly to bypass _merge's None replacement
224+
result = ServerModule.get_shared_server_properties(
225+
server, None)
226+
# Should return server unchanged
227+
self.assertEqual(result.name, 'OwnerServer')
228+
self.assertEqual(result.passexec_cmd,
229+
'/usr/bin/vault-get-secret')
230+
183231

184232
class TestCreateSharedServerSanitization(BaseTestGenerator):
185233
"""Verify create_shared_server() strips sensitive
@@ -291,6 +339,7 @@ def test_no_session(self):
291339
# Should not crash
292340
result = ServerModule.get_shared_server_properties(
293341
server, ss)
342+
# SharedServer defaults passexec_cmd to None
294343
self.assertIsNone(result.passexec_cmd)
295344

296345

@@ -457,6 +506,79 @@ def test_owner_deletes(self, mock_cu, mock_ck):
457506
1, server.id)
458507

459508

509+
class TestOwnerOnlyFieldsGuard(BaseTestGenerator):
510+
"""Verify _set_valid_attr_value skips owner-only fields
511+
for non-owners."""
512+
513+
scenarios = [
514+
('Non-owner cannot set passexec_cmd',
515+
dict(test_method='test_nonowner_passexec_blocked')),
516+
('Owner can set passexec_cmd',
517+
dict(test_method='test_owner_passexec_allowed')),
518+
]
519+
520+
def runTest(self):
521+
getattr(self, self.test_method)()
522+
523+
@patch(SRV_MODULE + '.get_crypt_key',
524+
return_value=(True, b'key'))
525+
@patch(SRV_MODULE + '.current_user')
526+
def test_nonowner_passexec_blocked(self, mock_cu, mock_ck):
527+
mock_cu.id = 200 # Non-owner
528+
from pgadmin.browser.server_groups.servers import \
529+
ServerNode
530+
531+
server = _make_server()
532+
ss = _make_shared_server()
533+
node = ServerNode.__new__(ServerNode)
534+
node.delete_shared_server = MagicMock()
535+
536+
data = {
537+
'passexec_cmd': '/evil/cmd',
538+
'post_connection_sql': 'DROP TABLE x;',
539+
}
540+
config_map = {
541+
'passexec_cmd': 'passexec_cmd',
542+
'post_connection_sql': 'post_connection_sql',
543+
}
544+
545+
node._set_valid_attr_value(
546+
1, data, config_map, server, ss)
547+
548+
# SharedServer should NOT have these set
549+
self.assertIsNone(ss.passexec_cmd)
550+
self.assertIsNone(ss.post_connection_sql)
551+
552+
@patch(SRV_MODULE + '.get_crypt_key',
553+
return_value=(True, b'key'))
554+
@patch(SRV_MODULE + '.current_user')
555+
def test_owner_passexec_allowed(self, mock_cu, mock_ck):
556+
mock_cu.id = 100 # Owner
557+
from pgadmin.browser.server_groups.servers import \
558+
ServerNode
559+
560+
server = _make_server()
561+
node = ServerNode.__new__(ServerNode)
562+
node.delete_shared_server = MagicMock()
563+
564+
data = {
565+
'passexec_cmd': '/usr/bin/new-cmd',
566+
'post_connection_sql': 'SET role dba;',
567+
}
568+
config_map = {
569+
'passexec_cmd': 'passexec_cmd',
570+
'post_connection_sql': 'post_connection_sql',
571+
}
572+
573+
node._set_valid_attr_value(
574+
1, data, config_map, server, None)
575+
576+
# Owner should have these set
577+
self.assertEqual(server.passexec_cmd, '/usr/bin/new-cmd')
578+
self.assertEqual(
579+
server.post_connection_sql, 'SET role dba;')
580+
581+
460582
class TestGetSharedServerRaisesOnNone(BaseTestGenerator):
461583
"""Verify get_shared_server() raises if SharedServer
462584
cannot be created."""

web/pgadmin/model/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,13 @@ class SharedServer(db.Model, UserScopedMixin):
560560
shared = db.Column(db.Boolean(), nullable=False)
561561
connection_params = db.Column(MutableDict.as_mutable(types.JSON))
562562
prepare_threshold = db.Column(db.Integer(), nullable=True)
563+
passexec_cmd = db.Column(db.Text(), nullable=True)
564+
passexec_expiration = db.Column(db.Integer(), nullable=True)
565+
kerberos_conn = db.Column(
566+
db.Boolean(), nullable=False, default=0
567+
)
568+
tags = db.Column(types.JSON)
569+
post_connection_sql = db.Column(db.String(), nullable=True)
563570

564571

565572
class Macros(db.Model):

0 commit comments

Comments
 (0)