Commit Graph
8148 Commits
Author SHA1 Message Date
Murtuza Zabuawala 142a55b864 Feat: Added a option to colorize panel/tab header based on server color #2431 (#9799) 2026-05-20 18:56:02 +05:30
Ashesh Vashi 3b801fc6ba chore(deps): bump transitive lockfile resolutions surfaced by dependabot (#9955)
Pure lockfile-only updates — no package.json changes. Dependabot
surfaced these as separate PRs because they sit below pgAdmin's
direct deps in the resolution tree, so the manifest-level bumps
applied in #9954 did not pull them along.

web/yarn.lock:
- @babel/plugin-transform-modules-systemjs 7.29.0 -> 7.29.4 (#9923)
- devalue                                  5.7.0  -> 5.8.1  (#9937)
- fast-uri                                 3.1.0  -> 3.1.2  (#9922)
- svelte                                   5.55.1 -> 5.55.8 (#9938)
  (5.55.8 supersedes the 5.55.7 dependabot was tracking when the PR
   opened; both are within the same ^5.0.0 range.)

runtime/yarn.lock:
- fast-uri                                 3.1.0  -> 3.1.2  (#9924)

All resolutions stay within their existing semver ranges declared by
the parent packages — no manifest constraints touched. Refreshed via
`yarn up -R <pkg>` in each workspace.
2026-05-20 17:22:35 +05:30
Ashesh Vashi aad2dfd725 chore: Apply non-breaking dependency updates from open dependabot PRs (#9954)
Python:
- requirements.txt: google-auth-oauthlib 1.3.1 -> 1.4.0
  (#9929 / #9931), gated so Python 3.9 stays on 1.3.1 (1.4.0
  requires python_version >= 3.10). Mirrors the existing
  boto3 1.42.*/1.43.* split.
- tools/requirements.txt: requests >=2.33.1 -> >=2.34.2 on
  python_version > '3.9' (#9943 / #9944).
- web/regression/requirements.txt: selenium 4.43.0 -> 4.44.0
  (#9946). The selenium pin already requires Python >=3.10 in
  master, so the bump introduces no new 3.9 gap.

JavaScript (web/package.json, web/yarn.lock):
- postcss 8.5.12 -> 8.5.14 (#9874 / #9889)
- @tanstack/react-query 5.100.5 -> 5.100.9 (#9878)
- ip-address 10.1.0 -> 10.1.1 (#9918)
- packageManager pin yarn@4.14.0 -> yarn@4.15.0 and regenerate
  yarn.lock at lockfile __metadata.version 10. CI runs yarn
  4.15.0 with hardened mode on public PRs and refuses to migrate
  the lockfile from version 9 (yarn 4.14.x) to 10; master passes
  today only because hardened mode is PR-only.

Electron runtime (runtime/package.json, runtime/yarn.lock):
- axios 1.16.0 -> 1.16.1 (#9948)
- eslint 10.3.0 -> 10.4.0 (#9947)

Skipped (genuine breaking changes, deferred to follow-up PRs):
- @mui/material 7 -> 9 (#9843)
- @mui/x-date-pickers 8 -> 9 (#9888)
- cryptography 47.0.* -> 48.0.* (#9926 / #9932)
- paramiko 3.5.1 -> 5.0.0 (#9927 / #9930)
- electron 41.5.0 -> 42.1.0 (#9945)

Verified in an isolated worktree:

  - jest:        140/0/0 suites, 824/0/0 tests
  - eslint:      clean (web + runtime, both silent)
  - pycodestyle: 0 violations project-wide

Each version was cross-checked against the corresponding
dependabot PR diff via `gh pr diff`. Each Python bump was
cross-checked against PyPI's requires_python so Python 3.9
support stays intact.
2026-05-20 14:53:04 +05:30
Dave Page af888027a4 fix(docker): ship libpq-oauth-18.so and libcurl for PostgreSQL 18 OAuth (#9952)
libpq 18 dlopens libpq-oauth-18.so (the SASL OAUTHBEARER flow plugin)
when connecting to a server with an `oauth` pg_hba.conf rule. The
container previously copied only libpq.so.5.18 from postgres:18-alpine
and omitted both the plugin and its libcurl runtime dependency, so
OAuth connections failed with "no OAuth flows are available (try
installing the libpq-oauth package)" before any token exchange could
begin.

Add libpq-oauth-18.so to the existing pg18-builder COPY (it sits next
to libpq.so.5.18 in /usr/local/lib in postgres:18-alpine) and install
the libcurl apk package so the plugin can dlopen libcurl.so.4 at
runtime.

Closes #9951
2026-05-20 12:39:42 +05:30
Ashesh Vashi 86a8b165ab fix(test): mock does_utility_exist in maintenance accept-valid tests
MaintenanceInputValidationAcceptsValidTest asserted batch_process_mock
was called, but the route short-circuits with success=0 (HTTP 200)
when does_utility_exist() returns a missing-binary error. On Windows
CI psql.exe is not at the path pgAdmin probes, so BatchProcess was
never reached and the five 'accepted' scenarios failed with
'AssertionError: False is not true'. Stub does_utility_exist to None
in this test so it focuses on input-validation acceptance, not on the
runtime PostgreSQL binary layout.
REL-9_15
2026-05-06 15:03:18 +05:30
Ashesh Vashi bdbca57332 docs: note Python 3.9 boto3 split in v9.15 release notes
Annotate the boto3 1.42 -> 1.43 dependency line to reflect that 1.43
requires Python >=3.10, so 3.9 stays on the 1.42.* series.
2026-05-06 12:46:58 +05:30
Ashesh Vashi dda6d0e3a8 fix(deps): pin boto3 to 1.42.* on Python 3.9
boto3 1.43.0 requires Python >=3.10, which breaks installs on Python
3.9. Add a python_version gate so 3.9 stays on the 1.42.x series (the
last to support 3.9) while newer interpreters track 1.43.*.
2026-05-06 12:43:59 +05:30
Ashesh Vashi 411d718fa7 docs: add v9.15 release notes to toctree
Sphinx ran with -W (warnings as errors) and failed both build-docs and
build-python-package CI jobs because release_notes_9_15.rst was not
referenced from any toctree.
2026-05-06 12:03:29 +05:30
Ashesh Vashi d17616eda8 docs: round out v9.15 release notes with stragglers
Adds entries for items that landed after the release notes were first
written:

  - Housekeeping: #9906 (Italian translation update, merged from
    origin/master).
  - Test-suite stability: 208541cc4 (ImportExportServersTestCase
    sys.executable + subprocess error surfacing).
  - Documentation (new subsection): 9923eefca (clarification that
    MAX_LOGIN_ATTEMPTS applies only to INTERNAL auth; LDAP / OAuth2 /
    Kerberos / Webserver brute-force protection is the upstream IdP's
    and reverse-proxy's responsibility).
  - Dependencies (new subsection): the cumulative non-breaking
    dependabot updates aggregated for v9.15, organized by Python /
    JavaScript (web/) / JavaScript (runtime/), listing the
    package-level diffs without commit-keyed framing. Covers both
    aggregating commits (d55ffe405 and 4330a688a) so a reader looking
    for "what versions did 9.15 ship with" finds it in one place.

Sphinx build remains clean for release_notes_9_15.rst.
2026-05-05 19:33:16 +05:30
Ashesh Vashi c6a6d46283 Updated version for release v9.15 2026-05-05 19:07:40 +05:30
Ashesh Vashi 4330a688aa chore: Apply non-breaking dependency updates from open dependabot PRs
Python (requirements.txt):
- boto3 1.42.* -> 1.43.* (#9908)
- psycopg 3.3.3 -> 3.3.4 (#9911) for python_version >= '3.10'

JavaScript (web/package.json, web/yarn.lock):
- axios 1.15.2 -> 1.16.0 (matches dependabot's #9907 in /runtime,
  applied to /web for cross-package consistency)

Electron runtime (runtime/package.json, runtime/yarn.lock):
- axios 1.15.2 -> 1.16.0 (#9907)
- electron 41.3.0 -> 41.5.0 (#9910)
- eslint 10.2.1 -> 10.3.0 (#9912)
- globals 17.5.0 -> 17.6.0 (#9909)
  follow-redirects 1.15.11 -> 1.16.0 transitively

Skipped (genuine breaking changes, deferred to a future minor):
- @mui/material 7 -> 9 (#9843)
- @mui/x-date-pickers 8 -> 9 (#9888)

Verified in an isolated worktree:

  - jest:                140/0/0 suites, 824/0/0 tests
  - eslint:              clean (silent)
  - pycodestyle:         0 violations project-wide
  - python regression:   1879/0/308 (PG18, --exclude feature_tests)

The axios 1.16.0 release notes call out three observable changes; only
the first is potentially relevant to pgAdmin and is a bugfix:

  - Fetch adapter now enforces maxBodyLength / maxContentLength (these
    were silently ignored on the fetch adapter before 1.16.0). pgAdmin
    does not set these limits, so behaviour is unchanged.
  - Proxy requests preserve user-supplied Host headers — pgAdmin does
    not proxy through axios.
  - Basic-auth credentials embedded in URLs are URL-decoded — pgAdmin
    does not construct credential-embedded URLs.

psycopg 3.3.4 brings three bugfixes: spurious connection-timeout in C
extension on long-uptime systems, client-side adaptation of enums whose
names need quoting, and consistent Cursor.statusmessage after
executemany().

electron 41.5.0 is a patch within the 41.x line carrying Chromium
security backports plus a Windows frameless-window resize regression
fix and a low-level mouse-hook teardown fix.
2026-05-05 18:47:55 +05:30
Ashesh Vashi 96e6528243 Merge remote-tracking branch 'origin/master' into cve-9.15 2026-05-05 16:22:33 +05:30
Ashesh Vashi ba9ff51aaa docs: assign CVE IDs to v9.15 release-notes placeholders
Replaces the "(CVE pending)" markers on the seven placeholder issues
with their assigned identifiers:

  #9830 -> CVE-2026-7813   (cross-user data access / shared-server escalation)
  #9865 -> CVE-2026-7814   (stored XSS via crafted PostgreSQL object names)
  #9898 -> CVE-2026-7815   (SQL injection in Maintenance tool option values)
  #9899 -> CVE-2026-7816   (OS command injection in Import/Export query export)
  #9900 -> CVE-2026-7817   (LFI/SSRF in LLM API configuration endpoints)
  #9901 -> CVE-2026-7818   (unsafe deserialization in session manager)
  #9902 -> CVE-2026-7819   (symlink path traversal in file manager)
  #9904 -> CVE-2026-7820   (account-lockout bypass via Flask-Security /login)

#9835 is a follow-up to #9830 and shares CVE-2026-7813, so its
parenthetical is dropped rather than replaced with a separate ID.

To be revealed publicly when this branch is pushed for the 9.15 release.
2026-05-05 16:14:02 +05:30
Domenico Sgarbossa 4eb899c92c Updated Italian translation for v9.15 (#9906) 2026-05-05 16:07:08 +05:30
Ashesh Vashi 6469b69565 style: fix pre-existing pycodestyle violations across CVE-9.15 test files
24 violations had accumulated on the cve-9.15 branch from the #9901 and
#9902 CVE-fix work and a couple of older spots. Surfaced when the full
suite was run after the #9904 work; no functional change.

  - 22 x E501 (line too long > 79):
      - 15 in pgadmin/misc/file_manager/tests/test_filemanager_security.py
        (13 class declarations using two mixin parents,
        2 docstrings)
      - 6  in pgadmin/utils/tests/test_session_file_format.py
        (5 class declarations, 1 docstring)
      - 1  in pgadmin/browser/tests/test_kerberos_with_mocking.py
        (extracted self.app.url_map._rules_by_endpoint into a local
        before the membership test)
  - 2  x E305 (expected 2 blank lines after class/function):
      - pgadmin/misc/file_manager/__init__.py (after _open_upload_target)
      - pgadmin/browser/__init__.py (after _first_form_error)

Class declarations are wrapped via parenthesised continuation, the
standard pgAdmin convention; docstrings are either shortened or wrapped
across two lines preserving the same meaning. Verified:

  - pycodestyle clean project-wide (24 -> 0).
  - Affected tests still pass: test_filemanager_security 17/0/0,
    test_session_file_format 18/0/0, test_kerberos_with_mocking 2/0/3
    (skips are pre-existing and unrelated -- Kerberos blueprint not
    loaded in default config).
2026-05-05 15:03:00 +05:30
Ashesh Vashi 208541cc48 fix: ImportExportServersTestCase uses sys.executable + surfaces subprocess errors
Two latent bugs in pgadmin/setup/tests/test_export_import_servers.py
caused this test to fail on macOS / modern Linux distros:

1. The test invoked the subprocesses with a hardcoded "python" via
   os.system. macOS and many modern Linux distros do not provide a
   "python" alias — only "python3" or a venv-specific binary. The
   subprocess fails with "sh: python: command not found", exits non-
   zero, and writes nothing to the dump-servers output file.

   Same root cause that a50a553b0 ("chore: feature tests use
   sys.executable") fixed for AppStarter; this is the same fix in the
   regression test.

2. Both os.system calls redirected stderr to /dev/null. When the
   subprocess failed the empty output file then tripped json.loads
   with the misleading "Expecting value: line 1 column 1 (char 0)"
   instead of surfacing the underlying "command not found" error.

Switch to subprocess.run with a list (so there is no shell quoting and
no command-injection surface), use sys.executable, capture stderr, and
self.fail() with the captured stderr if the subprocess exits non-zero.

Verified: setup.tests goes from 4 pass / 1 fail / 1 skip to 5 pass / 0
fail / 1 skip on PG18.
2026-05-05 15:03:00 +05:30
Ashesh Vashi 9923eefcab docs: clarify scope of MAX_LOGIN_ATTEMPTS and document brute-force protection for LDAP
The "Avoiding a bruteforce attack" section in login.rst implied that
MAX_LOGIN_ATTEMPTS protected all logins; in fact it only applies to the
INTERNAL authentication source (the /authenticate/login view filters by
auth_source=INTERNAL). Operators using LDAP / OAUTH2 / KERBEROS /
WEBSERVER got no signal that brute-force protection lives at a different
layer.

Adds:

  - login.rst: the bruteforce section now states that MAX_LOGIN_ATTEMPTS
    is INTERNAL-only and points operators of external sources to the
    upstream identity provider's lockout policy and to the reverse proxy
    for IP-based throttling.
  - ldap.rst: a new "Brute-force protection" section calling out that
    LDAP credential lockout is the directory's responsibility (ppolicy /
    AD account-lockout GPO) and that request rate-limiting belongs on
    the proxy. Cross-references the login-page section.

No code change. Closes the documentation gap that surfaced during the
analysis of the #9904 lockout-bypass fix without inheriting the
maintenance burden of duplicating directory lockout in pgAdmin.
2026-05-05 15:03:00 +05:30
Ashesh Vashi 32d194691e docs: add release-notes line for #9904 lockout-bypass fix
Mirrors the CVE-pending entries for #9898-#9902 with reporter credit.
The detailed CVE record (CVSS, description, affected files) is held in
the local docs/CVEs/ working draft and submitted to MITRE/Vulnogram
externally; only the release-notes summary lands in git pre-disclosure.
2026-05-05 15:03:00 +05:30
Ashesh Vashi d336c1e786 fix: enforce account lockout on Flask-Security's /login endpoint (#9904)
pgAdmin enforces MAX_LOGIN_ATTEMPTS only inside its own /authenticate/login
view. Flask-Security's default /login view (registered automatically by
security.init_app) was reachable but never consulted the locked field:

  - User inherited Flask-Security's UserMixin.is_locked(), which always
    returns True (= "not locked, proceed").
  - User inherited Flask-Login's is_active, which only checks the active
    column, not locked.

So an attacker with valid credentials could bypass a lockout by posting
to /login after triggering the counter at /authenticate/login.

Override both contracts on the User model so every auth path inherits
them:

  - is_active: False when active=False OR locked=True, so
    flask_security.login_user() refuses to mint a session.
  - is_locked(form_error): returns False (= "locked") and appends the
    same "Your account is locked" message /authenticate/login uses, so
    LoginForm.validate() rejects the submission cleanly.

Only INTERNAL accounts are reachable by the bypass (LDAP/OAuth2/Kerberos/
Webserver users have no local password, which LoginForm.validate rejects
before the locked check) and lockout itself is internal-only (auth_source
filter at authenticate/__init__.py:124), so the overrides only take
effect for INTERNAL accounts.

Adds a unit test covering the four (active, locked) combinations of the
contract LoginForm.validate() depends on.

Also includes a SQLite-only data-cleanup migration. Migration 6650c52670c2
added the locked column with server_default='false'. SQLite has no native
BOOLEAN affinity, so the literal 'false' was stored as TEXT both in the
column DEFAULT and on existing rows backfilled by ALTER TABLE.
SQLAlchemy's Boolean processor then reads that TEXT back as Python True
(because bool('false') is True for any non-empty string), which means
the new is_active override would otherwise see every legacy row as
locked and refuse login. The new migration normalize_locked_text_default
rewrites every SQLite row's locked to integer 0/1 (NULL -> 0); on a
PostgreSQL config DB the column was always stored as a proper BOOLEAN
so the migration is a no-op there (and integer literals would fail
on a BOOLEAN column anyway). SCHEMA_VERSION is bumped from 51 to 52.

Reported-by: Fernando Bortotti <fernando.bortotti@bsd.com.br>
2026-05-05 15:00:19 +05:30
Ashesh Vashi 7f230d05fa docs: add release notes for v9.15
Drafts the user-facing release notes for the 9.15 release. Covers all
47 non-merge commits since REL-9_14:

- 18 issue-linked entries under New features / Housekeeping / Bug fixes,
  with reporter credits (names only) for the six external CVE reports
- #9901 absorbs 14 follow-up commits (session encryption + 0o600,
  SHA-256 digest, drop of live AuthSourceManager / cloud provider
  instances, DATA_DIR perms, log file mode, log handler hardening,
  user_info_server prompt-loop bound) via "Also..."
- #9830 absorbs the @with_object_filters extension to ServerNode.list
- 14 commits without an associated GitHub issue listed under
  "Additional changes" (bug fixes / test stability / refactoring /
  housekeeping) for transparency

CVE IDs are placeholders ("CVE pending") and will be filled in once
MITRE assigns them. Release date and bundled-utility version are
also placeholders pending the actual release.
2026-05-02 12:59:54 +05:30
Ashesh Vashi fcb3db7f54 Merge remote-tracking branch 'origin/master' into cve-9.15 2026-05-02 12:12:20 +05:30
Ashesh Vashi 2f211dc04c Updated message catalogs for v9.15 2026-05-02 12:11:32 +05:30
Ashesh Vashi a11d289bd9 test: harden click_modal backdrop wait and open_query_tool stale-element retry
click_modal: wait up to 5s for the MuiDialog-backdrop to become
invisible after clicking the modal button. MUI v7 leaves the backdrop
in the DOM during the ~300ms close animation, which intercepts the
next click in tests that chain modal->modal interactions.

open_query_tool: the execute-query toolbar button can re-render between
the visibility wait and the ActionChains.move_to_element call
(Firefox/geckodriver hits this regularly). Retry the move up to 3
times on StaleElementReferenceException, refetching the element each
attempt rather than reusing a stale handle.

Both changes are pure test-side stability fixes; no production code
or behavior is affected.
2026-05-02 12:10:51 +05:30
Ashesh Vashi a50a553b0a chore: feature tests use sys.executable; sync yarn.lock to package.json
1. AppStarter.start_app() spawned the pgAdmin subprocess with bare
   "python" via subprocess.Popen. macOS (and many modern Linux
   distros) do not provide a "python" symlink — only "python3" or a
   venv-specific binary. The result was an Errno 2 "No such file or
   directory" the moment a feature test tried to launch pgAdmin.

   Switch to sys.executable so the spawned pgAdmin uses the same
   interpreter and venv as the test runner. Works regardless of how
   the venv is named or whether "python" is on PATH.

2. yarn.lock churn from `yarn install` resolving the @tanstack /
   moment-timezone / postcss peer-dep ranges. Snapshot the resolved
   versions for reproducibility (post the dependency bumps in
   d55ffe405).
2026-05-02 11:02:30 +05:30
Ashesh Vashi e7eaef0dc0 Merge remote-tracking branch 'origin/master' into cve-9.15 2026-05-02 11:00:24 +05:30
Ashesh Vashi 849c679fc6 Updated message catalog post merging few translations 2026-05-02 10:59:22 +05:30
rx500 f43c781e98 Update messages.po (es) (#9893)
Word translation corrected.
2026-05-02 10:56:59 +05:30
Daniel Nylander 119a99375d Update Swedish translation. (#9764)
- Database terminology corrections (bord → tabell, Visa → Vy, etc.)
- Standardize Tabellrymd, Sammansatt utlösare
- Fix punctuation in placeholder lists (Oxford-comma alignment)
- Drop incorrect fuzzy flag from .po header

Note: msgid "on" (from ai_tools.js) is missing here because the PR's
.po was regenerated against a slightly older source tree; it will be
re-added on the next `make messages` run.
2026-05-02 10:53:37 +05:30
MichalBartos ff0fc9e7eb Fix translation for 'Refresh' in Czech (#9832) 2026-05-02 10:37:12 +05:30
Degit22 aec3ea117f Update Russian translation. (#9839) 2026-05-02 10:19:18 +05:30
Ashesh Vashi ddc371adf7 fix: bound user_info_server prompt loops + allow .local in deliverability test
Two related issues, both surfaced when running the regression suite
non-interactively:

1. user_info_server()'s while-not-validate retry loops had no upper
   bound. With a mocked or closed stdin (test mocks, EOF in CI/cron,
   typo'd PGADMIN_SETUP_EMAIL=''), the loop would call input()/pprompt()
   forever, printing 'Invalid email address. Please try again.' on
   every iteration. We saw this manifest as a 13.5 million-line
   25 GB log when test_no_email_deliverability hit the case.

   Cap each loop at MAX_PROMPT_ATTEMPTS=5 and raise RuntimeError with a
   pointer to PGADMIN_SETUP_EMAIL/PASSWORD env vars on exhaustion.

2. test_no_email_deliverability included pg@postgres.local in its
   "should be accepted with deliverability=False" data set. The .local
   suffix is in email_validator.SPECIAL_USE_DOMAIN_NAMES which fails
   syntactic validation regardless of the deliverability flag, so
   validate_email always rejected it - looping forever pre-fix #1.

   Set config.ALLOW_SPECIAL_EMAIL_DOMAINS = ['local'] in the test's
   try/finally block so .local is allowed for this scenario, restored
   afterward to avoid leaking state into other tests.

With both fixes in place, the test can be removed from the regression
runtests --exclude list.
2026-05-02 01:52:29 +05:30
Ashesh Vashi 0fad04de85 fix: PSQL socket tests use authenticated tester; role-deps test skips on auth failure
Two test-infra issues exposed by the full regression suite:

1. PSQL socket tests (test_backend_task, test_psql_input,
   test_resize_terminal, test_socket_disconnect, test_start_process)
   created a fresh, unauthenticated app.test_client() and built a
   socketio test client around it. The /pty connect handler is
   wrapped with @socket_login_required, so the unauthenticated client
   was rejected and is_connected('/pty') returned False.

   Switch to the authenticated self.tester from BaseTestGenerator so
   the connect handler accepts the connection. Matches the pattern
   already used by BaseSocketTestGenerator (test_socket_connect,
   test_psql_disabled).

2. test_role_dependencies_sql creates a temporary LOGIN role and
   calls create_table as that role. On clusters where pg_hba.conf
   does not allow arbitrary roles to connect from 127.0.0.1, the
   create_table swallows the connection error via try/except and
   the subsequent pg_class lookup returns no row, surfacing as an
   opaque "NoneType is not subscriptable" error.

   Detect the empty fetchone() and skipTest with a clear message
   pointing at pg_hba.conf so the test fails gracefully on
   environmentally-restricted clusters instead of erroring.
2026-05-02 01:39:00 +05:30
Ashesh Vashi 1f7194924f fix: harden 6 regression tests against environmental drift
Tests that worked on legacy CI envs but failed under newer
email_validator / non-postgres-named superuser / no-app-context
on tearDown / etc.:

1. test_import_export_create_job_unit_test.py - E-string scenario
   was labelled "Rejected: ... (false positive, safe)" expecting
   parser over-rejection. The parser actually correctly identifies
   the query as balanced (close paren is inside the literal). Update
   expected to True and rename scenario to reflect the real
   handling.

2. test_sg_data_isolation.py - tearDown does ORM query/delete after
   the @create_user_wise_test_client wrapper has popped the test
   client/context. Wrap tearDown's ORM ops in an explicit
   app_context().

3. test_validate_user_email.py - the "with deliverability" scenario
   used postgres@local.dev expecting rejection. Newer email_validator
   no longer rejects this. Switch to postgres@nonexistent.invalid,
   which is RFC-reserved and rejected regardless of DNS state. Also
   align expected message with the actual format string used by
   validate_user.

4-5. test_grant_wizard_save_permissions.py and
   test_grant_wizard_get_sql.py - test data hardcoded 'postgres' /
   'enterprisedb' as grantee/grantor. On clusters where the
   superuser is named differently (e.g. dev Homebrew installs use
   the local OS account), the GRANT statement fails with
   "role X does not exist". Use self.server['username'] instead.

6. test_role_dependencies_sql.py - the test creates a fresh LOGIN
   role and tries to CREATE TABLE as that role; without explicit
   CREATE-on-database grant, the create silently fails and the
   subsequent pg_class lookup returns no row. Mark the test role
   SUPERUSER so it can create the table.

All are test-infra fixes; none of the production code paths under
test were modified.
2026-05-02 01:29:01 +05:30
Ashesh Vashi d78a7cc3bf fix: ConnectionLocker session safety + batch_process tests need request context
Two related issues that surfaced in BatchProcessTest:

1. ConnectionLocker.__enter__ accessed Flask session (via 'in' check)
   AFTER acquiring the lock. When called outside a request context,
   session access raises RuntimeError. Python's 'with' semantics skip
   __exit__ if __enter__ raises, so the lock leaked - any subsequent
   call to ConnectionLocker hung forever waiting on a lock that
   would never be released.

   Wrap session access in try/except RuntimeError so missing-request-
   context falls through cleanly and the lock is released normally
   on with-block exit. Also use .get() chains so partial session
   shapes do not raise KeyError.

2. The four batch_process unit tests (backup, import_export,
   maintenance, restore) used app_context() instead of
   test_request_context(). flask-babel's gettext() in
   BackupMessage.details() and similar code paths require a request
   context; ConnectionLocker.__enter__ also touches session as above.
   Switching to test_request_context('/') gives both the request
   binding they need.

Verified: tools.backup.tests.test_batch_process now runs 4/4 passing
(was 3 ERROR + 1 hang before fix #1; 3 FAIL before fix #2).

Both are pre-existing issues exposed by Python 3.14 / flask 3.1 /
flask-babel stricter context enforcement; not introduced by 9.15
CVE work.
2026-05-02 01:10:33 +05:30
Ashesh Vashi 657529280e fix: guard session['auth_source_manager'] access in BatchProcess.start
processes.py:322 unconditionally indexed session['auth_source_manager']
to gate the KRB5CCNAME copy. When the Flask session does not have the
auth_source_manager key (test request contexts that bypass the login
flow, or sessions whose login hasn't populated this key yet), the
indexing raises KeyError, aborting BatchProcess.start() before it ever
spawns the subprocess. The error has no obvious traceback in the
runtests harness because it bubbles up through BackupCreateJob's mock
infrastructure - the test just records ERROR with no message.

Replace [...] with .get(...) chains so a missing auth_source_manager
yields None and the Kerberos branch is skipped, matching the original
intent.

Surfaced by BatchProcessTest "When backup server" / "When backup
globals" scenarios which mock current_user but leave session in its
default unauthenticated state. Same shape on master; the fix is a
one-line robustness improvement.
2026-05-02 00:54:48 +05:30
Ashesh Vashi d55ffe405b fix: Apply non-breaking dependency updates from open dependabot PRs
Python (requirements.txt, tools/, web/regression/):
- cryptography 46.0 -> 47.0 (move CFB8 import to decrepit module to
  silence the 47.0 deprecation warning and survive 49.0 removal)
- typer 0.24 -> 0.25 for python>3.9 (drop removed [all] extra)
- safety >=1.9.0 -> >=3.7.0 (CI audit tool)
- requests >=2.21.0 -> >=2.33.1
- testtools 2.8.7 -> 2.9.1
- pycodestyle >=2.5.0 -> >=2.14.0

JavaScript (web/package.json, web/yarn.lock):
- postcss 8.5.6 -> 8.5.12
- moment-timezone 0.6.0 -> 0.6.2
- @tanstack/react-query 5.90 -> 5.100.5

Skipped (genuine breaking changes): @mui/material 7->9 (#9843),
@mui/x-date-pickers 8->9 (#9888).
2026-05-02 00:18:47 +05:30
Ashesh Vashi 4eb184739a fix: replace 'e.message' with str(e) and add @with_object_filters to ServerNode.list
Two pre-existing master bugs surfaced by the data-isolation regression
tests:

1. 'e.message' on caught exceptions (13 sites). Exception.message was
   removed in Python 3 (it lived on BaseException in Py2). When the
   except handler runs, accessing e.message raises AttributeError,
   masking the original exception. Werkzeug HTTPException doesn't
   expose .message either - it uses .description / .__str__.

   Replace 'e.message' with str(e) across:
   - browser/server_groups/__init__.py (delete/update/create handlers)
   - browser/server_groups/servers/__init__.py (5 handlers)
   - browser/server_groups/servers/databases/__init__.py
   - misc/cloud/__init__.py
   - tools/debugger/__init__.py
   - tools/grant_wizard/__init__.py (2 sites)

   Sites guarded by hasattr(e, 'message') first (psycopg3 driver) are
   left as-is; module.messages dict access (utils/__init__) is also
   unrelated.

2. ServerNode.list() missing @with_object_filters decorator. PR #8917
   (99b822e47) added object_filters as a required positional arg to
   list() but only added the @with_object_filters decorator to
   get_nodes(), leaving list() with a signature the Flask routing
   couldn't satisfy. Surfaced by SharedServersGetTestCase 'Get a all
   shared server' test, which calls the list endpoint directly.
2026-05-01 23:57:57 +05:30
Ashesh Vashi d57acce354 fix: harden validation/preference/connection-params paths against pre-existing edge cases
Five small defensive fixes that were exposed by running the full
regression suite end-to-end:

1. utils/validation_utils.py: validate_email() now returns False
   instead of raising TypeError when passed a non-str/bytes value
   (e.g. None from a missing form field). Matches the wrapper's
   contract that it only ever returns bool.

2. tools/user_management/__init__.py: list endpoint guarded against
   users with no roles. u.roles[0].id -> u.roles[0].id if u.roles
   else None. Triggered by ChangePasswordTestCase fixtures.

3. utils/preferences.py: control_props['tags'] / ['creatable']
   replaced with .get(...) so preferences whose control_props omit
   these keys do not raise KeyError on update.

4. browser/server_groups/servers/__init__.py (create endpoint):
   convert_connection_parameter() is bidirectional (list<->dict).
   The save path always wants the storage shape (dict). When input
   is already a dict (internal callers / tests mimicking storage
   form), skip conversion to avoid the dict->list round-trip that
   breaks the MutableDict column. Same fix applied to the workspaces
   save path.

5. misc/workspaces/__init__.py: same defensive handling for
   convert_connection_parameter() on the save path.

These are all pre-existing master bugs surfaced by edge-case test
data; none are introduced by the 9.15 CVE work.
2026-05-01 23:29:43 +05:30
Ashesh Vashi dc61039e93 fix: quote username in views/mview test helper for dotted local roles
Same dot-username bug as 504775de8 / 9b29bc203 but in a different shape:
view_test_data.json query strings are Python expressions interpolating
server['username'] into ALTER TABLE ... OWNER TO and GRANT ... TO
clauses. views/tests/utils.py:create_view evaluates these expressions,
producing SQL with an unquoted identifier. A local PG role with a dot
(e.g. 'ashesh.vashi') was rejected with 'syntax error at or near "."'.

Substitute server['username'] with Driver.qtIdent(None, server['username'])
in the query template before evaluating, so the resulting SQL contains
the properly-quoted identifier. The substitution covers every
occurrence in the template (some queries have OWNER TO and GRANT TO in
the same string).

Accounts for the remaining 72 'syntax error at or near "."' cases that
504775de8 + 9b29bc203 did not reach.
2026-05-01 23:09:09 +05:30
Ashesh Vashi 9b29bc2033 fix: quote username in types/compound_triggers test helpers for dotted local roles
Two more test setUp helpers had the same dot-username bug as
user_mappings (504775de8): server['username'] was substituted into
the OWNER TO clause unquoted, so a local PG role with a dot (e.g.
'ashesh.vashi') was rejected with 'syntax error at or near "."'.

- types/tests/utils.py - 'ALTER TYPE ... OWNER TO %s' now uses
  Driver.qtIdent() for the username.
- compound_triggers/tests/utils.py - sql_query template substitution
  now passes the qtIdent-quoted username.

Same Driver.qtIdent(None, server['username']) approach as in 504775de8.
2026-05-01 23:00:48 +05:30
Ashesh Vashi 504775de80 fix: quote username in user_mappings test helper for dotted local roles
The setUp helper in user_mappings/tests/utils.py interpolated
server['username'] directly into the CREATE USER MAPPING DDL, so a
local PostgreSQL role containing a dot (e.g. 'ashesh.vashi') was
parsed by PG as a schema-qualified identifier and rejected with
'syntax error at or near "."'. This blocked every test whose setUp
created a user mapping (UserMappingGetSQLTestCase, etc.).

Wrap the FOR target with Driver.qtIdent() so the identifier is
double-quoted when it contains special characters. Mirrors the
approach used by the resql framework's _normalize_owner() / <OWNER>
substitution introduced in d112dc3b9 - that fix covered SQL
expectation comparison; this fix covers test setUp DDL generation,
which the resql logic does not reach.

The OPTIONS user/password are left as raw '%s' since they are SQL
string literals (single-quoted), where dots are syntactically safe.

Verified: 33/33 user_mappings tests pass; zero 'syntax error at or
near "."' occurrences in the targeted run.
2026-05-01 22:54:09 +05:30
Ashesh Vashi 044355c5e0 fix: harden EnhancedRotatingFileHandler._open and add regression tests
Found by aggressive review of 83abb1c4f:

1. fd leak on os.fdopen failure — os.open returns a raw fd; if
   os.fdopen() raises after, the fd is orphaned. Wrap in try/except
   and close on failure. Built-in open() doesn't have this issue
   because it manages the fd internally.

2. Silent regression of close-on-exec — Python's built-in open() sets
   fds non-inheritable by default since PEP 446 (3.4+). os.open()
   does not. Replacing the default open path therefore made the log
   fd inheritable across fork/exec — mostly harmless in pgAdmin's
   subprocess paths (they default close_fds=True) but still a
   silent behavioral diff. OR in O_CLOEXEC to match.

Also adds web/pgadmin/utils/tests/test_enhanced_log_rotation.py
covering: new file mode, rotated archive + new active file mode,
pre-existing file mode unchanged, and fd non-inheritable. Skipped on
Windows where POSIX mode bits and PEP 446 semantics differ. Verified
all 4 cases pass via web/regression/runtests.py --pkg utils.
2026-05-01 16:38:49 +05:30
Ashesh Vashi 85366aa128 fix: create pgadmin4.log with mode 0o600
Override EnhancedRotatingFileHandler._open() so the active log file
and rotated backups are created owner-only on POSIX. The parent
DATA_DIR is already 0o700, so this is defense-in-depth — but the
log file can contain sensitive context (auth events, query
fragments, paths) and should not be world/group readable on its own.

Pre-existing log files keep their current permissions; new installs
and rotated files pick up the tighter mode. Windows uses the default
open path since os.open mode is ignored there.
2026-05-01 16:38:49 +05:30
Ashesh Vashi 6f4f28def7 refactor: factor wtforms-error-to-JSON into helper, drop dead import
Two endpoints (change_password, forgot_password) had previously been
patched ad-hoc to handle wtforms validators that emit Babel
LazyString (LazyProxy) error messages — those instances report as
iterable, so json.dumps fails with "Circular reference detected" if
they reach the encoder. Both fixes lived inline with slightly
different shapes and a shared trap waiting to bite the next caller.

Extract `_first_form_error_message(form, default=None)`: walks
form.errors, force-resolves the first LazyString to plain str, returns
the default when the form has no errors. Use it from both endpoints.

Also drop the now-dead `from flask_security.views import
default_render_json` import — the only callsite in browser/__init__.py
was replaced when that function's signature drifted; the import was
left behind.

No behavior change beyond consolidating the two ad-hoc patterns; both
endpoint test suites still pass.
2026-05-01 16:38:49 +05:30
Ashesh Vashi fb9ce563fb fix: tighten DATA_DIR file/dir permissions at creation
Two related hardening changes around the pgAdmin data directory:

1. Atomic 0o600 for pgadmin4.db
   ----------------------------
   pgadmin/__init__.py:run_migration_for_sqlite() and
   setup.py:setup_db()'s run_migration_for_sqlite() previously chmod'd
   pgadmin4.db to 0o600 *after* SQLAlchemy/SQLite created it via
   db_upgrade(). With a typical 0o022 umask, the file existed at 0o644
   between SQLite's create and pgAdmin's chmod — a TOCTOU window.
   Practically narrow because the parent dir is 0o700, but easy to
   close: wrap the migration call in `os.umask(0o077)` so the file is
   born 0o600. Both code paths use try/finally so the prior umask is
   restored even on migration failure (a raise during db_upgrade no
   longer leaves the rest of the worker running with 0o077 in effect).
   The post-hoc chmod is kept as belt-and-suspenders for the case where
   the file already existed at a wider mode from an older install.

2. 0o700 for sensitive DATA_DIR subdirectories
   --------------------------------------------
   setup/data_directory.py previously chmod'd only SESSION_DB_PATH and
   the parent dir of SQLITE_PATH to 0o700. STORAGE_DIR (user uploads
   including saved cloud-deployment certs/keys), AZURE_CREDENTIAL_CACHE
   _DIR (MSAL token cache files — real Azure credentials), KERBEROS_
   CCACHE_DIR (Kerberos credential caches), and the directory holding
   pgadmin4.log (stack traces frequently capture sensitive context)
   were left at umask-default (typically 0o755 — directory listing
   readable by any local user).

   Refactor the create-loop to track which directories were newly
   created on this invocation, then apply chmod 0o700 uniformly to all
   sensitive dirs at once. Errors (e.g., chmod on a mounted volume in
   OpenShift) emit a WARNING but don't abort — same lenient pattern the
   existing SQLITE_PATH-dir chmod already used.

   pgadmin4.log itself is still 0o644 (umask-default) because Python's
   logging.FileHandler doesn't take a mode argument; tracked as a
   follow-up task to subclass the handler.
2026-05-01 16:38:49 +05:30
Ashesh Vashi 3b36dd3964 fix: encrypt session-file body (Fernet) for confidentiality at rest
The session file contains OAuth access/refresh tokens, AWS / Google /
Azure / BigAnimal cloud credentials, the Kerberos cache path, MFA OTP
material, and pass_enc_key — the symmetric KEK that decrypts the user's
saved Postgres server passwords. The HMAC header introduced earlier in
this branch protects integrity but not confidentiality: a leak of
sessions/<sid> alone exposes every secret in plaintext.

Wrap the pickle body in Fernet (AES-128-CBC + HMAC-SHA256, AEAD) before
the on-disk HMAC computation — encrypt-then-MAC, so pickle.loads is
unreachable on the read path until both the file HMAC verifies and
Fernet authenticates the ciphertext.

File format becomes:

    +----------------------------------------------------------+
    | _HMAC_HEX_LEN bytes : hex HMAC over the ciphertext      |
    +----------------------------------------------------------+
    | N bytes : Fernet(pickle((randval, digest, data)))       |
    +----------------------------------------------------------+

Fernet key is derived from SECRET_KEY via HKDF-SHA256 with a fixed,
versioned salt and info string (`pga-session-body-v1`,
`pgadmin session body encryption v1`) so multiple workers produce the
same key deterministically and a future format swap can derive a fresh
key without reusing bytes already in flight on disk.

Caveat (not theoretical): SECRET_KEY currently lives in pgadmin4.db in
the same DATA_DIR. A leak that includes BOTH sessions/ AND pgadmin4.db
recovers the derived Fernet key and decrypts session bodies. Closing
that gap requires moving SECRET_KEY out of DATA_DIR (e.g., into the OS
keychain via USE_OS_SECRET_STORAGE) — tracked as a Layer-2 follow-up.

Backwards compat: pre-Layer-1 session files (HMAC over plain pickle, no
Fernet) pass the HMAC check but raise InvalidToken on Fernet.decrypt.
That's caught and logged as "legacy unencrypted body"; users see a
one-time re-login on upgrade. (The same upgrade also flips
SESSION_DIGEST_METHOD's default from sha1 to sha256 and changes the
file format, so the re-login is unavoidable regardless.)

Three new tests:

* TestSessionBodyIsEncryptedOnDisk: place a sentinel in the session,
  read raw bytes, assert the sentinel does NOT appear on disk.
* TestSessionBodyRejectedWithDifferentSecret: write under SECRET_KEY=A,
  read under SECRET_KEY=B, confirm rejection (rules out hard-coded-key
  bugs).
* TestLegacyHmacOnlyFileRejected: pre-Layer-1 file is rejected with the
  expected "legacy unencrypted body" log message.

Pre-existing tests that built bodies directly (TestCorruptedHmacHeader,
TestCookieHmacMismatchWithValidFile) updated to wrap the body via the
new make_encrypted_body() helper so they exercise the realistic file
shape.
2026-05-01 16:38:49 +05:30
Ashesh Vashi bee80fe943 fix: write session files with mode 0o600 (was umask-default 0644)
$DATA_DIR/sessions/<sid> contains OAuth access_tokens / refresh_tokens
(via session['oauth2_token']), AWS / Google / Azure / BigAnimal cloud
credentials (via the cloud refactors earlier in this branch), the
Kerberos credential cache path, MFA OTP material, and pass_enc_key —
the symmetric KEK that decrypts the user's saved Postgres server
passwords.

The HMAC header added earlier in this branch protects integrity but
not confidentiality: anyone with read access to the file gets the
secrets. Default `open(path, 'wb')` uses the process umask, which on
typical systems leaves files 0o644 (world-readable). Switch
new_session() and put() to a new _open_session_file() helper that
opens with `os.open(... O_WRONLY | O_CREAT | O_TRUNC, 0o600)`, mirroring
the upload helper introduced in PR 1.

The directory itself is already 0o700, so this is defense-in-depth for
container scenarios where the data volume might be mounted under shared
uids, or for misconfigurations of the directory mode.

NB: Existing session files retain their old mode until next write, then
adopt 0o600. Operators who want to forcibly tighten existing files can
chmod the sessions directory recursively post-upgrade.

Adds a positive test asserting both put() and new_session() produce
0o600 files (skipped on Windows where POSIX mode bits are not
meaningful).
2026-05-01 16:38:49 +05:30
Ashesh Vashi ccfbd2c457 fix: SESSION_DIGEST_METHOD default to sha256, follow-up review polish
Default SESSION_DIGEST_METHOD from hashlib.sha1 to hashlib.sha256.
HMAC-SHA1 is still cryptographically acceptable for the cookie's
(sid, randval) signature, but SHA-256 is the modern default and aligns
with the file-HMAC header introduced earlier in this branch. The session
file format already invalidates all existing sessions on upgrade (the
new HMAC header is required), so flipping this default at the same time
is a free hardening rather than an additional break.

Test polish from the post-merge hostile review:

* Tighten the "no unsafe deserializer imported" assertion in the four
  cloud-module test files (RDS, Google, BigAnimal, Azure) to a regex
  anchored at line start with a word boundary, so it catches
  `from pickle import dumps, loads`, `import pickle as p`, and indented
  imports — not just bare `import pickle`.

* test_login.py: drop a sid-rotation assertion that would have given
  false confidence. Flask-Paranoid does NOT rotate the session id on
  login (it binds a `_paranoid_token` to UA+IP and validates per
  request), so an `assertNotEqual(pre_sid, post_sid)` would always fail
  for the wrong reason. Comment the limitation in the test for the next
  reviewer; stronger fixation testing is owed as a follow-up.

* docs/proposals: spec line numbers in §4.2 had drifted ~10 lines from
  the implemented branch (helper extraction, etc.); refresh them and
  the audit-summary table to point at HEAD-of-branch lines. Append a
  §1.6 entry enumerating the residual `pickle.loads` callsites in
  sqleditor / schema_diff / bgprocess that PR 7 / Phase 2 will close,
  so future reviewers see the surface.
2026-05-01 16:38:49 +05:30
Ashesh Vashi 1518b0828d fix: SERVER_MODE python-test path and two endpoint regressions
CI runs the python test suite with SERVER_MODE=False (DESKTOP) which
explicitly skips OAuth2 / LDAP / Kerberos / change-password / forgot-
password tests via runtime guards. Running the suite with
SERVER_MODE=True surfaced two real pgAdmin endpoint regressions and a
batch of stale test fixtures that this commit addresses.

Test-infra fixes (regression/python_test_utils/csrf_test_client.py):

* fetch_csrf was looking for <input id="csrf_token" ...> which the
  React SPA login no longer renders. The token is exposed as
  "csrfToken": "..." in a JSON config block embedded in /login HTML
  (camelCase) and as "csrf_token": "..." in JSON API responses
  (snake_case). Match both, fall back to the legacy hidden-input form.

* login() captured the CSRF from GET /login, but Flask-Paranoid
  regenerates the session on login (anti-fixation), which drops the
  csrf_token from the new session. Subsequent state-changing API calls
  failed with "The CSRF session token is missing." Refresh the token
  from a post-login GET /browser/ when SERVER_MODE is True.

Endpoint regressions (web/pgadmin/browser/__init__.py):

* change_password JSON path: bad_request(list(form.errors.values())[0][0])
  passed a Babel LazyString (LazyProxy) to the JSON encoder, producing
  500 with "Circular reference detected." Force resolution to plain
  str.

* forgot_password JSON path: default_render_json(form, include_user=
  False) used a Flask-Security API that no longer accepts include_user,
  yielding 500 on every JSON POST. Replace with pgAdmin's standard
  make_json_response/bad_request envelope.

Test-fixture maintenance:

* test_change_password.py / browser/tests/utils.py: send JSON to the
  JSON-only change_password endpoint (was sending form data, which the
  endpoint silently ignored); use DELETE /user_management/save/<id>
  for cleanup; update respdata strings to match current pgAdmin output.

* test_login.py, test_gravatar_image_display.py,
  test_ldap_with_mocking.py, test_kerberos_with_mocking.py,
  test_webserver_with_mocking.py: drop the
  'Gravatar image for X' assertion (server-rendered HTML no longer
  exists; React renders the gravatar client-side) and verify successful
  authentication via session._user_id instead.

* test_ldap_login.py: skip when LDAP config is template placeholders
  (no live LDAP server reachable in dev/CI).

* test_kerberos_with_mocking.py: skip when authenticate.kerberos_login
  is not registered (the blueprint loads only when KERBEROS is in
  AUTHENTICATION_SOURCES at app-init time).

* test_reset_password.py: switch to JSON POST against the JSON-only
  forgot_password endpoint; parametrize expected status; drop HTML
  pre-check.

* test_validate_email.py: drop pg@postgres.local (modern email-
  validator rejects RFC 6761 special-use TLDs); switch deliverability
  scenario to .invalid/.test domains for stable DNS.

Net result: 296 tests pass, 0 failures, 12 skipped (LDAP/Kerberos
require infra; 1 pgcrypto scenario; cloud-wizard real-credential
tests).
2026-05-01 16:38:49 +05:30
Ashesh Vashi 93206710f7 fix: drop live Azure instance from session, persist auth state only
The azure cloud-deployment module wrote a live Azure class instance
directly into session['azure']['azure_obj'] (no pickle.dumps -- it
relied on the session backend to serialize anything). That is an
implicit pickle dependency: any session-format change would crash, and
the live class instance carries Azure SDK credential objects with
mutable state.

Add Azure.to_state()/from_state() that round-trip the persistable
fields (tenant_id, session_token, use_interactive_credential,
authentication_record_json, region, subscription_id, availability_zone,
available_capabilities_list, azure_cache_name, azure_cache_location)
through a plain dict. Live SDK objects (_clients, _credentials,
_cli_credentials) are intentionally NOT in to_state -- they're rebuilt
lazily from authentication_record_json on first credential use.

from_state bypasses __init__ (which references current_user.username)
via cls.__new__(cls) so unit tests work without a Flask login context.

Module-level _get_azure_from_session()/_save_azure_to_session() helpers
replace the 18 session['azure']['azure_obj'] sites across 12 endpoints.

Worker-restart UX trade-off: today's behavior pickles the populated
Azure SDK client cache, surviving a worker recycle. After this change
the in-memory cache is gone on restart, but the persisted
authentication_record_json is sufficient for the SDK to silently rebuild
the credential without re-prompting for device code -- the class was
designed for this replay. Verification owed in PR review.

Seven new tests cover: round-trip of persistable fields, helper from
session state, lazy SDK objects after from_state, missing-session
graceful return, defaults for partial state, regression assertion that
no live instance leaks into 'azure_obj', and that the unsafe
deserializer is not used.
2026-05-01 16:38:49 +05:30