It looks the notarisation ticket up in Apple's CloudKit, and a dropped
connection there has failed both the arm64 build's verification step,
skipping the rest of a release candidate, and the acceptance test, on
bundles that were notarised and accepted by Gatekeeper.
FOR treats an item containing a wildcard as a glob against the current
directory and drops it when nothing matches, so libcrypto-*-x64.dll,
libssl-*-x64.dll, libintl-*.dll and libiconv-*.dll never reached the
inner DIR and were shipped unsigned since #10355. Run the loop from the
runtime directory instead, checking each name exists.
The assemble job downloaded every artefact of the run, which includes the
'pgadmin-org~pgadmin4~*.dockerbuild' build record that
docker/build-push-action uploads for the container. That artefact is not
stored as a plain zip, so actions/download-artifact fails to extract it,
and because it was downloading everything, the whole step failed after
its retries with 'Artifact download failed after 5 retries' -- while every
real artefact downloaded fine. None of the build record is used here; the
staging tree is assembled only from the pgadmin4-* artefacts. Restrict the
download to pgadmin4-*, which leaves the build record behind.
A called workflow's token is capped by the caller's permissions. The
release-candidate workflow sets 'contents: read' at the top level, which
leaves 'packages: none', so when it publishes the container with
publish: true the check-container-build publish-container job's
'packages: write' request exceeded the cap and the workflow was rejected
before starting (startup_failure). Grant packages: write on the container
caller job. The nightly snapshot is unaffected: it builds the container
as a check, with publish-container skipped, so it never requests the
scope.
Like the equivalent utility_conninfo case, a configured client file that
cannot be found is disabled (NO_CLIENT_FILE) on Windows rather than passed
on as its path; both are unopenable, so the connection still cannot use
another file. fs_short_path() also returns empty for a path that does not
exist on Windows, so the expectation could not be normalised. Run the
passthrough scenario on POSIX only.
The tests added for the 9.19 storage and connection hardening were
written on Linux and failed on Windows, where the behaviour they check
is correct but expressed differently. The production code is unchanged.
- Skip the Backup, Restore and Import/Export descriptor-based
symlink-race tests on Windows: opening a file with O_NOFOLLOW and
handing it to the utility as /dev/fd/N is POSIX-only, and the code
already passes the real path there.
- Compare credential-file paths after fs_short_path()+forward slashes,
the short (8.3) form libpq is given on Windows; this is the identity
on POSIX. Covers the --dbname conninfo call sites, get_complete_file_path,
client-file isolation and the SSH tunnel identity file.
- Normalise the File Manager closest-parent separator to '/'.
- Drop the other inherited credential variable before checking which
remove_inherited_libpq_credentials() removes, as the runner's own
environment may hold PGPASSWORD.
- Use forward slashes in the raw hostile --database value, whose
backslashes libpq would otherwise treat as escapes, and skip the
'missing configured file passed through' case on Windows, where such
a file is disabled (NO_CLIENT_FILE) instead.
require_auth needs libpq 16, so pgAdmin only adds it when the installed
libpq is new enough. These tests mock the connection-parameter libpq
version to exercise that path, but create_connection_string() validates
the connection string, and the injection tests build and parse it, with
the real installed libpq. On a runner whose libpq is older than 16 that
libpq rejects require_auth, so the scenarios that produce it errored on
macOS and Windows. Skip a require_auth-producing scenario when the
installed libpq cannot parse it, as the one real-parser scenario in
test_kerberos_conninfo already did.
EPAS can change a view's columns with CREATE OR REPLACE VIEW, so pgAdmin
only drops and re-creates the view on PostgreSQL (#5053). The tests
added for #9838 expected DROP VIEW on every server and failed on EPAS.
python:3-alpine now resolves to Python 3.15, whose psycopg[c] wheel links
against the system libpq. postgresql-dev is removed with build-deps, so
libpq.so.5 was no longer present at runtime and importing psycopg (as the
Sphinx docs build does via config.py) failed with 'no pq wrapper
available', breaking the container build. Install the runtime libpq
package separately so it survives the build-deps cleanup.
One entry per tracking issue (#10645-#10676), with the CVE identifiers
for the 15 vulnerabilities.
Also set the release date to 2026-10-29, and add the upgrade guidance
for the SSH agent, Flask-Security-Too, non-administrator shares and
SERVER_MODE_ALLOWED_SERVICES.
Add a shared "Accounts from different authentication sources" section to the
user management docs and link to it from the LDAP, Kerberos, OAuth2 and
Webserver pages. It explains that an account is identified by its username
and its authentication source together, that an external login is never
logged in to an account from another source, and that a password reset acts
only on the internal account. It also describes the account-lockout
behaviour: MAX_LOGIN_ATTEMPTS applies only to internal accounts, pgAdmin
never locks an account from another source, a failed sign-in for a shared
username counts towards the internal account's lockout, and a locked internal
account stops only internal sign-in without disclosing the lock while another
source is configured.
The LDAP page also notes that, with internal and LDAP both enabled, a shared
username is tried against both sources in the configured order, and that
accounts whose usernames differ only in case should not be created when
LDAP_DN_CASE_SENSITIVE is off.
Note on the Change pgAdmin Password page that changing the password, or an
administrator resetting it, signs out the user's other sessions.
Fix the setup.py examples: add-external-user and update-external-user take
the authentication source through --auth-source, not as a positional
argument; update-user and update-external-user do not take a password
positionally; the built-in role is "User", not "Users"; and a user added
without a role gets the "User" role.
A schema, table or column name is wrapped with qtIdent() and interpolated
into psql's \copy command. qtIdent() doubles an embedded double quote but
leaves a backslash untouched, while psql's \copy parser (strtokx) treats a
backslash inside a double-quoted identifier as an escape. A name such as
one ending in a backslash therefore becomes "name\", which psql reads as
an unterminated identifier that runs past its intended end, the same
command-injection class (CWE-78) that the query guard already blocks for
query exports. A null byte can truncate the C string so Python's
validation and psql's parse disagree. The column lists reach the \copy
command's FORCE_* options even for a query export, so they are affected
too.
Reject a backslash or null byte in any of these names outright, as the
query path does; pgAdmin never needs to name an object containing one.
The check inspects the names columns_to_string() would pass to qtIdent()
whatever JSON type they arrive as (a list's items, a dict's keys, or a
bare string iterated character by character), so a column value sent as a
string or object cannot slip a backslash through.
JavaScript: @tanstack/react-query, @tanstack/react-table, dompurify,
lossless-json, rc-dock, react, react-dom, react-dropzone,
react-virtualized-auto-sizer, html-react-parser, zustand,
@mui/x-date-pickers, core-js, webpack, postcss, typescript-eslint,
eslint-plugin-jest, globals, @testing-library/user-event.
The postcss resolution override is raised along with the direct
dependency, so installs actually resolve to 8.5.31.
Python (python_version > '3.9' rows only): Authlib, certifi,
Flask-SocketIO, urllib3 and selenium.
Held back: @tanstack/react-virtual (3.14.13) and marked (18.0.14) break
the Jest suite in later releases, and pickr stays at ~1.9.1 because
1.10.x breaks the production bundle.
Feature tests:
- Backup and Restore clicked their Tools menu item while it was still
disabled, because the menus are refreshed asynchronously after a
database gets connected. Wait for the item to be enabled first.
- check_if_element_exists_with_scroll() never terminated when it reached
the bottom of the tree and the node it looked for was above the visible
area. Stop scrolling down at that point.
add-user and add-external-user defaulted --role to None, so without
--role or --admin they looked up a role named None and exited with
"Role 'None' does not exists." Default --role to 'User', the role
pgAdmin gives every user it creates itself. --admin still takes
precedence, and an unknown --role still fails.
The 'LDAP Authentication with Auto Create User' scenario logged in
ldap_user, creating an LDAP account that was never deleted, and the
'without Auto Create User' scenario only passed because that account was
already there; run on its own it failed.
The 'without' scenario now adds the LDAP account it logs in to with
add_user(), which deletes it with the rows the login created when the
test ends, and every auto-create scenario deletes the account its login
created.
After adding a user, setup.py looked the new account up for display by
its email together with the auth source. For an external user the email
is not the username, so without --email every user of that auth source
was listed, and with --email nothing was shown at all.
Look the account up by the username it was created with and its auth
source instead, and add tests that run setup.py add-user and
add-external-user against a copy of the test database and check that
only the new account is shown.
- Webserver login and auto-creation looked the account up by username
alone. An internal account with the same username was found first, so
the Webserver user was refused, or never created.
- LDAP auto-creation, when LDAP_DN_CASE_SENSITIVE is off, compared the
stored username with the lower-cased login name, so an LDAP user whose
username has capitals was not found after their first login, and the
attempt to create them again failed, so they could not log in. It now
compares both lower-cased.
- setup.py update-user and update-external-user failed with a KeyError
unless --role or --admin was given, so an account could not be
deactivated on its own, as the LDAP documentation describes.
A test also checks that a password reset requested with the email in
another case is sent for the internal account.
Flask-Security registers views of its own next to pgAdmin's: /login,
/verify, /change, /reset and /reset/<token>. Submitting to them logged a
user in without the failed login count or the lock, checked a signed-in
user's password without limit, changed a password without pgAdmin's
change password permission or re-encrypting the saved server passwords,
and sent or applied a password reset for an account that is not
internal or is locked. pgAdmin does all of these through views of its
own, so a submission to Flask-Security's is now refused with 405
Method Not Allowed. A GET still renders the page, as GET /login renders
pgAdmin's login page, whose form is posted to /authenticate/login. The
refusal is not logged as an error, as anyone can send one.
The test uses server mode only views and is excluded in desktop mode.
The limit on failed logins (MAX_LOGIN_ATTEMPTS) could be got round, and
its lock reached accounts it does not protect:
- The internal account was looked up by its exact username, while
Flask-Security matches the email ignoring case, so a username typed in
another case was neither counted nor checked for the lock. It is now
looked up as Flask-Security does.
- With LDAP or Webserver authentication also configured, the login form
always passed validation, so a wrong internal password was never
counted. It is now counted whenever no source authenticates the user
and internal authentication was tried.
- A login that posts a password no longer falls back to Kerberos, OAuth2
or Webserver authentication, which do not check it: with them listed,
a guess was answered by the Kerberos challenge, or silently logged in
the user the web server named, without being counted.
- The account is locked on the failed login that reaches the limit, and
the user is told so, rather than at the next login.
- A login through another source no longer resets the internal
account's failed logins.
The lock is the internal account's. A locked internal account only stops
internal authentication: the other sources are still tried and only ever
log in an account of their own, so a user of another source with the
same name is not locked out by it. The password is not checked against
the locked account. When another source refuses the user, one neutral
message is shown whatever the password, saying a pgAdmin account may be
locked; with no other source to try, the account locked message is shown
as before. The skip is logged.
The new tests use server mode only features and are excluded in desktop
mode. The Kerberos login test now posts no password, as a Kerberos login
does.
The same email or username may belong to accounts from several auth
sources. Flask-Security looks the account up by email alone, and took
whichever row came first:
- Internal login checked the password against that row, so an internal
user could not log in when an LDAP or OAuth2 account with the same
email was found first. Flask-Security's lookup now prefers the internal
account; ignoring case, it prefers the one matching exactly, then the
oldest. A missing email or username identifies no account.
- A password reset was sent for whichever account had the email, and a
reset token for an account from another source was accepted, giving it
a local password and logging it in. Reset instructions are now sent
for the internal account, and a token for any other account is
refused.
- The About box showed the configuration to any user whose email was
that of an administrator's account, whatever its source. It now checks
the user's own role.
The test runner excluded every test module whose name started with one
on its desktop mode list, so excluding test_reset_password also excluded
test_reset_password_auth_source. Each entry now excludes only the module
it names, and the server mode only modules are listed.
In desktop mode get_storage_directory() returns None and a path is
absolute, so check_access_permission() returns it unchanged. These tests
patch in a storage directory in either mode and passed a path relative
to it, which in desktop mode named a file at the root of the filesystem,
so the save failed. They now pass a path inside the storage directory in
desktop mode, as a desktop user would.
The tests patched the application's SERVER_MODE setting but not
config.SERVER_MODE, which get_locale() reads. In desktop mode the refusal
message's gettext() then took the desktop branch and read
current_user.is_authenticated from the stand-in user, which has no such
attribute, so the "non-admin cannot write" scenario failed. Patch
config.SERVER_MODE as well in the three tests that use this setup.
In server mode, check_access_permission() validated a path into the
user's storage directory, but several callers then acted on a path built
differently, so the path checked and the path used could name different
files:
- Opening a file in the Query Tool, saving a file, the Google client
secret and, on Windows, Import/Export Data, Backup and Restore joined
the path to the storage directory after stripping leading separators,
so a path checked as inside storage could name any file the pgAdmin
account can read, or on Windows write outside it.
- Listing a folder decoded the path again after the check, so a folder
named like an encoded ".." listed any directory on the host.
- Download, delete, rename and the file-exists check appended the path
to the storage directory without a separator, so where one user's
storage directory name was a prefix of another's, that user's files
could be read, deleted or moved; download also checked the path
appended to itself.
- Creating a folder checked the parent rather than the folder created,
and upload wrote to a path that was not checked.
check_access_permission() now returns the absolute path it checked, and
every caller acts on that path. Folder paths are decoded once, rename
checks the new name where it is created, and download returns a 404
without the server path for a missing file.
In server mode on POSIX, download, delete, rename and file loads now
open files relative to directories opened from the storage directory
without following symbolic links, as upload and save already did, so a
parent directory swapped for a symbolic link after the check is refused.
The walk follows the path as written: a ".." goes back up past a
directory the walk opened, rather than being collapsed in the text to a
different file from the one the check resolved. It keeps open only the
directories a later ".." can still go back up to, so a path holds at
most two more file descriptors than it has ".." segments, and working
out which to keep takes time linear in the length of the path.
Also:
- The storage directory itself can no longer be deleted.
- In desktop mode, a download refuses a FIFO or other non-regular file
instead of blocking on it; symbolic links are still followed there.
- A symbolic link refusal is recognised as ELOOP, or EMLINK only where
O_NOFOLLOW uses it.
- The File Manager sends the selected storage when checking whether a
file exists, so the overwrite prompt sees files in a shared storage.
- get_closest_parent() tested whether the storage directory existed
rather than the remembered folder; it now finds the closest existing
folder inside storage.
- A refused or failed download now shows the server's message.
- check_file_for_bom_and_binary() reports a read error in its result.
The same username may exist under several auth sources. Kerberos uses
the shared login(), which looked the account up by username alone, so a
Kerberos principal matching an internal account's username was logged in
as that internal account, including the initial administrator. login()
now looks the account up by the authenticated username and the source
that authenticated it.
form.user is no longer used by any source but internal. Flask-Security's
validation, run for internal authentication, sets it to the account whose
email was typed, whether or not its password matched. With internal
authentication listed first, a Kerberos user could type another Kerberos
user's principal and be logged in as them, and an LDAP user could do the
same with an LDAP account whose email is their username. Internal login
logs in form.user, whose password was verified, only if it is an
internal account, and otherwise nothing.
The LDAP + Internal login test relied on an LDAP bind that accepted the
internal test account's credentials logging in that internal account,
which is the behaviour fixed here. LDAP now refuses those credentials in
that scenario, and new scenarios check that an LDAP login whose username
matches an internal account is not logged in as it, with LDAP or
internal authentication tried first. A further test logs in through the
login endpoint as a Kerberos user who types another Kerberos user's
principal. The LDAP documentation's add-external-user example passed the
source as an argument; it is given with --auth-source.
LDAPAuthentication.login() looked up the user to log in by username
alone, or accepted the form.user set by Flask-Security's internal
validation, without checking its auth source. Because the same username
may exist under several auth sources, an LDAP user whose username matched
an Internal account (such as the initial administrator created at setup)
was logged in as that Internal account, with its roles.
Only use form.user if it is LDAP-sourced, and restrict the fallback
lookups to auth_source=LDAP, matching the existing guard in Webserver
authentication.
Document that the initial administrator is always an Internal account,
and recommend deactivating it in favour of an LDAP administrator when
using LDAP authentication only.
Reported-by: Yves Soete of Blacksight LLC
sshtunnel was given neither host_pkey_directories nor ssh_config_file, so it
always loaded the unencrypted private keys in ~/.ssh of the account pgAdmin
runs as, and read that account's ~/.ssh/config (IdentityFile, User, HostName,
ProxyCommand) for the tunnel host. In server mode any user could therefore
configure a tunnel to a host reachable by that account, with no credentials
or an identity file that does not exist in their storage, and authenticate
with the service account's key. The ssh-agent of that account could also be
used.
In server mode the tunnel no longer reads those keys or that configuration,
and does not use the agent. An identity file that cannot be found in the
user's storage is reported instead of silently falling back to ~/.ssh.
Desktop mode is unchanged.
Compatibility: in server mode, SSH tunnels that relied on the service
account's own key, ~/.ssh/config or ssh-agent will stop working; the
identity file or password must be configured on the server instead.
Rotate fs_uniquifier on a password change (self-service and admin reset), so
every other session of that user is logged out. The changing session is
re-logged in, and its database connections are kept.
Re-encrypt saved passwords, including shared-server ones, under the new key.
Passwords that can't be decrypted are cleared, and the user is told. If
re-encryption fails, the sessions are still revoked and all saved passwords
are cleared rather than returning an error.
Reported-by: Zohaib
does_utility_exist() and /misc/validate_binary_path only rejected a
binary path under STORAGE_DIR, so a user could point the binary path of
a server type at a SHARED_STORAGE directory that ordinary users can
write to, and have pgAdmin execute whatever file was placed there
(CWE-426).
The check is now made by is_reserved_storage_path(), shared by both call
sites and extended to every SHARED_STORAGE entry. It expands $DIR and
collapses '..' before resolving symlinks, so it judges the directory the
utilities actually run from, compares directories with samefile() so
that a different spelling on a case-insensitive filesystem cannot get
past it, and refuses a path containing a NUL. An empty STORAGE_DIR is
treated as unreserved instead of being resolved against the working
directory. The guard is enforced inside get_binary_path_versions(), so
no caller can skip it, and the PSQL tool now goes through
does_utility_exist() too.
Reported-by: Harsh Singh
_is_query_parens_balanced() rejected a backslash inside a single-quoted
string but not inside a double-quoted identifier, where it treated the
next quote as closing the token. psql does not: strtokx in
parse_slash_copy takes its escape character from the connection's
standard_conforming_strings setting and applies it to whichever quote
opened the token, so with standard_conforming_strings off the \" in
"a\"b" is an escaped quote and the identifier runs on to the next quote.
That desynchronises our quote state from psql's in the opposite
direction to the case fixed in 1496fabe2, and a query export such as
SELECT "a\"b"c) TO PROGRAM 'cmd' x" therefore passed the balance check
whilst the ) it hid closed the wrapping \copy (...) context, exposing a
live TO PROGRAM clause. psql calls popen() for a client-side
\copy ... TO PROGRAM before the backend sees the statement at all, so
the command runs on the pgAdmin host even though the query is then
rejected as a syntax error. An ordinary PostgreSQL role can set
standard_conforming_strings on itself, and a non-administrator pgAdmin
account holds tools_import_export_data by default, so no privileged
access of either kind is needed.
Apply the same treatment as the single-quoted branch and reject any
backslash encountered whilst scanning a double-quoted identifier, since
the correct interpretation still depends on a server setting we cannot
reliably know at validation time.
Co-authored-by: Dave Page <dpage@pgadmin.org>
Reported-by: Yonghwa Lee of Xint by Theori
When a connection was not meant to use a Kerberos user's delegated
credentials, pgAdmin removed KRB5CCNAME from its environment, so the
Kerberos library fell back to the default credential cache of the account
pgAdmin runs as. libpq then answered any server that asked for GSSAPI
authentication with that account's ticket, whatever user name the
pgAdmin user gave, so any user could connect as the service account's
principal; gssencmode=disable does not prevent this. A client keytab for
that account had the same effect, even with no cache, as the library
fills the cache from it. On Windows, SSPI used the service account's
logon in the same way. psql was also started with a copy of pgAdmin's
environment, which could hold another user's credential cache whilst that
user was connecting, and the connection made to list a publisher's
publications was not made under the lock that guards it.
In server mode KRB5CCNAME and KRB5_CLIENT_KTNAME now always point at a
file that cannot exist, except whilst a user who logged in with Kerberos
connects to a server with Kerberos authentication enabled, when
KRB5CCNAME is that user's delegated cache as before. Because environment
isolation alone does not stop SSPI or gssproxy, every other connection
also gets require_auth=!gss,!sspi and gssencmode=disable, merged with the
server's own require_auth, which is refused if it allowed only GSS or
SSPI. A Kerberos-allowed connection forces gsslib=gssapi, so libpq uses
the delegated cache rather than the Windows logon. psql and the utilities
receive the same settings as PGREQUIREAUTH, PGGSSENCMODE and PGGSSLIB;
background processes now pass the server's own kerberos_conn instead of
always assuming a Kerberos connection, and the publication lookup
connects under the lock. Desktop mode is unchanged.
The forced require_auth, gssencmode and gsslib are serialised last, so
they win over any value a crafted connection parameter key places before
them, and the final connection string is parsed and refused if any of
them differs from the value forced (or is present where it is left to
the environment), so a smuggled value cannot steer the PSQL tool or a
utility to GSSAPI or SSPI however the parameters were serialised.
A pg_service.conf entry ranks above the PG* environment variables, so
only a service the administrator lists in SERVER_MODE_ALLOWED_SERVICES
can override these settings for the utilities; the documentation says
such an entry must not set them.
Compatibility: in server mode, connecting with the service account's
Kerberos ticket, client keytab or Windows logon (SSPI) no longer works;
only users who log in to pgAdmin with Kerberos can use GSSAPI, with their
own delegated credentials, as the documentation already described. A
server whose require_auth lists only gss or sspi is refused unless the
user logged in with Kerberos (SSPI-only is never usable). require_auth
needs libpq 16 or later; with an older libpq only the KRB5CCNAME and
KRB5_CLIENT_KTNAME isolation applies, which does not cover Windows SSPI
or gssproxy.
Co-authored-by: Dave Page <dpage@pgadmin.org>
Co-authored-by: hiteshjambhale <hitesh.jambhale@enterprisedb.com>
libpq looks a service up in the connection service file of the account
pgAdmin runs as, and an entry there can hold a password or name a
password file or client certificate. In server mode any user could set a
server's service, or a 'service' connection parameter, to one of those
entries and connect with its credentials. Likewise, a PGPASSWORD or
PGSERVICE variable set for that account was used by libpq for every
connection that had no value of its own, and passed on to the utilities.
In server mode a service may now only be used if it is listed in the new
SERVER_MODE_ALLOWED_SERVICES setting, which is empty by default. Creating
or updating a server, an ad hoc workspace connection or a servers.json
import that names any other service is refused, and connecting to an
existing server, or running psql or a utility for it, fails with a
message that names the setting. The 'servicefile' connection parameter is
refused in server mode, and PGPASSWORD and PGSERVICE are removed from
pgAdmin's environment at startup, with a warning. PGSERVICEFILE and
PGSYSCONFDIR are kept, since they locate the listed services. Desktop
mode is unchanged.
The allowlist is checked again on the final connection string, as libpq
will parse it, so a service smuggled in through a crafted parameter key
is refused however the parameters were serialised. pg_dumpall expands a
--database value containing "=" as a connection string, so the
maintenance database name of Backup Server and Globals is wrapped in one
and a name such as "service=name" no longer selects a service from the
service account's service file. client_files_isolated() treats a missing
PGADMIN_RUNTIME as "not the desktop runtime", so "setup.py load-servers"
no longer fails on any servers.json containing a Service and still
enforces the allowlist in server mode.
The tests run the real job-creation path of every tool that starts a
utility, and check that a service that is not listed is refused before
the utility starts, a listed one is exported as PGSERVICE, and a service
or servicefile connection parameter, or a database name that looks like
a connection string, never selects a service.
Compatibility: in server mode, servers that use a service stop
connecting until the administrator lists it in
SERVER_MODE_ALLOWED_SERVICES, and a PGPASSWORD or PGSERVICE set in the
environment pgAdmin starts with is ignored. "setup.py load-servers" now
refuses a servers.json whose Service is not in
SERVER_MODE_ALLOWED_SERVICES (exit status 1) instead of printing an
error and exiting 0.
Co-authored-by: Dave Page <dpage@pgadmin.org>
Co-authored-by: hiteshjambhale <hitesh.jambhale@enterprisedb.com>
To pass a server's password to psql and to the utilities run as background
processes, ServerManager.export_password_env() wrote it into os.environ:
as PGPASSWORD for psql, and under the process ID for a utility. Nothing
removed it again, and pgAdmin's environment is shared by every user's
requests and copied into every process it starts, so another user's later
psql session or utility inherited the password, and libpq read PGPASSWORD
for any connection made without one of its own. In server mode that let
one user connect with another user's saved password.
The password now goes only into the environment built for the process
that needs it: export_password_env() fills in a dict it is given, the
background process adds it to the environment of that process alone, and
psql is started with a copy of pgAdmin's environment plus its password,
rather than with os.environ itself, which get_user_env() also modified by
setting the user's psqlrc and history paths in it. The process executor
removes the variable it reads the password from, so the utility only
sees PGPASSWORD.
There is no compatibility impact: psql and the utilities receive the same
password in the same variable as before.
Co-authored-by: Dave Page <dpage@pgadmin.org>
When a server had no password file, client certificate or client key
configured, or the configured one could not be found, pgAdmin left the
parameter out of the connection and libpq fell back to ~/.pgpass,
~/.postgresql/postgresql.crt and ~/.postgresql/postgresql.key (or
PGPASSFILE, PGSSLCERT and PGSSLKEY) belonging to the account pgAdmin runs
as. In server mode that let any user connect with the service account's
saved passwords or client certificate, and supplying a copy of its public
certificate was enough for libpq to pair it with the service account's
private key.
In server mode each of these parameters is now always given a value:
the user's own file if one is configured, otherwise a path that can never
be opened, so libpq neither uses nor looks for a default. This applies to
the connection string, to the PGPASSFILE, PGSSLCERT and PGSSLKEY variables
passed to the utilities, and to the direct connection made to list a
publisher's publications. A configured file that does not exist is now
passed on as configured instead of being dropped, so a missing root
certificate or CRL no longer quietly falls back to root.crt or root.crl,
and a CRL directory is now passed on in server mode. The trust settings
(sslrootcert, sslcrl, sslcrldir) are still left to libpq when they are not
configured, as they hold no secret, and "system" is no longer mistaken for
a file name in the subscription connection. Desktop mode is unchanged.
libpq ranks a pg_service.conf entry above the PG* environment variables,
so a service entry that set passfile, sslcert or sslkey still won for
pg_dump, pg_restore, psql and the maintenance tools. In server mode the
three settings are therefore also passed in the --dbname connection
string, which libpq ranks above the service file; pg_dumpall takes them
in a separate --dbname argument so that its per-database pg_dump
processes inherit them. pg_dumpall's --database, the maintenance database
name passed raw, is now wrapped as a connection string too, so a name
such as "passfile=..." cannot bring the service account's files back. The
file settings are chosen from the same source as the environment
variables, so that the two cannot disagree.
Compatibility: server mode deployments that relied on the service
account's ~/.pgpass, PGPASSFILE, default client certificate and key,
PGSSLCERT or PGSSLKEY, or on a passfile, sslcert or sslkey in a
pg_service.conf entry, will need to configure those files on each server
instead. In server mode a pg_dump, pg_restore or psql older than
PostgreSQL 10 fails on the passfile keyword, and a server configured with
an sslcert but no sslkey now fails with "certificate present, but not
private key file" instead of silently using the service account's key.
The container is not affected, as its entrypoint already copies
PGPASS_FILE into the user's storage and sets the password file
explicitly. Passwords in a pg_service.conf entry chosen through the
service setting are not addressed here.
The tests now cover a server whose user password file is held only on
the server row. The auth gating test restores the provider modules it
re-imports, so later OAuth2 tests no longer fail depending on test
order.
Co-authored-by: Dave Page <dpage@pgadmin.org>
The server dialog only shows the Shared switch to administrators, and
the servers.json import only honoured it for them, but the create and
update endpoints stored whatever 'shared' and 'shared_username' they
were sent. Any user able to register a server could therefore share one
with every other user of a server mode installation. The import had a
similar gap: validate_json_data() removed a non-administrator's "Shared"
entries only after its loop, so an early validation error left them in
the data, and the web path ignored that error, so selecting a shared
entry in a file that also held an invalid one created a shared server.
A non-administrator's request that would share a server is now refused
with a 403. A 'shared' value that changes nothing is still accepted, so
editing a private server is unaffected, and 'shared_username' is dropped
rather than refused because the dialog sends it whenever the username
changes. The import prunes a non-administrator's shared entries before
validating and skips them again for each imported entry, only a JSON
true counts as shared, and validation errors are reported on the web
path too. Copy Server no longer sends shared for a non-administrator
owner.
The existing servers are cleared for "replace" only after the file has
validated, so a refused file no longer deletes them. Servers shared by
non-administrators before the upgrade stay shared until the owner
un-shares them or an administrator changes them; pgAdmin warns about
them at server-mode startup, and the documentation describes this.
Compatibility: a servers.json import in server mode that fails
validation is now refused as a whole on the web path, as it already was
in setup.py.
Co-authored-by: Dave Page <dpage@pgadmin.org>
In server mode, get_complete_file_path() joined the user's storage
directory with the path they supplied after stripping only leading
slashes, so a password file, SSL certificate, key, CRL or SSH tunnel
identity file set to "../<another user>/.pgpass" (through the server
dialog, the API or a servers.json import) would connect with another
user's credentials or key. The sslkeylogfile parameter was copied into
the connection string unchanged, so a traversal path let libpq create or
append a file as the pgAdmin service account.
The resolved path, with symbolic links followed, must now stay inside
the user's storage directory, or inside the named shared storage
directory for a "Name:/file" path, as the file manager already requires.
sslkeylogfile is confined the same way, is refused in a shared storage
directory that is read-only for the user, and is no longer inherited by
non-owners of a shared server. A path that escapes, or whose storage
directory cannot be determined, raises PermissionError rather than
letting libpq fall back to its default file, and the message says where
the file has to live. Desktop mode is unchanged.
make_conninfo() quotes connection-parameter values but not their keys,
so a key such as 'sslmode=require passfile' smuggled its own
keyword=value pairs past these checks. Only exact libpq keywords, read
from the linked libpq at runtime, are now accepted as connection
parameter keys: when a server is created, updated, imported from
servers.json or used for an ad-hoc connection, and again when the
connection string is built, for rows saved earlier.
Connecting, the SSH tunnel and the utilities' job creation report a
refused path or key instead of proceeding, and a job whose environment
cannot be resolved is removed rather than left in the Processes list. A
server with a refused path or key can still be listed and edited to
correct it. On Windows, the short path of a not-yet-existing file is
resolved via its parent directory instead of being dropped.
Co-authored-by: Dave Page <dpage@pgadmin.org>
Co-authored-by: hiteshjambhale <hitesh.jambhale@enterprisedb.com>
Follow-up to the Import/Export Servers, File Manager and utility-file
symlink-race fixes.
- Hard links: O_NOFOLLOW refuses only symbolic links, so a second name
for a file outside the storage directory was still opened.
_open_upload_target(), _open_read_target() and open_utility_file() now
refuse a file whose link count is not 1, reporting a file unlinked
after the open separately from one with several links. For writes,
O_TRUNC is applied only after that check (via ftruncate), so a
hard-linked file is never emptied before it is refused.
- FIFOs on write: _open_upload_target() opens non-blocking and accepts
only a regular file, so a FIFO planted at the path can no longer hang
an upload, save or export waiting for a reader. _open_beneath()
reports the ENXIO of a reader-less FIFO as "Not a regular file", so
Backup, Restore and Import/Export Data get the same message.
- Errors: the Import/Export Servers, save_file, Backup, Restore and
Import/Export Data handlers return the reason (strerror) rather than
str(e), which included the absolute storage path, and fall back to the
exception's text for errors without an errno, so a non-UTF-8
servers.json is reported instead of failing with a 500.
load_database_servers() keeps the file path in its messages only for
the setup.py CLI, and the upload response returns the
storage-relative name instead of the on-disk path.
Tests cover the hard-link refusal (target left intact), reader-less
FIFOs on the upload and utility-write paths, the non-UTF-8 servers.json
and the handlers' path-free errors.
Backup, Restore and Import/Export Data checked the user's file path
against their storage directory with filename_with_file_manager_path()
and then passed the path to pg_dump, pg_dumpall, pg_restore or psql,
which opened it by name later. A symbolic link swapped in at the leaf or
a parent directory in between redirected the utility outside the storage
directory, so a backup could overwrite, or a restore or \copy read,
another file that pgAdmin's OS user can reach.
In server mode pgAdmin now opens the file itself through the File
Manager's descriptor-relative O_NOFOLLOW walk from the storage directory,
accepts only a regular file, and hands the utility /dev/fd/N.
BatchProcess.start() gains pass_fds and launch-only arguments so the
descriptor is inherited by the process executor and the utility, whilst
the job details stored and shown to the user keep the real path.
Descriptors are only passed on Linux; on macOS and the BSDs the file is
still opened and checked safely but the utility reopens it by name
afterwards. Desktop mode and Windows are unchanged.
dump_database_servers() validated the servers.json path against the
user's storage directory with filename_with_file_manager_path(), then
wrote it with a plain open(), so a symbolic link swapped in at the leaf,
or at any parent directory of it, between the check and the write let the
export overwrite a file outside the storage directory.
load_database_servers() and the import file-listing endpoint had the same
gap on the read side.
A new _open_beneath() opens the storage directory and then walks down to
the file one component at a time, each open relative to the descriptor of
the directory above it and made with O_NOFOLLOW, so the kernel refuses a
symbolic link wherever it appears below the storage root. New files are
created 0o600, and reads accept only a regular file, opened non-blocking
so that a FIFO cannot hang the request. Desktop mode, Windows and the
setup.py CLI path are unchanged.
Reported-by: MUTED CYBERSECURITY S.L.
MViewNode.refresh_data() passed the target database's name straight to
psql's --dbname argument. libpq treats a --dbname value containing "="
(or a postgresql:// URI) as a full connection string, so a database
named e.g. 'host=attacker port=5432 dbname=postgres' - creatable by any
role with only CREATEDB - redirects the spawned psql to an attacker host,
leaking the connecting user's saved server password.
Route the name through database_conninfo(), the same helper already used
for backup, restore, maintenance and import/export, so libpq treats it
strictly as a database name and never as connection directions.
Reported-by: Bae Seung-won of Ajou University
pgAdmin renders its templates with trim_blocks, which removes the newline
after a block tag. nodes.sql ended its WHERE clause with a conditional, so
with neither a statistics id nor schema_diff set, which is what expanding
the Statistics collection sends, the SQL ran into "::oidORDER BY" and the
server rejected it.
Give each condition its own line ending in plain text, and add a test that
renders the template for every parameter combination and runs it.
* Document running the container with rootless Podman, and the PUID and PGID environment variables. #9524
* Note that the U volume option is recursive and does not relabel for SELinux.
* Document that config_local.py is lost on upgrade on macOS. #5369
* Retitle the config_py docs page to 'Configuration Files', as it covers all four configuration files. #5369
Starfleet endpoints drop SSL connections that arrive without SNI, which
libpq only sends from version 14, and never answer a non-SSL connection.
The cloud wizard now warns when pgAdmin's libpq is older than 14, and
the Register Server dialog defaults sslmode to require for *.pgedge.cloud
hosts, since with prefer any failed SSL attempt is retried without SSL
and reported only as a timeout.
Python 3.14 has to wait: the Windows build needs pywinpty, which is held
at 2.0.x for andfoy/pywinpty#545, and 2.0.x cannot be built for 3.14.
Note that beside both pins.
Python 3.14's venv module creates a "𝜋thon" symlink next to python,
python3 and python3.X (CPython gh-119535). The hash bang fixup runs
"sed -i" over venv/bin/*, which replaces every symlink with a regular
copy of its target; the python* links are recreated afterwards, but the
new alias was not, so packages built against Python 3.14 shipped a full
copy of the interpreter under that name, and dpkg on Ubuntu 26.04 fails
to unpack it. Nothing uses the alias, so remove it when the venv is
created.
The Logs tab reads the current log file in fixed-size byte ranges with
pg_read_file(), which raises 'invalid byte sequence' (SQLSTATE 22021)
when a range boundary falls inside a multi-byte character, so Tabular
format failed on logs containing non-ASCII text. The ranges are now read
with pg_read_binary_file(), transported as hex and decoded in pgAdmin
using the server encoding. Tabular reads fetch a few bytes of overlap so
a character split at a page boundary is shown whole on one page, and
genuinely invalid bytes are shown as replacement characters rather than
raising an error. For the EUC family of server encodings a page boundary
can still garble one character, as lead and trail bytes share ranges.
- Keep valid text after an invalid byte near the end of the file, trimming only a character still being written.
- Treat a truncated character in the CJK codecs as incomplete rather than invalid.
- Correct the encoding limitation comment, as GBK and GB18030 are not server encodings.
Several tests changed the server dict that the runner shares between all
tests (shared, service, SSH tunnel settings and so on) and never put it
back, so later tests inherited those settings; running the whole server
test package locally made the non-owner tests in
test_server_data_isolation.py fail with HTTP 428.
Move restore_server_fixture_on_cleanup() into servers/tests/utils.py and
call it from every test that modifies the fixture, including the sqleditor
new connection tests that added a password key, and stop the SSH mock
connect test relying on a tunnel_password key leaked by test_add_server.
The column ALTER emitted RESTART SET START whenever START changed,
rewinding the identity sequence so the next generated value could
collide with existing rows; Schema Diff hit this too when only START
differed. RESTART is now emitted only when the sequence is still pending
at its old START with nothing generated since, or when its current value
would fall outside a new MINVALUE or MAXVALUE (which PostgreSQL refuses
without it). To decide, the column update reads last_value and is_called
from the sequence itself when START is set, since
pg_sequence_last_value() is NULL after any RESTART WITH.
The fix for #10357 remounts the dialog form on Reset so that SQL editor
values still waiting to come back through SchemaView are discarded. This
spec types into an SQL field, removes the edit, types again and presses
Reset before SchemaView has passed those values back, then checks that the
original value is shown. Without the remount the editor keeps the last
typed value.
When an edit and its undo (or a key, Backspace and the same key again) were
all reported before SchemaView passed the first value back, the editor's
pending values held the same text twice. Matching the newest copy dropped the
value in between, which then replaced the document when it came back. Values
come back in order, so InputSQL now matches the earliest copy; if some are
skipped, the leftover entries are cleared by later changes.
The inherited-columns CTE joins on column name, so a column merged from
more than one parent produced a row per parent, and DISTINCT ON then
kept whichever came first. Order by the inheritance sequence number so
the first parent in inheritance order is always reported.
A source column without a comment has a description of None, which the
column update template skips, so the comment was reported as a difference
but never dropped. Send an empty string in that case, as the column dialog
does when a comment is cleared, so COMMENT ON COLUMN ... IS '' is
generated.
The SQL_ASCII codec unescaped every valid \uXXXX and \UXXXXXXXX
sequence, so a value or column name containing '\u0041' was shown as
'A', and saving the row wrote 'A' back.
Decode each byte as latin-1 and only unescape escapes for code points
from U+0100 to U+10FFFF, which are the only ones pgAdmin writes when
encoding. Lower escapes and invalid sequences are kept as their literal
bytes. Encoding is unchanged.
- Decode long runs of backslashes in linear time in the SQL_ASCII codec.
The column and table templates only emitted an identity option when its
value was greater than -1, which was meant to skip unset options but
also dropped every negative one. So on a descending identity column,
changing INCREMENT to -2 in the column dialog or a MINVALUE difference
in Schema Diff produced no ALTER at all, and the table's CREATE SQL lost
its INCREMENT, START, MINVALUE and MAXVALUE. Test for an unset option
explicitly instead.
The tests patch psql.sio.emit globally, so a psql session from another
test that is still running can emit pty-output into the same mock. Only
count the emit calls sent to the test's own room.
pgAdmin uses raw_unicode_escape as the Python codec for SQL_ASCII
connections, and psycopg decodes column names and query text with it.
A name such as "C:\User\te" contains '\U' that is not a valid
\UXXXXXXXX escape, so decoding raised "truncated \UXXXXXXXX escape".
Register a pgAdmin codec that behaves like raw_unicode_escape but keeps
invalid escape sequences as their literal bytes, and use it for
SQL_ASCII. Encoding is unchanged.
- Give the SQL_ASCII codec incremental support, and keep invalid escapes in server errors.
Each poll of the debugger returns its own batch of notices without a
trailing newline, so appending them directly ran messages from separate
steps together. Start each appended batch on a new line.
- Clarify that debugger message batches only usually lack a trailing newline.
SchemaView passes a field's value back a render or two after the change,
so when two keys are pressed almost at once the SQL editor could be handed
the value from the first key whilst already holding the second. Applying
that older value replaced the whole document, which moved the cursor to
the start. InputSQL now remembers the values the editor has reported and
ignores one that is older than the editor's current content.
- Remount the dialog form on Reset so pending SQL editor values are discarded.
pg_constraint.confkey holds the attribute numbers of a foreign key's
referenced columns, which differ between databases when a column has been
dropped from the referenced table in only one of them. The referenced
columns are already compared by name, so ignore confkey as conkey already
is.
A foreign key whose referenced columns differ is now dropped and
recreated, rather than being reported as different with an empty diff.
parse_priv_to_db() now merges ACL entries that grant exactly the same
privileges, from the same grantor, into one entry listing every grantee,
so the templates for all object types produce
'GRANT ... TO role1, role2;' rather than one statement per role.
A privilege whose grantee is changed away from PUBLIC now also revokes
PUBLIC's privileges, rather than leaving them in place.
Schema Diff passes a changed column with all of its properties, but the
column update template emitted COMMENT ON COLUMN and every identity
sequence option without comparing them with the column as it stands.
So any difference in an identity column repeated its unchanged comment,
and repeated START as RESTART SET START, which rewinds the identity
sequence on the target. Only emit those that actually differ.
resetLayout() re-opened the non-default tabs without their metaData, so
the layout saved straight afterwards stored them as bare ids and loadTab()
had nothing to rebuild them from on the next load. Their group is now
carried over as well, rather than falling back to 'default'.
PostgreSQL 18 rejects UNLOGGED on a partitioned table, in both CREATE
TABLE and ALTER TABLE ... SET UNLOGGED/LOGGED, so on PG18 and later
the Unlogged switch is now disabled for partitioned tables (including
a partitioned partition) and the generated CREATE, ALTER and reverse
engineered SQL no longer include it. Leaf partitions can still be
unlogged, and older servers are unchanged.
The Partitions SQL section of a partitioned table's script also now
keeps UNLOGGED for partitions that are unlogged, which it previously
dropped.
The route has no user placeholder, so url_for() always dropped it and
the backend never read it; the now-unused user argument is removed
from the connect-server helpers too.
The latin-1 read fallback now records the encoding, and encodings are
tracked per resolved file path rather than by basename, and only after
a save succeeds.
A cached manager whose server definition no longer matches is rebuilt,
and the owner's settings are deep-copied before being overlaid for
shared-server users.
The #10330 branch added and then dropped a schema version bump; replayed
on top of #10506, which also took version 53, the squash set it back to
52 even though the server_passexec_name migration remains.
Tags are copied to a user's shared server entry, whilst passfile is
treated like passwords, SSH keys and SSL client files and is never
copied from or overlaid with the owner's value.
Connection parameters with a libpq environment variable are exported to
pg_dump, pg_restore, psql and the other utilities, PGSERVICE comes from
the per-user server manager, and boolean parameters stored as strings
are normalised consistently with the main connection path.
psql is launched directly through pywinpty, read errors end the session
cleanly, spawn failures are reported to the terminal, and the pty is
closed when the session ends.
Views with a single base table, all of its primary key columns exposed,
no INSTEAD OF triggers and no rules other than _RETURN can be edited in
View/Edit Data. Updates and deletes are checked row by row, and adding
rows is disabled because inserts through views are not supported.
Accumulate Responses API function calls by item, treating output_index
and the item id as names for the same call, and use
response.output_item.done for the final name, call_id and arguments.
Users holding ADMIN OPTION on a role, directly or through an inherited
membership as PostgreSQL itself allows, can now change its members,
whilst all other property changes still require CREATEROLE.
The inherited-columns CTE compared pg_inherits.inhrelid with a fixed
OID instead of the table being queried, so inherited columns were never
reported as such on PostgreSQL 14 and later.
In server mode, users choose a password exec command by name from
SERVER_PASSEXEC_COMMANDS rather than entering a command line. Existing
commands are converted on upgrade once the allowlist is configured; if
it is empty the conversion is postponed and a warning is logged.
The database is passed as a dbname= connection string, quoted when
needed, so service files and unusual names work, and backup and
import/export require a database for object-level jobs.
Converting a plain column to SERIAL now creates the sequence with the
source sequence's own type, moves it past existing values and restores
the nextval() default; converting away from SERIAL drops the default
before the owned sequence.
The providers keep credentials and authentication state in nested dicts
in the Flask session, which ManagedSession does not detect as modified,
and FileBackedSessionManager may also defer the disk write. Add a
write_session_through() helper that sets both session.modified and
session.force_write, and call it wherever a provider stores or clears
state that a later request depends on.
The shortcut called focus() on the rc-dock tab pane, a plain div that
cannot take focus, so focus stayed where it was. Focus the react-aspen
tree inside the panel instead, which is already focusable, and draw its
focus indicator with the theme's activeBorder colour on :focus-visible.
Electron 43 deliberately stopped remembering the last folder used by the
native open and save dialogs, so they always opened in Downloads. Track the
last visited directory in the runtime's config store and pass it as
defaultPath whenever the caller has not specified a location.
Results can now be saved as JSON or XML as well as CSV, with preferences
for the output file encoding, prepending a byte order mark to CSV/TXT
files, and the default state of the grid's copy-with-headers toggle.
execute_void() reused the cached server-side cursor, whose execute() cannot
run a transaction-control statement, and the resulting exception was
swallowed, so the statement never reached the database. Run such statements
through a throwaway plain cursor instead, and guard poll()'s error path
against a cursor with no query.
Tab switching now works when focus is inside a tool and defaults to
Ctrl+Alt+PageUp/PageDown, Ctrl/Cmd+Shift+D duplicates the current line or
selection in the SQL editor, and dialogs handle Enter and Escape
consistently.
Add --statistics-only, --no-policies, --no-data, --no-schema,
--no-statistics, --statistics and --sequence-data where the utilities
support them, gated on server version 18, and suppress combinations that
pg_dump, pg_dumpall and pg_restore would reject. The pg_dumpall --format
and pg_restore --exclude-database/--globals-only options were reverted
before PostgreSQL 18.0 and so are not included.
The expression check compared pg_get_indexdef() output, which is quoted,
with the raw attname, so such columns were treated as expressions and
dropped. Compare against quote_ident(attname) instead, and replace the
.strip('"') calls with an unquote_ident() helper that also unescapes
doubled inner quotes.
get_ctypes.sql wrapped a multi-row UNION in a scalar subquery, which failed
when a libc database's collation and ctype differed. Rewrite it as a flat
UNION, add a 15_plus variant, and read datlocale for ICU and builtin
providers on PostgreSQL 17+.
check_external_config_db() let connection failures and malformed URIs
propagate as exceptions. Create the engine inside the try block, return
False on any failure, and dispose of the engine in a finally block.
SSHTunnelForwarder defaults allow_agent to True, so pgAdmin probed the
agent even when it had a credential of its own. Disable the agent in that
case, keeping it enabled when neither is configured.
The shipped OAUTH2_CONFIG template sets OAUTH2_USERINFO_ENDPOINT to None,
but get_user_profile() only checked whether the key was present, so it
called client.get(None). Check whether the value is set instead, apply the
same check to OAUTH2_LOGOUT_URL and OAUTH2_ADDITIONAL_CLAIMS, and clear a
stored logout URL when the provider has none or the callback fails.
The PG17+ allowed_privs.json files for views and materialised views were
placed directly in the 17_plus bucket rather than under its sql/
subdirectory, so the versioned template loader fell back to the default
list, which lacks MAINTAIN. Move them under sql/ and add tests covering
the template path and granting MAINTAIN through RE-SQL.
get_formatted_columns() copied the parent table name onto inherited columns
loaded from a saved table's properties, but not the parent's OID, so the
table dialog could not match those columns when the parent was removed from
'Inherited from'. Keep the first parent's OID, matching what the dialog does
when parents are added interactively.
Files are streamed to the Query Tool in 4 MB chunks. If a byte that is not
valid UTF-8 appeared after the first chunk, the resulting UnicodeDecodeError
caused the file to be re-read from the start as latin-1, after earlier chunks
had already been sent, so they were delivered twice.
Check that the whole file decodes with the detected encoding before streaming
anything, falling back to latin-1 up front if it does not. Also replace the
deprecated codecs.open() with open(..., newline='') to preserve line endings.
* Add a macOS 26 layered app icon with dark, clear and tinted variants.
The new pgAdmin4.icon Icon Composer document splits Slonik into body and
line layers over the existing light gradient, using the system dark fill
in dark mode. It is compiled ahead of time into Assets.car, so the build
does not need Xcode 26, and pgAdmin4.icns remains the icon on older
releases.
Closes#10473
* Match the macOS 26 icon's Slonik to the old icon's size and drop its glow.
Remove the thick white silhouette from the body layer, soften the group
shadow, and scale and position Slonik to match pgAdmin4.icns.
* Thin the macOS 26 icon's white lines to match pgAdmin4.icns.
* Add a pgEdge Starfleet API client for cloud deployment.
* Add a pgacloud provider for pgEdge Starfleet.
* Add the pgEdge Starfleet cloud deployment blueprint.
* Return the generated pgEdge Starfleet password once on deployment and allow saving it.
* Add pgEdge Starfleet to the Deploy Cloud Instance wizard.
* Test the Starfleet password is fetched once and validate the save request body.
* Show the generated pgEdge Starfleet password once after deployment.
* Document pgEdge Starfleet cloud deployment.
* Show the Starfleet password dialog at most once per job and handle copy failures.
* Wait for the Starfleet instance schema before rendering its form.
The schema is built after an asynchronous client IP lookup, but SchemaView
takes its initial data from the first schema it receives, so mounting it
early left the deployment type unset and hid the managed-only fields.
* Keep the Starfleet token for each deployment job until it completes.
The wizard clears its cloud session as soon as the deployment starts, so
the token was gone by the time the job finished and the generated
password could never be fetched. Copy the token for the job when it is
started, read the password with that copy, and discard it on completion.
* Show the reason a cloud deployment failed.
pgacloud prints the provider's error as JSON before it exits, but the
failure path ignored it, so the user saw only a generic process failure
whilst the server quietly vanished from the tree. Pass the message
through update_server and show it once per job as a plain-text error.
* Keep the cloud deployment failure message until it is dismissed.
* Explain an empty Starfleet BYOC cluster list.
* Wait for Starfleet connection details before finishing a deployment.
* Handle malformed Starfleet responses without a server error.
* Clear the Starfleet PostgreSQL version when the deployment type changes.
* Show the Starfleet size label in the deployment summary.
* Stop logging the values of a background process's environment.
* Use the official pgEdge logo for the Starfleet provider.
* Only mount the selected provider's credentials form in the cloud wizard.
* Require HTTPS and handle malformed pgEdge Starfleet responses.
Addresses CodeRabbit review on #10499: the API client refuses a non-HTTPS
URL before sending credentials, a 2xx body that is not JSON or lacks an
access token raises StarfleetError, and the provider and credential check
report an unexpected response shape instead of failing with a traceback.
* Reject an empty or non-string pgEdge Starfleet access token.
* Note that the pgEdge Starfleet API URL must use HTTPS.
* Add screenshots to the pgEdge Starfleet deployment documentation.
* Update the cloud provider screenshot to include pgEdge Starfleet.
* Remove pgEdge Starfleet BYOC support from the cloud deployment wizard.
Only Managed databases are now offered, so the Deployment type selector,
the cluster list, the BYOC PostgreSQL version list and the BYOC capability
probe are gone, along with the --kind and --cluster-id options of
pgacloud's starfleet create-instance command.
* Regenerate the Starfleet instance and review screenshots without the deployment type.
* Retry transient pgEdge Starfleet polling errors and write job tokens through.
A network error, rate limit or server error whilst waiting for a new
database no longer ends the deployment, leaving the database running in
Starfleet but removed from pgAdmin; polling now retries until its
deadline, failing at once only on other client errors.
The job's copy of the access token is now written to the session store
immediately, so a status poll handled by another worker can still fetch
the generated password.
* Write the pgEdge Starfleet wizard token through, and warn that a failed deployment may leave the database behind.
5.8.3 fixes an open redirect in Flask-Security's default configuration
(GHSA-ccgp-pv8r-95wm). The fix is also in 5.9.1, but 5.9 needs Python
3.11 or later and removes get_post_login_redirect(), which pgAdmin
imports, so raise the lower bound within the 5.8 series instead.
Adds browser support for PostgreSQL 14+ extended statistics objects
(CREATE STATISTICS), with the create/edit dialog, reverse engineered SQL,
the computed values ANALYZE collected where the role may see them, and
schema diff support. Completes Murtuza Zabuawala's #9748.
Co-authored-by: Dave Page <dpage@pgadmin.org>
Adds OAUTH2_SERVER_GROUP_CLAIM and OAUTH2_SERVER_GROUP_CLAIM_MAPPING. When
the claim is configured for a provider, shared servers owned by other users
are only visible and accessible if their server group's name appears in the
(optionally mapped) claim values; if the claim is missing, none are. The
user's own servers, and users of other authentication methods, are
unaffected. The claim is resolved at login and only stored once the login
has been authorised.
Co-authored-by: Dave Page <dpage@pgadmin.org>
When SPNEGO negotiation failed, the exception object was passed to flash(), so the session could not be serialised and the login page was replaced by a 500 JSON response. A generic message is now flashed; the detail is already logged.
Also fix a redirect to a non-existent 'authenticate.kerberos_login' endpoint (it is 'kerberos.login'), which had caused every Kerberos mock test to skip unconditionally, and return the username from Kerberos auto-creation so a user's first login is not rejected with "Incorrect username or password".
Co-authored-by: Dave Page <dpage@pgadmin.org>
Share one explicit react-dnd manager between DataGridView and PgTreeView. react-arborist bundles react-dnd 14, which stores its manager under the same global key as react-dnd 16 but keeps a separate reference count, so unmounting one provider could drop the manager while its HTML5 backend was still attached to window.
Moves table construction to useTable() with a shared tableFeatures() registry (filtering, sorting, selection, expanding, sizing/resizing, visibility, row models, sort and filter functions), renames sortingFn to sortFn, adapts to v9 state access, prototype-bound row/cell methods and getRowCanExpand, feeds the DataGridView search state through table state instead of setOptions(), and lets Jest transform the ESM-only @tanstack packages.
Also points the react-checkbox-tree webpack alias at lib/index.js, the 2.1 ESM build, as lib/index.esm.js is a stale 2.0.2 build that leaves the app blank.
* Parse react-frame-component as CommonJS so 5.3 bundles correctly
react-frame-component 5.3 added "type": "module" to its package.json
whilst mapping the "require" condition of its exports to a UMD build.
Babel compiles our imports to require(), so webpack resolves the UMD
file and, honouring the package type, parses it as an ES module. With no
exports object in scope, the UMD wrapper falls through to its browser
global branch and the module exports nothing, so <Frame> is undefined.
Opening the Query Tool then throws React error #130 and unmounts the
whole app, which is why every feature test timed out with the 5.3.2
bump in #10460. Node shows the same packaging fault: require() of the
package returns an empty object.
Tell webpack to parse the package as javascript/auto so the UMD takes
its CommonJS branch. Jest is unaffected, as it loads the UMD as CommonJS.
* Bump react-frame-component to 5.3.2.
Take the version the webpack rule exists for in the same change, so CI
exercises the rule against it.
YAML consumes the quotes around a Compose scalar, so a value such as
PGADMIN_CONFIG_MAIL_SERVER: "mail.example.com" reaches config_distro.py
as a bare name and fails with NameError at startup. Document the nested
quoting needed in map syntax and env_file, how the list form differs,
escaping $ as $$, when config_distro.py is regenerated, and add a Compose
example.
Adds a pgTimeTable Chains node under Servers when pg_timetable is
installed in the maintenance database, with Tasks beneath each chain.
Chains and tasks can be created, edited and deleted, a chain can be
triggered with Run Now, and both have statistics tabs. Includes
regression tests, CI installation of pg_timetable, and documentation.
Reformat pkg/docker/entrypoint.sh to use bash conditionals, arithmetic and
parameter expansion instead of the workarounds needed for BusyBox ash.
The email validation settings and PGADMIN_DEFAULT_EMAIL are now read from
the environment inside Python rather than interpolated into the Python
source, so an address containing an apostrophe works, and a Python error
during validation stops the container instead of passing silently.
Correct 162 entries in the zh_Hans_CN catalogue: strings that are wrong
rather than merely worded differently, plus terminology aligned with
the Chinese PostgreSQL documentation.
- Restore the order of positional %s placeholders in three strings.
The JavaScript gettext fills %s strictly left to right, so the
reordered translations garbled the update notice, the preference
load error and the user password length error.
- Keep the half-width colons in ': error: ', which ProcessDetails.jsx
matches with a regex to highlight error lines, and the SQL comment
marker in '-- missing definition'.
- Fix strings whose meaning was wrong or reversed, e.g. the ERD drag
and drop message, relhassubclass, RESTART IDENTITY, ENABLE REPLICA,
COLLATABLE, CREATE TABLE ... LIKE, and the DBMS Scheduler
"Schedule" object being called a task.
- Give distinct labels to adjacent fields that shared one, and remove
leftovers such as "表(s)", "会话token" and an untranslated
"date/time".
- Use the established Chinese terms where the translation picked the
wrong sense, e.g. Dependents (previously given the word for
dependencies), local variables and the package body.
- Follow the terminology of the Chinese PostgreSQL documentation:
注释 (comment), 字符分类 (LC_CTYPE), 操作符 (operator), 聚簇
(CLUSTER), 提供者 (provider), 流复制 (streaming replication),
预备事务 (prepared transaction), 载荷 (NOTIFY payload) and 过滤
(filter).
- Translate Tab as 标签页 throughout, and the month-day options
1st-31st as 1 日-31 日.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
When an index metadata query failed, the Properties and SQL handlers
could replace the original database error with 'string indices must be
integers', or with an error about using a Response as index metadata.
get_column_details() read rset['rows'] before checking the query status,
and get_column_details(), get_include_details() and get_sql() returned
HTTP responses where their callers expect data.
Raise ExecuteError instead, as get_reverse_engineered_sql() already
does, so the original database error reaches the user. Adds regression
tests covering Properties, SQL, modified SQL and update.
The dialog hardcoded the pgpass flag to false, so it always demanded the
current password even when the connection authenticates via a passfile.
Pass the flag from check_pgpass through to the schema instead.
check_pgpass and change_password also disagreed about when a passfile is
in use: the former treated a password entered at connect but not saved
as a passfile connection, which would now disable the Current Password
field whilst the endpoint still required it. Both now use a shared
is_using_passfile() helper.
Fixes#10449
The Windows, Linux and macOS builds now read the Electron version from
runtime/yarn.lock using 'yarn info electron --json' inside runtime/,
rather than querying the npm registry or using the lower bound of the
range in runtime/package.json. This needs no network access or
node_modules, and packages exactly the version that yarn install
resolves. The build stops with an error if the lockfile is missing or
does not yield a valid version.
A fork with Actions enabled inherits the nightly schedule but none of the
signing secrets or self-hosted runners, so its snapshot fails at the macOS
certificate import and leaves the Windows job queued for a day. The
location job now skips a scheduled run anywhere else, and every build job
waits on it so that the whole run skips with it. A manual dispatch still
runs anywhere.
The test asserted that no X-Remote-User header is sent on the request
after a logout, which only holds in server mode. In desktop mode the
before_request hook logs the desktop user straight back in, so the
header is reported again, and every run-python-tests-pg job (which runs
in desktop mode) has failed on it since #10410. Assert the mode-specific
behaviour instead.
The one-step "signtool sign /fd sha256" fails on the build host with
0xc0000225 before the PIN is asked for, because crypt32 converts the
legacy CryptoAPI key handle to a CNG one for SHA-2 digests, and no CNG
provider is registered under the name of Certum's crypto3 CSP. Rebinding
to cryptoCertum3 KSP is not an option, as it cannot see the Common
profile's code-signing key.
signtool's split flow avoids the conversion, so pkg/win32/sign-files.bat
now does the signing in /dg, /ds, /di, timestamp and verify steps, with
one PIN prompt per call as before. Make.bat's :SIGN_FILES and Inno
Setup's SignTool both go through it, the latter via cmd /c.
Also default PGADMIN_SIGNTOOL_DIR to SDK 10.0.26100.0, the one installed
on the host, and match the PIN dialog on "Logowanie do profilu zwyk" so
that a Secure profile prompt is never answered with the Common PIN.
SIGNING.md gains the full setup procedure, the root cause and the split
signing flow.
A new Debian, Ubuntu, Fedora or EL release is named in seven places across
the build matrices, the snapshot and release workflows, the acceptance tests
and the publishing wrapper, and missing the wrapper leaves the packages built
and uploaded but never indexed. pkg/linux/PLATFORMS.md lists them all, with
the steps for adding a platform, retiring one and the server-side follow-up.
The platform lists in the Debian and Red Hat READMEs had drifted (Ubuntu
25.04, Fedora 40 and 41, RHEL 8), so they now point at the build matrices
instead.
#10355 moved signtool to /fd sha256 on the assumption that the key
provider repair described in pkg/win32/SIGNING.md made SHA-256 possible.
It does not: that repair binds the certificate to the Certum card's
legacy CryptoAPI provider, crypto3 CSP, which fails a SHA-256 file digest
in SignerSign() with 0xc0000225 before the PIN is requested, whilst
/fd sha1 signs and verifies. SHA-1 is what every previous release has
shipped with, so go back to it for both the component and the installer
signatures, and document why.
The timestamp digest stays at SHA-256, which works. Rebinding the
certificate to Certum's CNG provider is the likely route to SHA-256 file
digests, but is untested and is left for later.
The messages that report a missing prerequisite were changed from !VAR!
to %VAR% in commit afa0c773a, since the file does not enable delayed
expansion and they printed the variable name rather than the path. That
made them correct and unparseable at the same time: cmd expands %VAR%
when it parses a parenthesised block rather than when it runs it, and
PGADMIN_INNOTOOL_DIR defaults to "C:\Program Files (x86)\Inno Setup 6",
whose closing parenthesis then ended the IF block early. Every Windows
build has failed at "Checking the environment..." since, with "\Inno was
unexpected at this time", whether or not Inno Setup was installed, which
took the nightly snapshot with it.
The four checks move into a :REQUIRE_DIR subroutine that takes the
directory and the hint, and echoes both outside any block, where a
parenthesis is just a character. Enabling delayed expansion would also
have worked, but only by making ! a metacharacter for the whole file.
The other uses of these two paths are all inside double quotes, which
the parser does respect, so they were never affected.
The 'Change Password...' option on a server node was ambiguous, because it
changes the password of the PostgreSQL role that pgAdmin connects with, yet
read as though it might change the pgAdmin login password instead. The menu
option and its dialog are now called 'Change Server Password', with the
connected role name in the dialog title, and the equivalent option in the
user menu becomes 'Change pgAdmin Password', so that the two can no longer
be confused with one another.
The dialog's own 'User' field was always blank, since the userName prop that
showChangeServerPassword passes was never accepted by ChangePasswordContent;
it is now wired through, which also makes the existing documentation of that
field accurate. The documentation for both dialogs has been updated to match
the new names, along with a couple of button names that had drifted.
Fixes#9230.
Typing in the editor of a View/Edit Data tab promotes it to a Query Tool, but
that promotion only ever updated the component's own state: the tab's saved
metadata kept the original tool URL, with is_query_tool=false in its query
string. On the next start-up the tab was therefore restored as a View/Edit Data
tab, and View/Edit Data tabs execute their query as soon as the connection is
made, so whatever SQL the user had left in the editor ran unprompted. A
restored DELETE or UPDATE running by itself is not a pleasant way to start the
day.
toggleQueryTool now rewrites is_query_tool in the panel's stored toolUrl and
saves the layout, so a promoted tab comes back as a Query Tool and no longer
takes the auto-execute path. The URL is kept relative, because in Desktop mode
the runtime picks a fresh port on every launch and an absolute URL would point
the restored tab at a dead origin.
is_query_tool and restore are also normalised to real booleans up front rather
than compared against the string 'true' in each place they are read, and a
failed restore no longer leaves the editor disabled with nothing logged.
Fixes#10031
Adds Caddy examples alongside the existing NGINX ones for hosting pgAdmin with
Gunicorn or uWSGI, both at the root of a server and under a sub-directory,
mirroring the structure of the NGINX sections. Caddy does not speak the native
uwsgi protocol, so its uWSGI examples run uWSGI in HTTP mode instead.
Documents the two things that must line up for a sub-directory deployment
behind any reverse proxy: the proxy forwarding the request path unmodified
with an X-Script-Name header, and a SCRIPT_NAME environment variable on the
pgAdmin process itself, which is read at startup to set APPLICATION_ROOT and
SESSION_COOKIE_PATH. The missing environment variable is what caused the
"login succeeds then bounces back to the login page" report in #10334.
Also notes that pgAdmin trusts X-Script-Name and X-Scheme unconditionally,
unlike the X-Forwarded-* headers, so every example now either overwrites or
clears both rather than passing on whatever the client sent; warns that the
systemd unit shipped with Caddy sets PrivateTmp=true, which hides a socket
created in the system /tmp; and refreshes the stale python3.7 paths.
The PostgreSQL and Kerberos utilities and libraries we ship in the
Windows package are not signed, whilst the installer, the uninstaller
and pgAdmin4.exe are. Nothing verifies an Authenticode signature when
these run, so this is not a security fix, but it does make life easier
for organisations running application allow-listing under WDAC or
AppLocker, where publisher rules are simpler and more durable than path
or hash based ones, and it cuts down on antivirus false positives.
The signtool invocation moves into a :SIGN_FILES subroutine, and a new
:SIGN_COMPONENTS step signs everything we build ourselves once
:CREATE_RUNTIME_ENV has staged it. Names are matched through DIR /B so
the optional libraries are skipped when absent, and the whole thing is a
no-op when PGADMIN_WINDOWS_CSC is unset, so unsigned developer builds
are unaffected. The Electron, Python and VC++ runtime components are
deliberately left alone, being third party binaries whose own signatures
ours would replace. Everything goes through a single signtool call,
since the hardware token prompts for a PIN on the first signature of a
session.
The file digest moves from SHA-1 to SHA-256 for both the runtime and the
installer. SHA-1 was never a deliberate choice: it dates from the move
to the Certum token, at a point where the certificate in the machine
store had no key provider link, which limits signing to SHA-1. The
buildfarm now repairs that link with certutil when it provisions the
host.
The four optional PostgreSQL libraries were copied without checking the
result; they now abort the build like the others do.
PGADMIN_POSTGRES_DIR and PGADMIN_KRB5_DIR defaulted to an EDB PostgreSQL
installation and an MIT Kerberos for Windows installation respectively,
whereas both sets of binaries have come from winpgbuild for some time
and the buildfarm points at C:\build64\postgresql and C:\build64\krb5.
The four "does not exist" messages that report a missing prerequisite
used !VAR!, but the SETLOCAL at the top of the file does not enable
delayed expansion, so they printed the variable name rather than the
path that was looked for.
Finally, the acceptance test now verifies the signatures on everything
:SIGN_COMPONENTS signs, walking the same list of names where the files
land: none of them exist outside the installer until the installer has
run, so that is the only place a published build can be checked. The
expected publisher is taken from the installer itself rather than hard
coded, so a component signed by somebody else is caught without the
workflow needing an edit when the certificate is renewed.
In the four GitHub Actions workflows the flag is documentation only, since
Yarn's enableImmutableInstalls already defaults to true whenever CI is set in
the environment, but making it explicit means the intent is visible in the
workflow rather than implied by the runner.
The six installs that run outside CI are where this actually changes
behaviour: the Makefile, the Dockerfile, and the pip, Linux, macOS and Windows
packaging builds all run with yarn.lock beside them, and until now any of them
would quietly rewrite the lockfile rather than fail. A release build that
rewrites the lockfile is shipping something other than what we reviewed, so
failing loudly there is the point of the exercise.
The lock was written against the rolling tags because the per-version
releases did not exist when it was generated, and the refresh tool assumed
PostgreSQL was tagged by major, which it is not: the release that never
changes is postgresql-18.6, whilst only the rolling one says 18. The tool
now resolves a major to the version pgbuild's manifest names, and a tag that
does not exist is an error rather than something to fall back from, since a
lock recording an archive nobody can fetch is worse than no lock at all.
A lock change decides which PostgreSQL, OpenSSL and Kerberos binaries end up
inside the installer, so it is exactly the kind of change those checks exist
for, and without this it was the one packaging input that could move without
either of them running.
The install-pgbuild-deps action fetched PostgreSQL, OpenSSL, Kerberos, zstd
and lz4 from pgbuild release tags ending in -latest, whose assets are
replaced in place on every rebuild, and unpacked them with no checksum or
signature check anywhere in between. Since the resulting build is
Authenticode signed on Windows and Developer ID signed and notarised on
macOS, that made write access to pgbuild equivalent to write access to what
pgAdmin ships, and it meant a build was not reproducible either, the same
commit producing different binaries depending on the day it was built. This
is pgadmin-org/pgbuild issue 1.
pkg/pgbuild-deps.lock now records, for every dependency the action can
fetch on either macOS architecture and on Windows, the per-version release
tag to download from and the SHA-256 its archive must hash to. The action
fetches that tag, checks the digest before unpacking, and fails on a
mismatch or on a dependency the lock does not mention, since an unpinned
dependency reaching a signing build is the thing this is meant to prevent.
pgbuild publishes a .sha256 beside each archive, which is deliberately not
consulted: whoever rebuilds an archive rebuilds that too, so the lock is
the only thing here that pins anything.
Being out of date is a different matter from being wrong, so once an
archive is verified the action asks pgbuild what it has published since,
and warns rather than fails when the pinned build is no longer the newest.
That check is best effort and never fails a build whose downloads have
already been verified.
tools/refresh_pgbuild_lock.py rewrites the lock, taking the version to pin
from pgbuild's own manifest.json and working one package at a time so that
bumping OpenSSL does not restate every other digest. The diff it produces,
with the upstream version visible in the tag column, is the audit trail the
previous arrangement had none of.
The digests were taken from the archives pgbuild publishes today, which are
the same archives its per-version releases will carry, but those releases
do not exist yet, so the tags in the lock cannot be fetched until pgbuild's
own change lands.
The deployed files are generated rather than hand written, and that option is
not one we can set there, so the templates described a restriction production
does not have. What a key may do is still bounded by restrict and the forced
command, which is the part that matters; what is lost is the second answer to
where it may be used from.
It has been failing check-python-style since it landed: five lines over the
limit, and two breaks before a binary operator, which count here because
.pycodestyle sets an explicit ignore list and so loses pycodestyle's own
default exemption for W503.
The web server unpacks this into the documentation root and then hands
docloader the directory name pgadmin4-<version>-docs, so a flat archive would
have scattered several hundred HTML files across that root and then failed on
a directory that was never created. Every published tarball carries that top
level; the one assembled here did not, and the first release to notice would
have been the next one.
sync-ftp-to-s3.py and purge_pgadmin_releases.sh have been living in the
pgaweb checkout under tools/, which was never where they belonged: neither
touches Django, the site database or Varnish, and both work on the download
tree at /var/ftp/pgadmin4, so they are part of publishing rather than part of
the website. They move here, renamed to match the hyphenated names of the
scripts already in this directory, and the README gains them in the file
table, in the install line and in the prose that explains what lives where.
The sync-s3 verb now runs sync-ftp-to-s3.py out of PUBLISH_DIR instead of
TOOLS_DIR, which makes this a deployment-ordering change: the wrapper and the
copy of the script it names must both be installed on the download server
before the pgaweb side deletes its own copy, and the comment on the verb says
so. The three verbs that remain genuinely website operations, create-release,
load-docs and purge-cache, still reach into TOOLS_DIR and are untouched.
purge-pgadmin-releases.sh deliberately gets no verb. It is a by-hand
operation that deletes published content, it defaults to a dry run that
somebody is expected to read before re-running with --live, and the rebuild
scripts it pairs with are already documented as standalone; handing a
compromised runner the ability to delete releases would undo most of what the
wrapper is for.
Both scripts are otherwise unchanged apart from the filename in the purge
script's own header comment, and four lines in the Python rewrapped to 79
columns because pkg/ is covered by check-pep8 and the originals were not.
The runner has carried both labels all along, so this is the last of the
machine names out of the tree; roadie can stay on the runner until these have
run green, and then go.
The section named the script and left the reader to work out the rest: where
AutoHotkey comes from, where the script should live given the runner deletes
its own workspace, how it starts in the interactive session alongside the
runner, and how to tell whether it is running at all.
xcrun notarytool wait polls Apple for as long as the notarisation takes, and
exits non-zero if any single poll fails, so a runner losing its route for a few
seconds failed a notarisation that was proceeding perfectly well: the 22nd
September snapshot died twenty six minutes into waiting for a DMG Apple had
already accepted, with "The Internet connection appears to be offline".
The wait is now retried ten times at a minute apart, without resubmitting,
since the submission is still queued under the same ID. Each failure asks for
the status directly, because the wait also exits non-zero when a submission
genuinely ends as Invalid or Rejected, and any terminal status ends the loop
and leaves the existing check to decide what it means.
Every one of these is a way that a verb meant only to add something would
quietly change something already published, which is the failure the wrapper
exists to prevent, so they are worth having together.
Snapshot retention kept the newest five directories, and a directory is
something either key can create: five dated today with high suffixes filled
the keep set and every genuine snapshot was deleted. It now keeps every
directory whose date is among the newest five that are not in the future, so
retention is counted in days, and the intended consequence is that a day with
several builds keeps all of them.
release-fetch required only that v<VERSION> existed, and shutil.move onto an
existing file replaces it, so a release published months ago could be
overwritten and autoupdate-publish and sync-s3 would then carry the
replacement to the auto-updater and into an archive that has no undo. The
single-shot guarantee that release-create made for the first verb now covers
the whole sequence: it refuses before moving anything if .incoming exists or
if a content directory holds more than its maintainer marker, and refuses each
individual move whose destination is already there.
packages-fetch pulled with no --ignore-existing, so a staging package whose
filename matched a published one replaced it and the following rebuild signed
the new bytes. It now leaves the live copy alone and reports what it skipped,
since a collision during a real promotion means two builds have produced one
filename.
autoupdate-publish deleted the live archives before moving the new ones in,
which left a window with nothing there at all; the order is reversed and only
archives this release did not replace are removed, and once every move has
succeeded. --delay-updates leaves .~tmp~ behind in the live trees when a
transfer is interrupted, and both indexers descend into it, so the holding
directories are swept immediately before indexing rather than moved out of the
tree with --partial-dir, which would make the final rename a copy across
filesystems. DATESTAMP_RE and VERSION_RE gain re.ASCII, because \d matches any
Unicode decimal digit and int() parses those, so a fullwidth datestamp on the
filesystem read back as an ordinary date.
In the workflows, the repository variables naming the SSH target and the key
paths reached option positions, as the first positional argument to ssh and
inside the -e string rsync splits on whitespace. Only an administrator can set
those, so this is defence in depth: they are validated against a conservative
pattern in the step that already checks the keys are present, and the target
is passed after --. snapshot.yml passed the raw inputs.location to its publish
step rather than the validated output of the location job, which agreed only
by coincidence, and it now carries a flag for whether a location was named so
that an empty input still means the assembled directory name.
check-container-build.yml gains the top-level permissions: contents: read the
other workflows have, leaving the publish job's packages: write alone, and
promote-release.yml verifies the wheel's detached signature against the
published signing key before uploading it to PyPI, the mirror having been
trusted on TLS alone until now.
The README and the download server's key annotations are updated to match: the
guarantees they stated about single-shot publication and about retention were
overstated, and are now true.
The script that answers the signing card's PIN prompt lived only in the
buildfarm repository, as an Ansible template with the PIN itself interpolated
into it. That repository is going away, and the template could not come with it
as it stood, so this is the same script with the secret taken out and the two
faults that cost a build this morning fixed.
The PIN now comes from a file in the build user's local application data, read
once at startup, so neither this repository nor the compiled executable carries
it; the helper says so and exits if the file is missing or empty, rather than
sitting in the tray looking healthy whilst answering nothing.
The build was lost to a race. The original waited for the dialog and then
activated it as two separate operations, and when the activate lost that race
and threw "Target window not found", AutoHotkey put up its default error dialog
on a machine with nobody to dismiss it, so one transient fault became an
indefinite hang. So the work now happens on a timer rather than in a loop that
can end, activation is retried for a couple of seconds and addressed by window
handle rather than by title, a window that vanishes mid-operation is a log line
and a return rather than an error, and an OnError handler makes it impossible
for anything to put a dialog on the screen at all.
Whilst there, the PIN is sent with SendText rather than Send, which would have
read {, +, ^, ! and # in it as modifiers, and it is only sent once WinActive
confirms the card's own dialog has the focus, because a blind send on a build
machine is a good way to type a PIN into a console whose output gets uploaded.
The title is matched on its leading unaccented substring, which keeps the file
to plain ASCII and survives proCertum wording changes.
SIGNING.md gains the operator's half of that: where the PIN file goes, the
icacls to lock it down, where the log is, and what each thing the log can say
means when a signed build hangs.
The promotion validated its inputs in the promote job, which the registries
scope skips, so under that scope the typed-twice confirmation did not cover
the step that moves the latest tag; validation moves to a job both paths
depend on. The container tag loops no longer glob against the workspace, and
check-container-build validates the location it interpolates into a push tag
in a job holding the Docker Hub credentials.
The acceptance test built its apt sources line with sh -c, which re-parsed the
expanded base URL as source: the one place in the pipeline where a
caller-supplied value reached a shell. The outer shell writes the file now,
and the URL is checked once, against a host allowlist, before any job
downloads and installs what that host serves as root.
The actions in jobs holding registry or trusted-publishing credentials are
pinned by commit rather than by a movable tag, as the SonarQube action already
was, and so is the helm installer piped into a shell beside them.
Also: the README understated the signing verbs, which select by extension
across the tree rather than by anything tied to a build.
snapshot-purge sorted directory names and kept the last five, so a name that
merely sorted late survived and everything real fell off the end: five
snapshot-create calls with a future datestamp, which the verb accepts up to
two days ahead, were enough to have the next purge delete every genuine
snapshot. Anything dated after today is now left out of the retention set
entirely, and the ordering is by date and then by suffix as a number, which
also fixes 2026-09-22-10 sorting before 2026-09-22-2 and so a day with more
than nine builds keeping the older ones.
packages-fetch pulled staging's Packages files and repodata into the live
tree, whilst the signed Release, Release.gpg and InRelease they are checked
against live a directory further up and were left as they were. Every live
apt repository therefore failed on a hash sum mismatch from the moment the
fetch landed until that codename's rebuild caught up, which with five
sequential rebuilds each taking the lock is minutes. The docstring already
claimed the indices were left behind; now they are.
The pull key is renamed to pga-pull to match the other two.
The machine names were only ever in comments and two filenames, but the
repository is public and the buildfarm never was, so there is no reason to hand
a reader the mapping from internal name to public service. The two
authorized_keys templates become .staging and .download, and the prose refers
to the staging server, the download server and the runner.
The pull key the download server uses is renamed to match, so it needs moving
on that host: ~pgaupload/.ssh/id_ed25519_paxsor_pull -> id_ed25519_pull. The
runner's own key paths come from repository variables, so the defaults here
changing affects nothing.
Both publishing steps were gated on inputs.publish, which exists so that a
dispatched test run builds without uploading. A schedule event carries no
inputs, so that expression was null rather than false and the nightly built
everything, assembled the tree and threw it away, reporting success. Publish
unconditionally on a schedule, and keep the input for dispatches.
The container re-tag and the chart push need no download server and touch
nothing that cannot be deleted, but they were reachable only through a real
promotion, which is the one thing we cannot practise. A "registries" scope
skips the promote job entirely and runs just those two, leaving the PyPI
upload out since a rehearsal has no published wheel to upload.
The Docker Hub credentials are environment secrets, and an environment
secret is not available to a job that does not name the environment.
secrets: inherit passes repository secrets, so the login failed with
'Username and password required' and would have done the same in a real
candidate run.
Splitting it also keeps the credentials away from the build check, which
runs on every pull request and has no business being able to push.
The last thing in the release process still needing Jenkins. Promotion
purges the cache, but the website edits that follow a release happen
afterwards and are all cached, so there has to be a way to purge it on
its own.
No arguments: the verb behind it clears everything, and there is nothing
for a caller to choose.
The last three things still running from Jenkins, and the reason the
buildfarm could not be switched off.
The container is built for both architectures and pushed under the
candidate's location, then re-tagged at promotion with buildx imagetools
rather than rebuilt, so what ships is the manifest the acceptance test
ran against. It goes to Docker Hub and to GHCR: Docker Hub stays
canonical because every tutorial and compose file in the world names
dpage/pgadmin4, whilst GHCR is organisation owned, needs no stored
credential and is not subject to Docker Hub's anonymous pull limits.
The Helm chart moves to GHCR outright. Its version comes from Chart.yaml
so there is no candidate copy to re-tag, its install base is a fraction
of the container's, and oci://docker.io/dpage was an odd home for a
chart.
The wheel is fetched from the published release rather than rebuilt, so
what reaches PyPI is the file the acceptance test installed, and it goes
up by trusted publishing, which exchanges the workflow's own identity for
a short-lived credential rather than holding an API token.
All three run in their own job on a hosted runner. They need none of the
publishing keys, so the machine that holds those never handles registry
credentials, and a registry outage cannot leave a half-finished
promotion on the download server.
It was the last SITE value the repository had to get right, and it was a
fully qualified internal hostname in a public tree.
It is read here rather than accepted from the caller, which is the part
that matters: the client names a verb and a datestamp and never a host,
a path, a binary or a signing key, and that is what stops a compromised
runner asking for a signature over something it fetched from a source of
its own choosing. A workflow variable would put it on the wrong side of
that line. It is validated as a hostname on the way in, since it becomes
part of an rsync remote specification.
Every directory under a release carries a CURRENT_MAINTAINER naming the
support address, which the PostgreSQL mirror network expects and which
the buildfarm wrote with an echo in each of nine job definitions. The
rehearsal produced a release directory without them.
It is a file beside the scripts now, so changing the support address is
one reviewed commit.
Two faults the first working rehearsal exposed.
rsync without -r reports 'skipping directory .' and transfers nothing,
so release-fetch created the directory, sorted no files and reported
success. The comment claiming -r was omitted on purpose was simply
wrong; the trees are excluded by pattern instead.
The auto-updater archives were being moved by release-fetch, which
writes them outside v<VERSION>. That contradicted the whole point of the
release-directory scope, which is that deleting one directory undoes it.
They are now autoupdate-publish's job, and it runs with the packages,
where the promotion stops being reversible anyway.
PULL_HOST carried the short name, one of the SITE values never confirmed
against the servers, and the first real release-fetch failed on it. The
firewall now permits the pull and the host key is recorded on the
download server under both names; the fully qualified one is used here so
that what this file names and what known_hosts records cannot drift.
Three settings rather than a boolean, because the steps differ in how
undoable they are and that is what somebody needs to choose between.
release-directory writes only /var/ftp/pgadmin4/v<VERSION>, so deleting
that directory undoes it completely; it is the only setting that leaves
the live repositories alone, and so the only one that can be rehearsed.
files goes on to copy packages into the production trees and re-sign
every index clients read. everything adds the website and the S3
archive, neither of which can be undone.
The default is the safe one, on the grounds that a full promotion should
be a deliberate choice rather than what happens if nobody reads the
form.
The website update and the S3 archive are the two steps with no undo:
one writes to the website database, the other copies into a bucket whose
sync carries no --delete. files-only skips both, so a promotion can be
rehearsed against a throwaway version and the files deleted again before
the hourly sync carries them to the mirrors.
The last piece, replacing pgadmin4-all-publish-qa-to-prod and the deb
and rpm promotion jobs, plus update-download-archive and
pgadmin4-website-create-release.
It builds nothing. It takes a candidate that already exists on the
development server, having been built, signed, indexed and installed
from by the acceptance test, and moves it across: the release directory,
the downloads sorted into it, the packages into the live repository
trees, then the indices, the READMEs, the website and finally the S3
archive. The order is deliberate, since an interruption before the
indices leaves packages nothing points at, which is recoverable, whereas
the reverse is a repository describing packages that are not there.
Two changes to the wrapper it depends on.
release-fetch assumed the staging build was divided into docs, macos,
pip, source and windows. It is not: a staging build is flat, and it is
promotion that sorts the downloads into those directories, which the
buildfarm did with a series of scp commands carrying shell globs. That
sorting is now a table, the destination comes from each file's own name,
and an unrecognised file stops the publication rather than being dropped
silently. It also handles the auto-updater ZIPs, which replace the
previous release's rather than accumulating beside them, and which
nothing here had carried across at all.
rebuild-readme is new, and closes the gap left when the README generator
went in: nothing regenerated the production platform tables, only the
staging and snapshot ones. Those carry the release history, so they
change with every release.
The macOS jobs failed on 'Disallowed xattr com.apple.FinderInfo', which
is not a signature problem: _create_dmg runs SetFile -a E on the bundle
to hide the .app extension in the disk image window, and that writes the
attribute --strict refuses to see.
What decides whether a user can open the application is Gatekeeper, so
spctl and stapler run first and are the pass or fail. --strict still
runs, and reports rather than fails, so that a future signature problem
is still visible and distinguishable from the attribute we put there
deliberately.
The apt and yum halves proved a user can install from the repositories.
These cover everything else a user can download.
Windows is the most valuable, because it closes a gap rather than
confirming something already checked: nothing had ever run the installer,
and nothing verified the Authenticode signature on the pgAdmin4.exe
inside it. Make.bat's own check tested a stale %ERRORLEVEL%, and the
workflow step that claimed to cover it re-verified the installer instead.
That binary does not exist outside the installer until the installer has
run, so this is the only place it can be verified.
macOS mounts the published disk image rather than inspecting the bundle
that was just built, with the quarantine attribute a browser would set so
Gatekeeper is actually consulted. It also runs otool over the bundled
client binaries and fails if any still points at /opt/pgbuild, Homebrew
or /usr/local, which is what a dependency that was linked but not
relocated looks like.
The rest is deliberately shallow: the wheel is installed into a venv and
imported, the tarballs are checked for a couple of known paths, the PDF
and ePub are checked to be those formats and not truncated, and every
detached signature is verified from the published URL rather than on the
server, since that is the copy a user actually fetches.
Both halves installed correctly and then failed the assertion that they
had: grep -q exits on the first match, dpkg -L and rpm -ql die of
SIGPIPE, and pipefail reports the pipeline as failed. The packages do
ship files under /usr/pgadmin4, as dpkg-deb -c on the published .deb
confirms.
setup-web.sh ships in pgadmin4-web, not pgadmin4-server, so the apt jobs
installed correctly and then failed on an assertion about a file that
package never contained. The check is now that the package delivered
files under /usr/pgadmin4, which cannot be wrong about which package
owns what.
The yum jobs never got as far as installing: dnf install curl conflicts
with the curl-minimal already in those images, and curl was not needed
in the first place.
Everything else checks that files exist, that indexes parse and that
signatures validate. All three can be true of a repository apt still
refuses, and both bugs the migration turned up were that shape: packages
one directory too deep, and noarch RPMs filed where no dnf client looks.
Neither failed a build.
So this inspects nothing. It follows the instructions in the repository
README, in a clean container, on all five apt platforms and all four yum
ones, and installs the package. No --allow-unauthenticated, no
--nogpgcheck, no pinning, so a wrong signature or an architecture missing
from Release fails the job.
It runs against a candidate rather than a snapshot because the
development server publishes its tree immediately, whilst the download
server sits behind an hourly sync to the mirrors that would otherwise be
most of what the test measured.
Promotion stays a human decision, so this gates nothing automatically; a
red result is simply a candidate that must not be promoted.
GitHub evaluates workflow expressions inside run blocks before the shell
sees them, comments included, so an empty one is a parse error: the
whole file is rejected with 'An expression was expected' and the
workflow cannot be dispatched or scheduled at all.
snapshot.yml has carried this since the review fixes landed, which would
have failed tonight's nightly, and release-candidate.yml inherited it.
Phase one of moving the release pipeline off the buildfarm, replacing
the pgadmin4-*-build jobs. The same builds as the nightly snapshot,
calling the same workflows, but published to procyon through the
wrapper's staging verbs rather than to the download server.
The difference is what the tree is for. A snapshot is finished when it
is published; a candidate is published so that it can be tested, and
promoted afterwards. developer.pgadmin.org serves the staging tree over
HTTP the moment it is written, so a candidate's repositories can be
added and installed from immediately, whereas the download server sits
behind an hourly sync to the mirrors.
Never scheduled, and publishing is off unless asked for, because a
candidate exists only because somebody decided to make one.
Needs a STAGING_SSH_TARGET variable on the publishing environment; the
keys and the wrapper are already deployed on procyon.
When a schema field uses a dynamic type function (type as a function)
and is rendered inside a collection (e.g. chain tasks), the function
always received the top-level schema data instead of the row-level
data. This caused the field to fall through to its default type,
ignoring the actual field values in the row.
Resolve the type callback's state against the field's own container
instead, mirroring what cell() already gets via its row argument: for a
field inside a collection row that is the row, and for a top-level
field the parent path is empty, so the whole schema data comes back as
before. Nested tabs and fieldsets carry no id, so they do not extend
the access path and are unaffected.
As well as the pg_timetable case that prompted this, it repairs the
expanded-row Definition forms that were already relying on row state
and silently getting the table's: the foreign key 'Referencing'
column list, the partition name control's attach/create switch, and
the column dialog's identity/generated constraint options.
When restoring saved Query Tool tabs, or switching to a connection with no
saved password, the backend replies with HTTP 428 and the 'Connect to server'
dialog opens, but obtaining_conn was left set to true whilst waiting for the
user. ConnectionBar renders MUI's CircularProgress from that flag, so every
affected tab kept two infinite CSS animations running at the display refresh
rate, costing around 30-35% CPU whilst completely idle.
obtaining_conn is now cleared when the 428 arrives and set again only whilst a
connection request is actually in flight, with a promptCallback threaded
through connectServer() so that password retries behave the same way. Along the
way this also fixes cancellation leaving obtaining_conn stuck on with the
Promise never settling, the New Connection dialog closing itself on a failed
connection rather than reporting the error, and duplicate entries being added
to the connection list when a role was an empty string rather than null.
Fixes#10386
GitHub evaluates workflow expressions inside run blocks before the shell
sees them, comments included, so an empty one is a parse error: the file
is rejected with 'An expression was expected' and the workflow cannot be
dispatched or scheduled at all. Tonight's nightly would not have run.
html-react-parser 6 is ESM only, and although webpack handles that without
complaint, Jest runs the suite as CommonJS and only transforms the packages
named in the allowlist in transformIgnorePatterns, so every suite that
reached a dialog died with "Must use import to load ES Module" from
domhandler. Add domhandler, domelementtype and htmlparser2 to the allowlist,
which is the smallest set that untangles the chain.
The allowlist approach fails closed and will keep catching us out on each
ESM-only bump; #10443 covers inverting it properly.
.gitattributes normalises everything to LF, which rewrote the whole file
the first time this branch touched it and turned a two-line merge into a
whole-file conflict. Make.bat has been stored with CRLF since long before
that rule, so it is marked -text to leave it that way.
The buildfarm-to-actions trigger existed so the branch could be tested
before it landed, which it no longer needs to be.
The four that filter on web/** also gain the message catalogue exclusion
that #10441 applied to the workflows already on master, for the same
reason: a .po change cannot break a package build, and check-translations
compiles the catalogues anyway.
Flask-Login has already cleared current_user by the time after_request
runs on a logout, so the X-Remote-User header, and with it the username
in the HTTP access log, was missing for exactly the requests that record
someone leaving. Stash the name on flask.g in before_request whilst it is
still available, and fall back to it when setting the header.
Signed-off-by: Grégoire Bellon-Gervais <gregoire.bellon-gervais@docaposte.fr>
The user menu avatar was always hashed from the local pgAdmin username,
so a user whose Gravatar profile is registered against their email
address (commonly the case when the account came from an OAuth2
provider) got a default avatar, or worse, the avatar belonging to
somebody else who happens to share that username. The email address is
now used where one is known, falling back to the username otherwise.
Fixes#10415
Bring the ja catalogue up to full coverage against the current message
template, taking it from 3313 to 3684 translated strings, and refine
eleven existing translations. The compiled catalogue is included and
matches the source exactly.
Refresh the zh_Hans_CN catalogue against the current message template,
taking it from 2681 to 3684 translated strings, so every message in
messages.pot is now covered. A further 1419 existing translations were
revised for consistency and accuracy, including a handful that were
plainly wrong ("Drop Table/Relation" rendered as "add table/relation").
Message catalogues live under web/, so every workflow filtering on web/**
fires for a .po change: both feature test suites, all three database test
suites, the container build and the tarball and wheel builds. None of them
can be broken by a translation, and check-translations already runs
msg-compile, which is the check that would catch a malformed catalogue.
The two JavaScript workflows need no change, their filters being narrow
enough to miss .po files already.
PostgreSQL 18 and later are now built with GSSAPI support, so the
libpq.dll we ship has a load-time dependency on gssapi64.dll where it
previously had none. The installer staged the MIT Kerberos runtime into
the python directory only, which left two problems.
pg_dump.exe, pg_dumpall.exe, pg_restore.exe and psql.exe live in the
runtime directory next to libpq.dll, and could no longer load it, so
backup and restore failed with a complaint that gssapi64.dll could not
be found. Adding the python directory to PATH worked around it.
Connections crashed with "exception: access violation writing
0x0000000000000000" against any server that accepts GSSAPI encryption,
which is most Linux and cloud builds, whilst servers that decline it
carried on working. We never staged krbcc64.dll, and on Windows krb5
statically defaults krb5_cc_dfl_ops to the CCAPI credential cache and
only switches away from it if that DLL loads, so its entry points were
left null and the first use of the default credential cache called
address zero. libpq asks for a credential cache on every connection
under the default gssencmode of prefer, and because psycopg falls back
to its ctypes binding on Windows, CPython caught the access violation
and reported it in the Connect to Server dialog rather than taking the
process down.
Stage the Kerberos runtime into the runtime directory instead, once,
adding krbcc64.dll and the ccapiserver.exe it drives. That serves the
client binaries directly, and serves libpq loaded by psycopg too, since
the runtime directory is prepended to PATH before Python is spawned and
a DLL's own directory is searched for its dependencies. Nothing is left
behind on upgrade, because the installer already deletes the python
directory before it installs. Whilst here, correct the build
instructions, which still claimed that the PostgreSQL build has no
Kerberos support, and fix a stray "L%" on the end of the pg_dumpall.exe
copy, which stopped a failure there aborting the build.
Issue #10428
Ten fixes from a review pass with fresh eyes. The ones that mattered:
rebuild-yum-repo.sh signed repomd.xml with no -u, so it used whatever
the keyring's default key resolved to. That is the packaging key today
only because there is one secret key in it. A repository whose metadata
is signed by the wrong key is one every client with gpgcheck=1 rejects,
produced by a run that exits 0.
The snapshot publish gate required only deb and rpm, whilst its comment
justified exactly one exception. A failed docs, tarball or wheel job
published a snapshot missing those files, and a failed repo-rpm job was
worse: install-repo-readme.py then fails after the packages are uploaded
and signed, leaving half a snapshot on the download server.
The location input was interpolated into shell on the runner holding both
publishing keys. It now travels through the environment, and is validated
against the shape of a datestamp before anything downstream sees it, so a
bad one fails in five seconds rather than after an hour of building.
Make.bat tested %ERRORLEVEL% inside a parenthesised block, where it is
expanded at parse time, so a failed Authenticode signature on
pgAdmin4.exe was undetectable; the handler was PAUSE, which on a headless
signing host either returns at once or blocks until the job times out.
Both sites now fail the build.
A called workflow inherits the caller's ref, so once this is on master an
ordinary push would have shared a concurrency group with the nightly
snapshot's builds and cancelled them.
And PULL_KEY named a file that does not exist on the download server,
which would have failed the first release promotion.
The rest: if-no-files-found on every build artefact, so a build that
produces nothing fails rather than silently shipping a snapshot without
it; pinned syft and helm installers, which were piping a default branch
into a shell as root in the container that builds signed packages; the
Helm chart no longer built and discarded; and the comments that had
stopped describing the code.
The self-test grows from 56 cases to 70, covering the verbs that delete
things, arguments that look like options, and the SSH_ORIGINAL_COMMAND
entry point rather than only the dry-run path.
rpmsign refuses to run without %_gpg_name, and the buildfarm satisfied
that with a ~/.rpmmacros on each build agent. Signing has moved to the
download server, which has never signed an RPM and has no such file, so
the first real snapshot-sign failed there.
Passing it explicitly suits this better than adding the dotfile: the key
is already a constant in this file, for the same reason the client cannot
name one, and the result no longer depends on whose home directory the
wrapper runs in.
roadie's workspace persists between jobs, so yesterday's aborted run
left stage/2026-09-17-2 behind and today's run found two dated
directories where it expected one. The guard caught it and refused to
guess, which is the right outcome, but the tree should not have been
there to find.
The merged assemble job begins with a checkout, which cleans the
workspace as a side effect, and this does not depend on that.
The path filters that just landed on master cover the workflows that
exist there. These five are new on this branch and were still firing on
every push, which is what starved the macOS runners repeatedly today:
each documentation or workflow commit started two macOS appbundle builds
that no change could possibly have affected.
Each now lists what it builds from. The two that consume pgbuild also
list the composite action that fetches it, since a change to how the
dependencies are installed changes the build as surely as the packaging
does.
* Only run each CI workflow when something it tests has changed
Every workflow fired on every push and every pull request, so a commit
touching nothing but documentation or a workflow file started the whole
matrix: two macOS jobs, the EPAS and PostgreSQL feature tests, the
container build and the rest. That is slow for the contributor waiting
on a green tick and wasteful of the hosted runners, and macOS capacity
in particular is the one resource we can actually exhaust.
Each workflow now lists the paths it depends on, and its own file, so
editing it still runs it. The scope is deliberately per workflow rather
than shared: eslint and Jest care about the JavaScript and the files
that configure it, pycodestyle cares about Python anywhere in the tree,
the documentation build cares about docs plus the version and branding
it renders, and the packaging checks care about their own pkg
subdirectory alongside the application.
sonarqube-scan is left alone deliberately, being a whole-repository
quality scan rather than a test of any particular thing.
* Widen the path filters to inputs I had missed
All eight review findings check out against the tree, so all eight are
applied:
- The container build copies docs into the image, and .dockerignore
decides what the build context contains at all.
- The documentation build imports web/config.py from conf.py.
- make install-python runs tools/setup-python-env.sh, so it belongs in
every workflow that calls it, which is eight of them.
- pkg/pip/build.sh packages LICENSE and README.md and builds the docs;
pkg/src/build.sh does much the same for the tarball.
- The Yarn and Babel configuration, web/.yarnrc.yml and
web/babel.config.json, change what eslint and Jest do, and the
JavaScript workflows filter on JavaScript rather than all of web/.
- tools/get_sb_package.py is fetched by the PostgreSQL and EPAS Python
test workflows.
One deviation: get_sb_package.py goes to run-python-tests-pg and
run-python-tests-epas, which use it, rather than to server-mode, which
does not.
Splitting the two meant assembling a tree of several hundred megabytes
on a hosted runner, uploading it as an artifact, and immediately
downloading it again on roadie, which is where it was going anyway. The
hosted runner also has to be waited for: the second end-to-end run had
every build finished and then sat queued for assemble behind nine
unrelated master workflows.
Roadie is dedicated and nothing else queues for it, so the job now runs
there start to finish. Only the publishing steps are guarded by the
publish input, so a dispatch without it still proves the tree assembles,
which was the other reason for keeping them apart.
What goes is the assembled tree as a downloadable artifact. It was a
copy of something that ends up on the download server, where it can be
looked at directly.
The pre-flight check tested only that the key file was readable, which a
.pub file also is, so a variable naming the wrong half of the pair passed
here and failed later inside ssh with a message that does not say which
file it objected to. Both halves live in one directory with names a
suffix apart, so it is an easy thing to get wrong and a cheap thing to
catch.
The first end-to-end run assembled yum/redhat/rhel-9-noarch and three
more like it, because the architecture was taken from each package's own
filename. pgadmin4 and pgadmin4-web are noarch, so on a real publish the
metapackage and the web package would have been missing from every
repository: dnf asks for <name>-<version>-$basearch and never looks in a
noarch directory. The live snapshots have them in rhel-9-x86_64
alongside the rest, which is what this now produces.
x86_64 is a constant here because that is the whole RPM matrix today; it
becomes a property of the build when aarch64 arrives.
Found by diffing the assembled tree against a snapshot the buildfarm
produced, rather than by the build failing, which it would not have.
The Windows one still pointed at dpage/winpgbuild and told people to dig
a binaries asset out of a workflow run. pgbuild publishes proper releases
now, each tagged *-latest, so both platforms get a predictable URL and
the same binaries CI uses. syft is added to the Chocolatey list, the
build having needed it since the SBOM step went in.
The macOS one claimed Python 3.6+, which has not been true for a while
and is now doubly confusing given the build downloads the interpreter it
bundles: the two are separated, and the version that ships is pointed at
pkg/python-version.txt. It also never mentioned syft or wget, both of
which the build needs, or that Homebrew's PostgreSQL lacks the zstd and
lz4 support ours is built with.
github.workflow in a reusable workflow is the CALLER's name, so every
build Snapshot calls landed in one group, 'Snapshot @ <ref>', with
cancel-in-progress set. Each called build therefore cancelled the one
before it: the first dispatched run finished with deb, rpm, docs,
tarball, wheel and helm all cancelled, and only the two jobs defined
inline in the orchestrator surviving.
Each workflow now names its own group literally.
Also restores snapshot-macos.yml, which the previous commit truncated to
its trigger block: the expression used to strip the push trigger matched
across the rest of the file and took the concurrency settings, the
permissions and the entire appbundle job with it.
A push trigger was never the intent; it was there because a workflow off
the default branch has no dispatch button, and it is what caused the
orchestrator to run alongside the standalone signing workflows it calls.
Nightly at 01:01, which is when pgadmin4-all-snapshot ran, plus
workflow_dispatch for testing and reruns.
They each had a push trigger, added when they were standalone and there
was no dispatch button for a workflow off the default branch. Now that
the orchestrator calls them, a push touching pkg/mac or pkg/win32 starts
them twice: once directly and once through Snapshot. The first push
after they became callable did exactly that, and the resulting contention
for the macOS runners and the one Windows signing host left the real
dispatched run queued behind its own duplicate.
They keep workflow_dispatch, so either can still be run alone.
Every snapshot on the download server carries
pgadmin4-fedora-repo-2-1.noarch.rpm and its redhat counterpart at the
root of yum/, and the README tells people to install one before anything
else. Nothing in the new flow produced them, so install-repo-readme.py
could not name them and snapshot-index-yum would have failed.
They are not a build of pgAdmin, which is why no QA workflow makes them:
each is a single .repo file pointing at the tree it ships in. That is
also why the destination has to be known before they are built, and why
the directory name is now decided once by its own job rather than by
each job calling date. Two jobs calling date independently would, on a
run straddling midnight, have shipped a repository configuration
pointing at a snapshot that does not exist.
They are left unsigned here and signed on the server with everything
else, since they land under yum/ where snapshot-sign finds them.
Verifying against paxsor and procyon before the first run turned up that
the flow had the wrong shape, which would have produced a snapshot
nobody could install from.
A staging build and a snapshot are flat: apt/, yum/, and the
downloadable files loose beside them. It is only a published release
that is divided into docs/, macos/, pip/, source/ and windows/, and the
promotion job sorts files into those on the way to v<VERSION>. We had
the release layout applied to all three, so stage-create and
snapshot-create made directories nothing uses, the assembled tree put
every file one level too deep, and sign_packages looked for things to
sign in directories that would have been empty. A snapshot would have
gone out unsigned.
Signing is now driven by extension and location rather than by a list of
directories, so it is right for both layouts: RPMs under yum/ signed in
place, and detached signatures for the tarball, wheel, documentation
tarball, PDF and ePub wherever they sit. Debs are left alone, apt
verifying them through the signed Release file, as are the disk images,
ZIPs and installer, which carry signatures of their own.
The documentation also gets versioned filenames, the build leaving them
as pgadmin4.pdf and pgAdmin4.epub whilst the download site has always
carried pgadmin4-<version>.pdf and .epub.
pkg/redhat/build.sh bundles postgresqlNN-libs into the yum trees,
choosing NN from the highest /usr/pgsql-1* on the build host rather than
from a constant, so the version that ships is decided by what the build
environment installs. Ours installed postgresql17-devel, so the
repositories carried postgresql17-libs alongside a libpq5 from 18.
pkg/redhat/setup.sh was staler again at postgresql15-devel, in all three
of its distribution branches.
snapshot-purge ran at the end of every publish, so the first test run
would have deleted the older snapshots the buildfarm put on the download
server, which is exactly what we agreed a test must not touch. A run
that names its own directory is a test by definition, so that is the
signal to leave retention alone.
Also fails rather than guessing when the artefact does not contain
exactly one dated directory, and checks for ssh and rsync up front.
I had the two flows conflated. Release candidates are staged on the
development server and promoted; snapshots are published straight to the
download server under snapshots/<date>, which is what every
*-snapshot job in the buildfarm targets.
So the publish job talks to the download server, the wrapper grows the
snapshot verbs it needed and loses snapshot-fetch, which existed only to
copy a staging build that is never made, and the variable is named for
the server it actually names.
It also reverses my advice about the upload key on the download server,
which I had said to drop on the reasoning that publication always pulls
from procyon. It does not, for snapshots. The key comes back, confined
to the snapshots tree, which is the right root on two counts: it is
where snapshots belong, and it is the one part of the download site that
the S3 sync excludes, so nothing written through it can reach the
archive or the mirrors.
snapshot-purge replaces the buildfarm's ls | tail | xargs rm. It is the
only verb that deletes anything, so it takes no argument: it cannot be
asked to remove a particular snapshot, only to enforce the retention the
server already has, and it ignores directories that do not look like a
snapshot.
They are already on roadie. Putting copies in the secret store would
mean two places to rotate them and two places to leak them from, so the
workflow names the paths and never handles key material.
What changes with it is where the boundary sits. An environment secret
is gated by a deployment branch policy; a file on disk is gated by which
workflows may run on that machine. This repository is public, so roadie
needs to be in a runner group restricted to these workflows, with
nothing triggered by pull_request targeting it. That is the same bargain
the Windows signing host already makes with the Certum token, but it is
now load-bearing in a second place, so it is written down.
The environment stays for the branch policy and the deployment record.
The last missing link: assemble produced a tree and nothing carried it
anywhere. The publish job uploads it over the rrsync-confined key and
then asks pga-publish to sign and index it, so the runner names a verb
and a datestamp rather than a command, and the signing happens where the
key is.
The staging directory can be named explicitly, because the buildfarm
writes <date> on that same server and a test run has no business landing
on the directory Jenkins is still filling. Publication is off by default
for the same reason: it takes an explicit input, so a dispatch that only
wants to know whether everything builds cannot reach the servers at all.
Also adds the deployment notes, which is where the four files go on each
host, what the two configuration files hold, and why no key gets a
shell.
It was drafted from the Jenkins job definitions before anybody had
looked at the servers, and most of what it assumed about them was wrong.
The apt layout is per distribution release, with the packages sitting
directly in binary-<arch> and no shared pool, so packages-fetch and
stage-create were writing to paths that do not exist. The allowlists
were stale in both directions: plucky and RHEL 8 are not built, whilst
resolute, Fedora 43 and 44 were missing. sync-ftp-to-s3.py takes the
CloudFront distribution to invalidate rather than a choice of what to
sync, so that verb loses its argument entirely and reads the identifier
from the server, since the client has nothing to choose.
The indexing is no longer implemented here. It calls the scripts in this
directory instead, which are the same ones an administrator runs by hand
after purging old releases, so there is one implementation rather than
the four the buildfarm had. That also settles the doubled signing the
draft worried about: the scripts sign as part of indexing.
New verb: stage-sign. With the GPG key staying on the servers rather
than moving into Actions secrets, the workflow uploads unsigned
artefacts and asks for them to be signed here. A compromised workflow
can therefore ask for a signature over something it just uploaded, which
is real and acknowledged; what it cannot do is hold the key, sign out of
band, or sign with any other key.
The self-test covers 56 cases and passes.
Replaces pgadmin4-all-snapshot and the dozen per-artefact snapshot jobs
it drove. Those were separate job definitions carrying the same commands
as their QA counterparts, which is how a snapshot comes to differ from
what QA tested; here the build workflows gain a workflow_call trigger
and the orchestrator calls them, so there is one definition of each
build.
Nothing in it signs anything beyond the macOS notarisation and the
Windows signature, which have to happen where their credentials are. The
GPG key that signs RPMs and repository metadata stays on the servers, so
the workflow assembles the tree and uploads it, and procyon indexes and
signs it. A compromised run can ask for a signature; it cannot make one.
The assembled layout is the one the publishing scripts and the download
site expect. Debian architectures come from the package filenames, since
dpkg encodes them there; the codename does not follow from the build
name, so that mapping is explicit and fails loudly on an unknown one.
The Helm chart is built but not assembled: it goes to an OCI registry
rather than the download tree.
The support matrix was maintained by hand in five Jenkins heredocs,
which is exactly why the published one had drifted: it stops at Noble
and still lists Mantic, whilst the job that writes it goes up to
Resolute.
It is now derived, and the two sources are deliberately different. Which
platforms are supported comes from the repositories present in the tree
being written, since that is what a user can actually install from.
Which releases each platform carried comes from the S3 archive rather
than the download server, because old releases are purged from the
server as they age: bookworm reads as 7.8 there against 7.0 in the
archive, so deriving history from the server would quietly rewrite it
every time somebody tidied up. The sync has no --delete, so the bucket
keeps everything.
Platforms that are no longer supported get their own table with the last
release each carried, which turns the standing note about the archive
into something a user on an old distribution can act on. For snapshot
and pre-release trees --no-archive drops that section and the release
columns with it, since every package there is from the one build.
Cross-checked against the hand-written tables: every value they assert
is reproduced, including bookworm 7.0, jammy 6.9, bullseye 4.30 to 9.14
and fedora-42 9.3 to 9.17. Two they got wrong come out right, RHEL 10
having been copied from the RHEL 9 row as 6.12 when it is 9.6.
Two traps worth naming. aws s3 sync follows symlinks, so the EL
compatibility links exist in the bucket as full copies and would
otherwise invent platforms like fedora-31Client. And Debian versions
carry a packaging revision now, as in 9.16-1.resolute, which sorts below
9.3 unless it is stripped first.
It lived unversioned in pgaupload's home directory on paxsor, which also
made it writable by the account the publishing scripts run as, despite
being an input to what gets indexed and therefore to what gets signed.
Taking it here settles both: changing the repository's Origin, Label or
architectures becomes a reviewed commit, and the copy on the server can
become root owned.
The default architecture list drops i386 to match. apt-ftparchive has
been writing dists/pgadmin4/main/binary-i386/Packages for years, but
Architectures in the Release file has only ever said 'amd64 all', so no
client has been able to reach that index through the signed metadata.
Nothing that works today stops working.
The action ran a final step whose only job was to publish whichever of
the two platform steps' outputs had been set, and it did that in bash.
The hosted Windows image has bash by way of Git for Windows; the signing
host does not, so the Windows snapshot build failed with 'bash: command
not found' before it reached anything interesting.
An expression does the same job without needing a shell to exist.
The sequence that indexes and signs an APT repository existed in four
places that differed only in which directory they ran against: inline in
the pgadmin4-deb-build, pgadmin4-deb-snapshot and
pgadmin4-deb-publish-qa-to-prod Jenkins jobs, and in rebuild-apt-repo.sh
in the pgaweb repository. The YUM equivalent existed in five, and the
repository READMEs were heredocs in five more jobs.
These are those sequences, once each, with the tree root as an argument
so that the staging, snapshot and production trees share one
implementation, and with the architecture parameterised rather than
x86_64 spliced into the paths, since we will want to publish for arm64
before long. The APT script passes the architecture list to
apt-ftparchive as an override rather than letting it come from
aptftp.conf, so the set indexed and the set advertised in the signed
Release file cannot drift apart; a package whose architecture is missing
from Release is ignored by every client, silently.
Run with no options they behave exactly as the pgaweb copies did, so
rebuilding a repository by hand after purging old releases is unchanged.
aptftp.conf still needs to be brought across from the download server.
Which Python pgAdmin builds against and ships has until now been settled in
three unrelated places. On Windows, Make.bat used whatever %PGADMIN_PYTHON_DIR%
pointed at, which the buildfarm's Jenkins node configuration injected, and then
derived the embeddable distribution it actually ships from that interpreter's
own sys.version. On macOS, pkg/mac/build.sh defaulted to a version of its own
whilst the workflows overrode it with a third, copied from the buildfarm's
DEFAULT_PYTHON_VERSION. Locally it was whatever the developer happened to have.
A single commit could therefore ship different interpreters on different
platforms with nothing anywhere announcing it, which nearly happened: a workflow
that discovered Python from the PATH would have shipped 3.14 on Windows against
3.13 on macOS.
The version now lives in pkg/python-version.txt, which every platform's
packaging reads. It is a file in the tree rather than a GitHub variable because
a local build has to read it too, and because a change of interpreter should
arrive as a commit somebody can review rather than as a settings change made on
an agent. The format is KEY=value with # comments, dull on purpose: it has to be
parsed by a batch file, by a shell script and occasionally by a person, and
anything richer would need a parser in at least one of those. It carries both
the exact CPython version and the python-build-standalone release tag, since
that project's releases are dated rather than versioned and the archive names
embed both.
Make.bat no longer depends on an installed Python at all. It downloads the
relocatable distribution named in that file, unpacks it into the build tree and
installs virtualenv into it, then fetches the matching embeddable distribution
from python.org for what actually ships, so the two can no longer disagree.
python-build-standalone suits the signing host particularly well, being a plain
tarball rather than an installer and so raising no UAC prompt, which matters
because an elevation dialog in that machine's auto-logged-in session waits
forever for somebody who is not there. curl.exe and tar.exe have shipped in
Windows since 1803, so nothing needs installing to bootstrap it. Both Windows
workflows consequently lose their Python setup, and the signing host needs
neither a Python, nor a virtualenv installed into it, nor the
PGADMIN_PYTHON_DIR repository variable.
macOS only reads the version. It still builds its framework with
relocatable-python, because python-build-standalone's relocatable distributions
are not framework-shaped and replacing what is shipped there would mean
reworking the codesigning and the @loader_path rewriting in _fixup_imports,
which is a job of its own.
The version itself is unchanged: 3.13.15 is what the released installers
already carry, and this makes that existing choice explicit rather than a
different one.
Copied from the QA builds without thinking, where cancelling an in-flight run
on a new push is exactly right: the tip is what matters and a superseded build
is wasted effort.
A signing build is not that. Cancelling one throws away forty minutes of work,
and worse, it can abandon a notarisation already submitted to Apple, leaving a
request in their queue for a build nobody will ever collect. The Windows
snapshot already queues rather than cancels, for the related reason that there
is one hardware token, so this brings the two into line.
Found by cancelling my own in-flight Intel notarisation with an unrelated push.
Four workflows fetch these — the QA and snapshot builds on each of macOS and
Windows — and the duplication has already cost something: the first version
used the gh CLI, which turned out not to exist on the self-hosted Windows
runner, and the same fix had to be made in two places. There will be more of
them as the snapshot and release builds fill in.
The action carries the parts that are easy to get wrong and tedious to
rediscover. On macOS the archives must land in /opt/pgbuild precisely, because
the install names inside are absolute and _fixup_imports copies from those
paths whilst rewriting the references. On Windows the location only has to
avoid the C:\build64 that the buildfarm's Jenkins worker deletes and
re-extracts, hence the runner's temp directory. Downloads are plain HTTPS
rather than a CLI, since pgbuild is public and its URLs are predictable, and
both curl --fail and Invoke-WebRequest fail on a 404 rather than writing an
error page to disk and failing later at the unpack.
Deliberately not reusable workflows, which was the first idea. The signed
builds need environment: signing and the QA builds must not have it, and there
is no clean way to express "no environment" as an input, so factoring at that
level would have meant either handing the QA builds access to the signing
certificate or contorting around it. The duplication that actually hurt was the
steps rather than the workflows.
"snapshot" was the wrong name for the boundary. The same Developer ID and
notarisation credentials serve both the snapshot and the release builds, so
naming the environment after one occasion would have meant either duplicating
the secrets or referring to "snapshot" from a release workflow. Naming it for
what it holds rather than when it runs avoids both.
Worth knowing that the old environment was never created deliberately: GitHub
creates one the first time a workflow references it, so an environment
appearing in the settings is not evidence that anyone intended it.
The signed build failed with CommandNotFoundException on gh. Hosted runners
ship the GitHub CLI; a plain Windows machine does not, and the signing host is
the one machine in the pipeline that cannot be rebuilt, so the fewer things it
must have installed the better.
pgbuild is public and its asset URLs are predictable, so this needs neither a
CLI nor authentication. Invoke-WebRequest throws on a 404, so a missing release
still fails immediately rather than writing an error page to disk and failing
later at Expand-Archive, which was the reason for preferring gh in the first
place.
The hosted QA build changes the same way, though it has gh and did not need to.
One mechanism for the same job is worth more than the small convenience,
particularly if that build ever moves to a self-hosted runner.
ProgressPreference is silenced because Invoke-WebRequest otherwise spends most
of a 90 MB download rendering a progress bar into a log nobody will read.
The previous attempt took whatever python was on PATH, which was wrong in a way
that would not have announced itself. The signing host has 3.14 on PATH, whilst
the buildfarm's Jenkins job injects PGADMIN_PYTHON_DIR pointing at 3.13, so the
two systems would have built the same commit against different interpreters and
shipped different runtimes, with nothing failing to indicate it.
Which Python we ship is a decision. It now comes from a repository variable,
set to match Jenkins, so it is stated once rather than inherited from whichever
Python was installed on a machine most recently. The build fails if the
variable is unset rather than falling back to something plausible, because a
plausible fallback is exactly how this went unnoticed in the first place.
This is the interim arrangement. The version belongs in a file in this
repository, with each platform fetching a private Python of that version at
build time, so that local builds cannot drift either and the Windows host needs
no system Python at all. That wants doing before the system Python goes rather
than after, and deserves its own change rather than being done in the middle of
getting signing working.
The second run failed immediately with "pwsh: command not found". PowerShell 7
is not on that machine: GitHub's hosted Windows images ship it, a self-hosted
Windows box has only Windows PowerShell 5.1 unless somebody installed it, and
nothing should be installing it on this machine to suit a CI job.
Nothing here needs PowerShell 7, so the steps use the shell that is present.
One thing does change with it, and it is the sort of difference that produces a
baffling failure rather than an obvious one: Out-File in 5.1 defaults to
UTF-16, so writing to GITHUB_ENV without an explicit encoding produces a file
the runner cannot parse and every variable set that way silently goes missing.
Hence -Encoding utf8 on each, which is what GitHub's own documentation
prescribes for this shell.
The hosted Windows QA build keeps using pwsh, since hosted images have it and
it is the better shell where there is a choice.
The first run on the self-hosted runner hung on setup-python for thirty
minutes, against eleven seconds for setup-node, and had to be cancelled. On
Windows that action installs Python rather than unpacking it, and anything
raising a UAC prompt in the auto-logged-in session waits forever with nobody to
answer it. Which is the PIN dialog problem wearing different clothes, and worth
remembering as a property of this machine: anything that might prompt will hang
rather than fail.
The machine has a Python already, which is what the buildfarm's Jenkins job
uses, so the workflow finds it on PATH and derives PGADMIN_PYTHON_DIR from
that. It is also closer to what we are actually testing here, since the point
of building on this box rather than a hosted runner is to exercise its
environment.
virtualenv is checked rather than installed, consistent with the other
prerequisites: a CI job should not be modifying the one machine in the pipeline
that cannot be rebuilt, so it stops with the command to run instead.
The signing host is about to become the only machine in the release pipeline
that cannot be rebuilt from something. Everything else either disappears or
becomes an ephemeral runner described entirely by a workflow file; this one is
configured by hand and holds a certificate on a hardware card that cannot be
copied or exported.
What documentation existed lived in the buildfarm repository, which is being
retired, and was written for the Jenkins arrangement. It also opened by saying
it was "preserved for reference only" because the configuration was "now
managed by Ansible roles", which was never true of this machine.
So this consolidates both notes into one document next to the build they serve,
rewritten for the GitHub Actions arrangement: the runner in an interactive
session rather than as a service, the Startup folder shortcut, the PIN helper,
the prerequisites the workflow checks for, and the PGADMIN_WINDOWS_CSC
repository variable.
The Certum material is preserved largely as it was, because it is hard-won and
none of it changes: CSP mode rather than Minidriver, importing to the machine
store, and above all the certificate-to-key repair via keyprov.inf, which the
card's own registration does not do correctly and without which signing either
fails or quietly degrades to SHA1. The troubleshooting section is kept for the
same reason, particularly that NTE_KEYSET_NOT_DEF almost always means someone
is on Remote Desktop.
Everything specific to an individual, a machine or a certificate stays out:
subject names, thumbprints, container IDs and passwords are placeholders, as
they were in the originals.
Replaces the buildfarm's pgadmin4-windows-x64-snapshot job, and it is the one
workflow that cannot use a hosted runner: the certificate is on a Certum
hardware token, reached through the machine certificate store, with a PIN
handler answering the prompt from the interactive desktop session. None of that
can move to a runner GitHub provides.
Setting PGADMIN_WINDOWS_CSC is the whole of what makes Make.bat sign, so the
build fails immediately if the variable is absent rather than quietly producing
an unsigned installer that looks like a successful snapshot. It is a repository
variable rather than a value in the tree: it is a person's name, and a
certificate renewal should be a settings change rather than a commit.
Three things differ from the QA build beyond the signing.
Nothing is installed. The hosted build runs choco; this machine is provisioned
by the buildfarm's Ansible and a CI job has no business mutating it, so the
prerequisites are checked and the build stops with a useful message if one is
missing.
Visual Studio, the VC++ redistributable and signtool are discovered rather than
hardcoded. The buildfarm pins all three in its Jenkins node configuration,
which then needs editing every time Visual Studio or the Windows SDK updates,
and signtool in particular lives at a versioned path.
Concurrency is a fixed group with cancel-in-progress off. There is one hardware
token, Jenkins may also be using it, and two builds signing through it at once
is a good way to discover what proCertum does under contention.
The verification step checks the executable inside the installer as well as the
installer itself. Make.bat verifies only the latter, and the former is the
thing users actually run.
These all trigger on pushes to master, which means that on a working branch
they never run and the branch cannot be tested at all. So the branch is named
alongside master in the triggers, to be removed when it merges.
The snapshot build needs different treatment. workflow_dispatch only offers a
"Run workflow" button for workflows that are on the default branch, so whilst
it lives on a branch there is no way to trigger it by hand at all. A plain push
trigger would mean signing and notarising on every commit, which is both
wasteful and rude to Apple's notary service, so it fires only on changes to
itself or to pkg/mac, which is to say only when the thing under test changes.
Its own header said to delete it once the cause was understood, and it is: the
unmount cost scales with the bundle, hdiutil's 120 second DiskArbitration
timeout cannot be raised, and detach -force waits on DiskArbitration exactly as
the polite detach does. The build now creates the image with hdiutil create
-srcfolder and never mounts a volume at all.
What the spike established is recorded where it will actually be read, in the
comments on _create_dmg and in the commit that replaced create-dmg, rather than
in a workflow nobody will run again: create-dmg's flags, the volume size, the
file count, Gatekeeper, Spotlight and the choice of runner image were each
tested and each exonerated.
The first ad-hoc run failed on the very first binary:
Contents/MacOS/pgAdmin 4: replacing existing signature
Contents/MacOS/pgAdmin 4: code object is not signed at all
In subcomponent: Contents/LICENSE
The Developer ID path passes --deep on every codesign call and the ad-hoc one
did not. These are bundles with nested code, and without --deep codesign will
not replace the signature on one whose nested code is unsigned. Matching the
existing path is both the fix and the smaller surprise for anyone reading the
two branches side by side.
Replaces the buildfarm's pgadmin4-appbundle-snapshot job. Where the QA build
proves the bundle assembles, this produces something distributable: signed with
the Developer ID, notarised by Apple, stapled, and with a ZIP alongside the DMG
because the Electron auto-updater consumes the ZIP whilst the DMG is the
download people click.
The buildfarm keeps the Developer ID in the worker's login keychain and unlocks
it with devkeys/unlock-csc.sh. A hosted runner has no such keychain, so one is
created for the job and destroyed with it, under a generated password that
never outlives the run. set-key-partition-list matters more than it looks:
without it codesign asks permission to use the key, and on a headless runner
asking means hanging until the job times out.
The credentials sit on a "snapshot" environment rather than loose on the
repository, so only workflows that ought to be signing can reach them, and the
cleanup step runs even on failure, since neither a keychain holding a Developer
ID key nor a conf file holding an app-specific password should survive a failed
build.
Nothing is uploaded yet. This proves the signed build works; publishing is the
next piece. It is also workflow_dispatch only rather than nightly, so it cannot
start signing things before we intend it to.
Requires six secrets on the snapshot environment: MACOS_CERTIFICATE (the
Developer ID .p12, base64 encoded), MACOS_CERTIFICATE_PASSWORD,
MACOS_DEVELOPER_ID, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD and APPLE_TEAM_ID.
C:\build64 was the obvious place to put them and the wrong one. It is what the
buildfarm's winpgbuild-update job manages on the Windows worker, where it
deletes and re-extracts postgresql and krb5 inside that directory.
That does not matter whilst these builds only run on hosted runners, which are
ephemeral and have no Jenkins on them. It matters a great deal as soon as the
signing builds run on that same machine as a self-hosted runner, because the
two systems would then take turns overwriting each other's dependencies, and
the Jenkins-side failures would look nothing like their cause: a build that
worked yesterday, with a PostgreSQL that is subtly not the one it was given.
So the path is derived from RUNNER_TEMP and passed on through GITHUB_ENV rather
than being a fixed constant. Nothing else changes, and QA stays on the hosted
runner.
Without codesign.conf the build skipped signing entirely, which leaves a bundle
that cannot be run rather than merely one that cannot be distributed.
_fixup_imports rewrites install names across several hundred binaries, which
invalidates whatever signatures they arrived with, Electron's included. On
Apple Silicon the kernel refuses to execute a binary whose signature is broken
or absent, so an unsigned bundle will not launch at all there, even once the
quarantine attribute has been removed. On Intel it is less severe but no more
correct: every binary ends up carrying a signature that no longer matches its
contents.
So with no Developer ID we now sign ad-hoc. This is not a substitute for a real
signature and is not meant to be: the result is still refused by Gatekeeper and
still cannot be notarised, and the build says so. It just means an unsigned
build is something a developer can actually open after allowing it, which is
the entire point of producing one.
The signing arguments move into a helper so the two paths cannot drift, and the
dylib loop stops using find -exec so it can share them. Signing the disk image
ad-hoc would achieve nothing, since a signature there is about distribution, so
that one still does nothing without a Developer ID.
Worth noting this path had never run anywhere before: the buildfarm's macOS QA
job signs properly, because it runs on a machine with the certificate, so the
unsigned branch existed only in principle. The same was true of the Windows
installer's unsigned path until last week.
_create_python_env installs the whole of requirements.txt into the framework
that gets shipped, and then _build_docs created a fresh venv and installed all
hundred-odd packages again. Both installs show up in every build log as
identical "Successfully installed Authlib-1.8.0 Babel-2.18.0 ..." lines a few
hundred lines apart.
The second install was not gratuitous: the doc build imports pgAdmin itself,
since docs/en_US/build_code_snippet.py pulls in config, pgadmin.utils,
pgadmin.browser.utils and the driver abstractions, so the dependencies have to
be importable. But they already are, in the framework populated moments
earlier, so the venv can inherit them instead. Sphinx still goes into the venv
rather than the framework, which is the reason the venv exists at all: it must
not end up in the shipped bundle.
--system-site-packages is safe here in a way it is not in the Linux build,
which deliberately avoids it. There the parent is a shared system Python that
may carry other packages' stale .pth files, and one of those corrupted sys.path
badly enough to break pip's own bootstrap on the el-10 builder. Here the parent
is a framework we built ourselves in this run, containing only our
dependencies. The comment says so, to save someone importing the Linux
workaround into a situation that does not call for it.
Windows already avoided the duplication by adding sphinx to the same
environment, though without the separation that keeps it out of the shipped
files.
pgadmin-org/pgbuild supersedes dpage/winpgbuild, and now that a full
build-all-windows run has published the Windows artefacts, this points at them
instead. Both platforms then build against a single manifest of pinned
versions, which is the main reason for merging the two repos in the first
place: until now the macOS and Windows dependency versions were kept in step by
hand, in two places, with nothing enforcing it.
Verified against what Make.bat actually needs before switching. The krb5
archive unpacks to krb5/ and carries kinit.exe, krb5_64.dll, comerr64.dll,
k5sprt64.dll and gssapi64.dll; the PostgreSQL one unpacks to postgresql/ and
carries libpq.dll, zlib1.dll, the client binaries, libcrypto-3-x64.dll and
libssl-3-x64.dll, along with the optional liblz4.dll, libzstd.dll, libintl-8.dll
and libiconv-2.dll. So the directory layout the workflow already assumed is
unchanged and every file the build copies is present.
Downloads now go through gh rather than Invoke-WebRequest, which keeps the tag
and the asset name in one place and fails loudly if a release is missing rather
than writing an error page to disk and failing later at Expand-Archive.
Homebrew's PostgreSQL was only ever a stand-in for the build check, and a poor
one: it has no zstd or lz4 support, which is issue #9425, it links Homebrew's
OpenSSL rather than the one we ship, and it carries whatever minimum macOS
Homebrew happened to build for. So the QA build proved that the bundle
assembles, but not that the binaries in it are the ones we intend to ship.
The dependencies now come from pgadmin-org/pgbuild, which builds OpenSSL,
Kerberos, zstd, lz4 and PostgreSQL against each other with a 14.0 deployment
target, matching the support policy. That makes the QA build the same thing the
release build will produce, minus the signing, and it exercises those artefacts
continuously rather than discovering a problem with them at release time.
They are unpacked into /opt/pgbuild specifically, because the install names are
absolute: pg_dump asks for /opt/pgbuild/zstd/lib/libzstd.1.dylib and libpq for
/opt/pgbuild/krb5/lib/libgssapi_krb5.2.2.dylib, among others. _fixup_imports
copies from those paths into the bundle and rewrites the references, so they
have to resolve during the build.
Which is also why /opt/pgbuild joins the prefixes _verify_bundle_linkage
rejects. An unrewritten reference to it is precisely the defect that check
exists to catch, no different from a dangling Homebrew path, and it would
otherwise pass unnoticed: the shipped binary would look fine on the build
machine and fail on a user's.
OPENSSL_DIR now points at our OpenSSL too, so the Intel cryptography build,
which compiles from source because there is no x86_64 wheel, statically links
the same OpenSSL as everything else in the bundle.
The DMG was built by mounting a read/write volume, populating it, unmounting it
and converting the result. The unmount is the step that does not survive
contact with a slow machine: hdiutil waits on DiskArbitration with a fixed 120
second timeout that nothing exposes a way to raise, and the cost scales with the
bundle. Measured on GitHub's hosted runners with an identical payload it takes
around 20 seconds on arm64, 41 on macos-26-intel and 90 on macos-15-intel, and
with the real bundle it exceeds 120 on both Intel images every time. It has been
seen intermittently on developers' own machines for the same reason.
`hdiutil detach -force` is no help, which was worth finding out the hard way
since it is usually described as the escape hatch: it waits on DiskArbitration
as well, times out identically, and the conversion then fails with "Resource
temporarily unavailable" because the image is still attached.
`hdiutil create -srcfolder` builds the image directly from a directory and never
mounts a volume, so none of this can happen. Verified on both architectures
against a real Electron bundle: the image is created and verifies, and mounting
it read-only afterwards shows the volume named correctly with the .DS_Store,
the background directory, the volume icon and the Applications symlink all
present. hdiutil preserves that symlink rather than following it. Creation takes
107 seconds on arm64 and 312 on macos-26-intel, slower than before, but with no
fixed wall anywhere in it.
That also disposes of create-dmg, which was cloned unpinned from its default
branch on every build and run against the signed bundle. Since we passed
--skip-jenkins, none of its Finder automation ran and everything it did for us
was staging, all of which is now done directly. Its EULA template is vendored
into pkg/mac/ rather than cloned for one XML file, and the licence is attached
with udifrez exactly as create-dmg did.
Ruled out before arriving here: create-dmg's flags, the volume size, the file
count, Gatekeeper, Spotlight and the choice of runner image. A 400 MB image of
30,000 text files and one holding Electron and a Python framework unmount in
comparable time, so only the speed of the machine and the size of the bundle
move the number.
One thing CI cannot check is whether the volume icon and background actually
render, since that needs Finder. The DMG is kept as a build artefact so it can
be opened and looked at.
The recovery added earlier does not work, and the run proved it plainly:
Forcing detach of /dev/disk22...
hdiutil: detach: timeout for DiskArbitration expired
hdiutil: detach: drive not detached
hdiutil: convert failed - Resource temporarily unavailable
So hdiutil detach -force waits on DiskArbitration exactly as the polite detach
does, same 120 seconds and same message, contrary to how it is usually
described, and the convert then fails because the image is still attached. That
was the one assumption I could not verify beforehand, the spike having never
reproduced the failure and its diagnostics therefore never having run.
hdiutil create -srcfolder builds the image directly from a directory and never
mounts a volume, so DiskArbitration is never involved and there is nothing to
detach. Since we pass --skip-jenkins, create-dmg does no Finder automation for
us anyway: what it actually provides is the volume name, an Applications
symlink, the background directory, the pre-built .DS_Store and a hidden
extension, all of which are staging. The two that are not are the volume icon,
which needs the custom-icon flag on the volume root, and the EULA, which is
attached to the finished image rather than placed inside it.
Rather than learn which of those survive through forty minute Intel builds,
test F stages a directory exactly as the replacement would and creates the
image from it, then mounts the result read-only to confirm the styling is
really there. Mounting a finished compressed image briefly is cheap; it is
unmounting a large populated read/write volume that is not.
The dead recovery code in _create_dmg stays put until this proves out, so it is
replaced in one change rather than reverted and then rewritten.
create-dmg builds a read/write image, mounts it, populates it, unmounts it, and
only then converts it to the compressed image we ship. The unmount is the
fragile step: hdiutil waits on DiskArbitration with a fixed 120 second timeout
that nothing exposes a way to raise, and the cost of unmounting scales with the
bundle.
Measured on GitHub's hosted runners with an identical payload, unmount to "Disk
image done" takes about 20 seconds on arm64, 41 on macos-26-intel and 90 on
macos-15-intel; with the real bundle it exceeds 120 on both Intel images, every
time rather than occasionally. It has also been seen intermittently on
developers' own machines, which is the same problem with a smaller multiplier.
Retrying create-dmg would achieve nothing, since a deterministic failure just
recurs. What matters is that by the time the unmount fails the working image is
fully populated, and the only step create-dmg did not reach is the conversion.
So on failure we force the volume off, which does not wait on DiskArbitration,
and perform that last step ourselves. The result is verified rather than
assumed, because this path only runs when something has already gone wrong.
The successful path is untouched: when create-dmg returns 0 we return
immediately and none of this executes.
A fair amount was ruled out before landing here. create-dmg's flags, the volume
size, the file count, Gatekeeper, Spotlight and the choice of runner image were
each tested and each exonerated: a 400 MB image of 30,000 text files and one of
the same size holding Electron and a Python framework unmount in comparable
time, and only the speed of the machine and the size of the bundle move the
number.
The previous commit message said this was going and then didn't remove it.
Spotlight was ruled out two runs ago: the step demonstrably disabled indexing
and the detach failed exactly as before, so the comment above it claimed a
cause that had already been disproved.
Tests C and D pinned the cost to something about a real bundle and, more
usefully, showed how differently the three runners handle it. Unmount to "Disk
image done", identical payloads: 19s on arm64, 41s on macos-26-intel, 82s on
macos-15-intel, against hdiutil's 120 second timeout, and that was with a
lighter stand-in than the real thing.
So the Intel QA job moves to macos-26-intel, which has roughly double the
headroom and is the newer image we would end up on anyway, macOS 15 being the
first to retire. The mdutil step goes at the same time: Spotlight was ruled out
two runs ago and leaving a step behind whose comment claims it fixes something
is worse than not having it.
What C and D could not separate is content type from file count, and the second
looks the more likely of the two. Test B held ~409 MB in 201 files and test D
~415 MB in a few thousand; the real bundle holds a comparable amount of data
across far more files, since web/ alone is 4,626 of them, 2,043 being .sql
templates, with docs/ adding 773 and site-packages and the Python stdlib on top.
Thirty thousand is a conservative estimate.
Test E therefore puts the same ~400 MB into 30,000 small text files with no
Mach-O content at all. If the unmount climbs, the driver is the number of files,
not Gatekeeper or LaunchServices inspecting binaries, and no amount of signing
or scanning policy would have helped. It also tells us whether macos-26-intel
survives a realistic file count, which decides whether the runner change above
is a fix or merely a stay of execution.
The first pass of the spike came back clean on all three runners, which is more
informative than it sounds. Bare hdiutil detached a 100 MB image in six seconds
and a 1.5 GB one in under a second, and create-dmg with pgAdmin's exact flags
and real assets over a 409 MB payload exited 0 in forty-four seconds, on the
very runner label where the real build has now failed three times out of three.
So it is not the runner image, not hdiutil, not create-dmg's invocation and not
the size of the volume. What is left is the bundle. Test B's stand-in was a
random blob in Contents/MacOS with no Info.plist and no Mach-O, which the system
does not regard as an application at all, whereas a genuine bundle landing on a
freshly mounted volume gets picked up by LaunchServices and Gatekeeper in ways a
folder of noise does not.
Test C is therefore a real Electron.app, which is precisely what the real build
starts from, and test D adds a relocatable Python framework on top, that being
the other symlink-heavy thing the build layers on and the reason
_prune_dangling_symlinks exists. Neither installs anything through pip, so this
stays in the region of ten minutes rather than the forty the real build needs on
Intel to compile cryptography.
If C fails we have it narrowed to real app bundles; if C passes and D fails, to
the Python framework.
Three Intel appbundle runs have now failed identically, always exactly 120
seconds after create-dmg starts unmounting, with the bundle otherwise complete
and verified; arm64 has passed four times through the same code, and disabling
Spotlight changed nothing. Reproducing it through `make appbundle` costs forty
minutes a go, nearly all of it compiling cryptography on Intel, which is a
miserable way to test a hypothesis.
So this reproduces the failing step directly, in a couple of minutes, and
answers three questions at once: whether plain hdiutil fails or only create-dmg
does, whether it depends on image size, and whether it is specific to the
macos-15-intel image. arm64 is included as the control that is known to work.
When something fails it captures evidence rather than leaving us guessing:
hdiutil's own view of what is attached, lsof against the volume, the processes
that typically hold one, the diskarbitrationd and hdiutil unified log for the
preceding few minutes, and whether a forced detach succeeds where the polite
one timed out. That last one should be the most telling, since it distinguishes
something holding a reference from DiskArbitration itself not answering.
The mdutil step added to check-macos-build.yml stays for now so the two runs
remain comparable; it should come out once we know the real cause.
The Intel appbundle build fails at the very last step, every time rather than
intermittently: the bundle is assembled, _verify_bundle_linkage passes, the SBOM
is written, the disk image is created and populated, and then create-dmg cannot
unmount it.
Unmounting disk image...
hdiutil: detach: timeout for DiskArbitration expired
hdiutil: detach: drive not detached
Two runs, identical failure, and the gap between the unmount starting and the
timeout is exactly 120 seconds, so something is waiting out a timeout rather
than racing. Spotlight indexing the freshly mounted volume is the usual cause,
holding a DiskArbitration reservation that the detach then sits behind, and
nothing on a build runner wants a Spotlight index anyway.
arm64 has never shown this, so the step is harmless there rather than needed.
If this doesn't settle it, the next thing to try is macos-26-intel, and after
that a retry around the detach, which would mean changing pkg/mac rather than
the workflow.
Dropped by accident when the compiler packages were added, and pkg/redhat's
build.sh shells out to yumdownloader at line 229, near the very end. So the
container built the docs, ran webpack, produced the RPMs, and only then died
with a bare exit 127 about twelve minutes in, which is a slow and unhelpful way
to learn that a package is missing.
Also check for yumdownloader explicitly once the packages are installed, so the
same class of mistake fails immediately and says what is wrong. That matters
beyond this one slip: the dnf5 distributions (EL 10, Fedora 41 and later) get
yumdownloader via yum-utils' compatibility shims rather than from yum itself,
which is the sort of thing that changes under us without warning.
Two problems, one of them in the packaging rather than the workflow.
installer.iss.in has always carried an unconditional `SignTool=pgAdminSigntool`,
but Make.bat only defines that SignTool when PGADMIN_WINDOWS_CSC is set. So the
unsigned path, which Make.bat explicitly supports and announces ("Skipping code
signing"), cannot actually produce an installer: ISCC aborts with "Value of
[Setup] section directive SignTool is invalid". It has presumably never been
exercised, because the Jenkins Windows job runs on the box with the token
plugged in, where the variable is always set.
Both signing directives are now inside `#ifdef SIGNED`, and the signing branch
of Make.bat passes /DSIGNED next to the existing /SpgAdminSigntool=. The signed
build emits exactly what it emitted before; the unsigned build now works.
Second, and more mundane, syft isn't on the hosted image. That one was silent:
:GENERATE_SBOM redirects syft's output into sbom.json and returns 0 whatever
happens, so a machine without syft ships an empty SBOM rather than failing. The
buildfarm's Windows worker installs it via Chocolatey, so this does too.
cryptography publishes arm64-only macOS wheels (checked against 50.0.1), so on
macos-15-intel pip builds it from the sdist and its openssl-sys crate links
whatever OpenSSL it finds on the build host, which on a hosted runner is
Homebrew's. _fixup_imports deliberately skips _rust.abi3.so, so the reference
survives into the bundle and _verify_bundle_linkage stops the build:
ERROR: .../cryptography/hazmat/bindings/_rust.abi3.so links against
build-host libraries:
/usr/local/opt/openssl@3/lib/libssl.3.dylib
/usr/local/opt/openssl@3/lib/libcrypto.3.dylib
That's issue #10123 reproducing exactly, and the release build already guards
against it by setting OPENSSL_DIR and OPENSSL_STATIC against its own OpenSSL.
This does the same against Homebrew's, which leaves no external reference at
all. The arm64 job was unaffected throughout, since it gets a wheel.
Worth noting for scheduling: the Rust compile makes the Intel build roughly
twice the length of the arm64 one, 50 minutes against 27.
wget isn't on the hosted Windows image, and Make.bat's Electron download is an
unbounded retry loop (Make.bat:316), so the build doesn't fail, it just spins on
error 9009 until the job hits its timeout. The buildfarm's Windows worker has
wget from the same Chocolatey line in pkg/win32/README.md, which is where this
one now gets it too.
Two faults from the first run, neither of them deep.
All nine jobs would have hit the git ownership check: the container runs as
root whilst the workspace is owned by the runner user, so git refuses to touch
it, and `yarn run bundle` shells out to git for the commit hash and dies with
exit 128. The DEB builds got all the way through webpack before falling over on
that, which at least confirms the rest of the environment is sound. Marking the
workspace as a safe directory after checkout is the usual remedy.
The RPM builds failed earlier and more prosaically, with no compiler in the
container: psycopg-c and gssapi are both built from source, since
pkg/linux/build-functions.sh passes --no-binary psycopg, and rpmbuild wants
redhat-rpm-config. The buildfarm picks these up from the @development-tools
group; they're listed individually here to keep the container small. The DEB
side already had build-essential, which is why it got so much further.
The runner has started warning about Node.js 20 actions being deprecated, which
covers every first-party action we use, since v4 of checkout and upload-artifact
still run on the Node 20 runtime.
So: checkout v4 to v7, upload-artifact v4 to v7, setup-node v4 to v7,
setup-python v5 to v7 and docker/build-push-action v6 to v7. Nothing behavioural
changes for us. The intervening majors are almost entirely the Node 24 runtime
move, with checkout v6 persisting credentials to a separate file, checkout v7
refusing to check out a fork's head for pull_request_target and workflow_run
(neither of which we use), and upload-artifact v7 adding an opt-in unzipped
single-file upload.
One thing to be aware of before we stand up a self-hosted runner for the signing
builds: checkout v5 and later need runner 2.327.1 or newer, so that host will
have to be reasonably current.
The SonarQube action is left pinned to its commit hash, which is deliberate.
These are the five QA builds that the Jenkins buildfarm still owns, and between
them they're the last thing standing in the way of retiring the QA half of it:
the container, tarball, wheel and documentation checks already run here.
The DEB and RPM builds run in a container per distribution on a hosted runner
rather than on a dedicated EC2 VM per distribution, which is the same build in
a reproducible environment and nine fewer machines to look after. The package
lists come from the buildfarm's own Ansible packages_build role. PGDG is
mandatory for RPM, because pkg/redhat/build.sh opens by globbing /usr/pgsql-1*
under `set -e` and dies without it; the DEB build is happy with the distro's
libpq-dev.
macOS builds on both architectures and is already proven: an earlier spike ran
it on macos-15 in 22 minutes with webpack peaking at 2991 MB against its 4096 MB
ceiling and no swap used at all, so the 7 GB arm64 runners have adequate
headroom. That spike workflow is removed here, superseded by the real one.
Windows runs on a hosted runner too, which wasn't obvious: signing in Make.bat
is conditional on PGADMIN_WINDOWS_CSC, so an unsigned build check needs neither
the Certum hardware token nor the machine it's plugged into, and only the
signing release builds need a self-hosted runner. Its dependencies come from
dpage/winpgbuild, the same place the buildfarm's winpgbuild-update job gets
them. This one hasn't had a successful run yet and should be expected to need a
few passes.
All five trigger on master and by hand, not on pull requests, matching what the
Jenkins jobs do today; whether the full matrix should also gate PRs is a
separate question.
We're assessing a move of the buildfarm to GitHub Actions, and the one thing
that isn't already proven by the existing workflows is whether the appbundle
build fits inside a hosted macOS runner. The arm64 images have 3 vCPU and 7 GB
of RAM against the 8 cores and 16 GiB of the mac2.metal worker they'd replace,
and pkg/mac/build-functions.sh:320 records webpack being OS-OOM-killed inside
TerserPlugin at a 3 GB old-space ceiling, with 6 GB then starving the earlier
steps. Nothing we currently run on macOS builds a production bundle, so that
history is untested on hosted hardware.
The workflow is workflow_dispatch only, builds unsigned (build.sh skips signing
and notarisation when the two conf files are absent, so it needs no secrets),
and samples resident set sizes and swap every ten seconds so the logs show
whether the machine actually ran out of headroom rather than just whether the
build happened to pass. The runner image, the Python version and the webpack
memory ceiling are all inputs, so the ceiling can be bisected across images.
To be deleted once the question is answered.
Backup/restore now pass the target database via PGDATABASE instead of
a CLI/positional argument, so the XSS regression test's check against
the displayed command string could never find the db name (escaped or
not) and always failed. Point Backup's check at the message field,
where the db name is still interpolated; for Restore, where the db
name no longer surfaces anywhere (command shows an always-empty
--dbname, message omits it entirely), assert its absence instead.
Also drop the fuzzy marker from the Korean catalog header. Babel skips a
catalog whose header is fuzzy, and ko's header still carried the
unfilled template placeholders that make babel mark it so, with the
result that ko/LC_MESSAGES/messages.mo had not been regenerated since
November 2025 -- 83KB against the 263KB the .po compiles to, so most of
the 89% of Korean strings that are translated never reached users.
The file mixed issue-backed entries with commit-id ones. Make every
entry issue-backed:
- The LLM API redirect hardening had no issue, and its entry was the
only record of Ziya Abdullayev's report outside the commit message.
It gets #10420, written up with the mechanism, the affected range
(>= 9.15, since ALLOWED_LLM_API_URLS did not exist before then) and
the fix commit, and noting that it is hardening rather than an
exploitable flaw, so no CVE was requested.
- The Table dialog fix closed two issues, #10179 and #10180, so it
becomes two entries. That also covers the Definition-tab type
restriction, which the single commit-id entry had omitted.
- The remaining commit-id entries are dropped.
The four-line comment explaining why an empty database must be rejected
was pasted twice above the guard in create_maintenance_job(). Comment
only; no behaviour change.
The PGDATABASE fix only set the env var when the database value was
truthy, so an empty string left both PGDATABASE and --dbname unset.
libpq then falls back down its chain to a database named after the
login role, silently restoring into / vacuuming the wrong database
instead of failing. Reject an empty or missing database with a 400
before any args are built, in both tools, and add regression tests
covering the empty-value path.
- Scan every argv token for the forbidden value (forbidden_arg_substr)
instead of list-membership only, so a value smuggled into a single
token (e.g. "--dbname=host=...") is caught.
- Assert pg_restore keeps the required "--dbname=" flag, guarding against
a regression that drops it (pg_restore errors without -d/--dbname).
- Add regression scenarios asserting the database value is absent from
argv and carried in PGDATABASE (maintenance: 1, restore: 2). They fail
if the fix is reverted.
- Use the attached form "--dbname=" (a single argv token) for pg_restore
instead of ['--dbname', ''] so the process-details command renders
correctly -- an empty standalone token was dropped from the display.
The restore and maintenance tools passed the user-controlled database
value to --dbname. libpq expands a --dbname value containing "=" (e.g.
"host=evil port=5433 dbname=postgres") into a connection string, letting
a user redirect the connection -- and the exported PGPASSWORD credential
-- to an arbitrary server, regardless of the --host/--port pgAdmin sets.
Pass the target database via the PGDATABASE environment variable instead
(libpq treats it as a literal name and never expands it):
- maintenance (psql) and restore plain/SQL (psql): drop --dbname; the
tool connects using PGDATABASE.
- restore custom/tar/directory (pg_restore): pg_restore requires
-d/--dbname, so pass an empty --dbname '' (no "=", not expanded) while
the real database name comes from PGDATABASE.
The macOS x64 packaging job fails while installing requirements.txt:
Preparing metadata (pyproject.toml): finished with status 'error'
error: subprocess-exited-with-error
exit code: -10
Exit code -10 is SIGBUS. The only output before the crash is
setuptools' own `[tool.setuptools.ext-modules] in pyproject.toml is
still experimental` warning, so it dies while setuptools reads the
project config, well before pg_config is ever invoked.
psycopg-c ships no wheels, so every platform compiles it from the
sdist. The only build-tooling change between 3.3.4 and 3.3.5 is the
strictly pinned build requirement:
3.3.0 - 3.3.4 setuptools == 80.3.1
3.3.5 setuptools == 83.0.0
Because that pin is an equality, PIP_CONSTRAINT cannot override the
version inside the isolated build environment - the only ways to keep
3.3.5 are --no-build-isolation with a pre-seeded setuptools (which
would fork the macOS build procedure from Linux's) or an upstream
fix. Linux builds 3.3.5 fine, so this looks specific to the
relocatable-python framework the macOS bundle is built against.
Pin to 3.3.4 to unblock the build. The encoding.py rewrite that
shipped alongside the 3.3.5 bump stays untouched: it reads the
derived py_codecs/pg_codecs dicts, which are plain dicts in both
versions.
One test expectation was 3.3.5-specific. psycopg 3.3.5 added an
ISO88591 alias to its encoding table, so inverting py_codecs resolves
iso8859-1 to ISO88591; on 3.3.4 only LATIN1 exists, so
get_encoding('LATIN1') returns ['LATIN1', 'iso8859-1'] instead of
['ISO88591', 'iso8859-1']. Both are valid PostgreSQL names for the
same encoding, so the scenario now accepts either and the test passes
on 3.3.4 and 3.3.5 alike.
Set npmMinimalAgeGate to 3d in web/.yarnrc.yml and runtime/.yarnrc.yml to prevent immediately installing freshly-published packages, providing a grace period against npm supply-chain compromises.
Revert checksumBehavior from update back to Yarn's default (throw) in web/.yarnrc.yml and runtime/.yarnrc.yml to restore tamper detection and ensure builds fail if downloaded package checksums do not match lockfiles.
Revert global enableScripts in web/.yarnrc.yml to Yarn's secure default (false) to prevent arbitrary lifecycle scripts from running on install. Allowlist only ttf2woff2 and unrs-resolver via dependenciesMeta.built in web/package.json, keeping promotional/unneeded scripts blocked. Narrow approvedGitRepositories to pgadmin-org/react-data-grid.
Fill in the identifiers allocated by the PostgreSQL CNA for the three
security entries that carried a placeholder:
#10383 webserver Remote-User auth bypass CVE-2026-86863
#10384 backup argument/conninfo injection CVE-2026-86864
#10385 File Manager save_file symlink follow CVE-2026-86861
Add the missing entry for #10388, connection-string injection in the
Restore and Maintenance tools (CVE-2026-86862). The fix was written but
never got a release-note line.
Move the release date out to 2026-09-17.
Backend properties fetch marks a column already inherited from a
parent table with inheritedfromtable, while a column fetched
interactively via 'Inherited from table(s)' carries inheritedfrom
instead and has no attnum yet. inSchemaWithColumnCheck only checked
inheritedfrom, and did so after an isNew() short-circuit that treated
the attnum-less interactive rows as new, so inherited columns ended up
editable and deletable in both cases. Check both fields, and check
them before the isNew() short-circuit, and extend
canEditDeleteRowColumns the same way so the row's edit/delete buttons
are disabled too.
Fix: restrict Definition-tab Data type options to edit_types (#10180)
The expanded row's Definition tab and the inline grid-cell editor each
defined their own copy of the edit_types filter for the 'cltype'
field, but the tab's version received the whole table's data as
'state' rather than the row, since MappedControl resolves a field's
'type' callback against the top-level schema, not the field's own row
(unlike 'cell', which already gets the full row). That made isNew()
and edit_types resolve against the wrong object, so the filter always
no-opped and the tab showed every type instead of the restricted set.
Have MappedControl also resolve a field's declared 'deps' against its
own row (listenDepChanges already does this correctly for 'cell') and
forward them as a 2nd argument to 'type', mirroring what 'cell'
already receives. column.ui.js declares 'edit_types'/'attnum' as deps
on 'cltype' and factors the filter into one shared editTypesFilter()
used by both 'cell' and 'type', so the two stay in sync by
construction.
Fx: honour inheritedfromtable in attlen/attprecision editable checks
The Length/Precision and Scale grid-cell editors only checked
inheritedfrom when deciding whether to allow editing, so a column
loaded with only inheritedfromtable set (i.e. already inherited when
the table was opened) could be left incorrectly editable, unlike the
other inherited-column checks in this schema. Add a shared
isInheritedColumn() helper covering both fields and reuse it in both
editable callbacks and in inSchemaWithColumnCheck.
The Windows installer and the pip wheel were both shipping web/yarn.lock,
web/package.json and the rest of the JavaScript build configuration into
the installed tree, even though none of it is used at runtime: only the
webpack output under pgadmin/static/js/generated is. Because node_modules
is stripped, the lockfile that remains describes packages that are not
present, so vulnerability scanners run against an installation report the
whole build-time dependency tree, devDependencies included, as findings
that cannot be acted on and will recur on every release no matter how
current the versions are.
The Linux, macOS and Docker builds already removed these files, so this
brings Windows and pip into line with them, and adds webpack.* to the
Linux and macOS lists so that all of the packagers now strip the same
set. For pip the cleanup runs before syft, so the SBOM describes what
actually ships.
Verified by assembling a web tree with the cleanup applied and starting
pgAdmin from it: /misc/ping and /browser/ both return 200 and every
generated bundle is served.
The whitespace-only difference that CREATE OR REPLACE left in procedure
bodies was fixed in abf926428, so proc1 now settles once the generated
script has been applied and the test fails on the stale entry, exactly
as it is designed to: the list fails when something on it starts working
so that it cannot quietly rot. Take the entry off.
boto3, azure.*, googleapiclient and google_auth_oauthlib were imported at
the top of the AWS RDS, Azure and Google Cloud modules, so every pgAdmin
start paid for all three SDKs whether or not anyone went near the cloud
deployment wizards. Move those imports into the functions that need them,
leaving the blueprints, their menu entries and the exposed endpoint map
eagerly registered, which is the property that makes this viable where
the earlier lazy-blueprint idea was not; repeat calls cost nothing, since
sys.modules caches each module after the first import.
The modules with several call sites get a small _azure_sdk() or
_google_sdk() helper handing back the symbols in a namespace, whilst the
one and two site cases carry a plain inline import with a comment saying
why it sits there. The oauth2client sentinel stays at the top of the
Google module, where it still runs before anything can import
googleapiclient, and _google_sdk() now carries a note pointing back at it
so that a later tidy-up does not move it and quietly reintroduce #10110.
Deferring the imports moves the failure mode for a broken or missing SDK
from startup, where it is obvious, to the first click in a wizard, so
each affected path now fails cleanly rather than leaking a traceback into
the UI: the Google and Azure lookups return their usual (data, error)
tuple, or an empty list with the reason logged; Google.callback() reports
a missing google_auth_oauthlib as a verification error; and the RDS
client helper logs the ImportError and lets its callers answer with empty
metadata instead of a 500.
Also fixes a pre-existing bug in the Google instance_types endpoint,
which assigned the (instance_types, error) tuple returned by
Google.get_instance_types() to a single name and then called .get() on
it, raising AttributeError for anyone who got that far in the wizard.
Measured on Linux with a warm page cache, timing create_app() in desktop
mode over five runs and taking the median, this takes application startup
from around 1567 ms to around 1354 ms; on Windows the saving was reported
as larger, at around 2131 ms down to around 1515 ms.
Partially addresses #10221.
* fix(pkg): stop the Windows build overwriting the venv's pip
CREATE_VIRTUAL_ENV creates a virtualenv and then copies the whole of the
system Python's Lib over it, so that the relocated interpreter has a
standard library to use. That copy includes Lib\site-packages, and XCOPY
/Y overwrites the files that exist on both sides without removing the
ones that only exist in the venv, so the venv's freshly seeded pip ends
up spliced together with whatever pip C:\Python313 happens to have.
That was harmless while the two layouts agreed. pip 26.2 turned
pip/_internal/build_env from a module into a package, so on a builder
whose system pip predates 26.2 the new build_env/ directory survives the
copy whilst utils/misc.py is overwritten by the older one, and every
subsequent pip invocation dies before it does anything:
File "...\pip\_internal\build_env\installer.py", line 22, in <module>
from pip._internal.utils.misc import get_runnable_pip
ImportError: cannot import name 'get_runnable_pip' from
'pip._internal.utils.misc'
It cannot recover on its own either, since the next line of the script
is the pip invocation that upgrades pip.
Copy the standard library with ROBOCOPY and exclude site-packages, which
the venv is entitled to keep to itself. ROBOCOPY rather than XCOPY
because /XD takes a directory to skip and the existing
:CHECK_ROBOCOPY_ERROR helper already handles its exit codes; /R:3 /W:5
so a locked file fails the build rather than retrying for a million
attempts.
* fix(pkg): make the Windows build's ROBOCOPY failures diagnosable
Follow-up review fixes to the site-packages exclusion.
Exclude site-packages by name rather than by full path. The full path is
built from PGADMIN_PYTHON_DIR, so if that is ever set with a trailing
backslash the source and the /XD argument both gain a doubled separator;
should the match then fail, site-packages is copied over the venv again
and the build fails with the same ImportError this was meant to remove.
No directory inside the standard library is legitimately called
site-packages, so matching by name is both simpler and unconditional.
Drop "> nul" from both ROBOCOPY calls in favour of /NFL /NDL /NP.
ROBOCOPY writes its errors to stdout, so with the redirect in place a
copy that returns 8 or more aborted the build with nothing in the log to
say which file or share was at fault; the switches suppress the per-file
noise whilst keeping the header, the summary and any errors.
Guard the copy of the web directory with "|| EXIT /B 1" as well, since
EXIT /B inside :CHECK_ROBOCOPY_ERROR returns from the label rather than
from the caller and a failed copy was therefore ignored, leaving the
build to package an incomplete tree.
Guard virtualenv.exe likewise: with the standard library now copied by
ROBOCOPY, which creates its destination, a failure there would otherwise
surface later at the activate step instead of where it happened.
---------
Co-authored-by: Dave Page <dpage@pgadmin.org>
Covers the ten commits merged from master: the CSP per-request nonce,
the Object Explorer keyboard shortcut (folded into the existing #9631
entry, since it is the same feature reached another way), the Schema
Diff dependency ordering, function-body whitespace, foreign table and
sequence fixes surfaced by the now-asserting regression test, the
server-cursor prepare TypeError, the partitioned index TABLESPACE
clause, the UDF argument grid, the Helm existingSecret path and the
Validate binary path dialog.
Entries within each section are now ordered by issue number, with the
SHA-keyed ones last.
The endpoint returned a pre-built <b>/<br/> HTML string, and the frontend
switched to the escaping alertText() during the XSS hardening pass
(9e370d3cb), so the modal showed literal tags instead of formatted text.
Return structured {utility, version} data from the backend and compose
the modal content as React elements on the frontend, keeping the
untrusted binary version output routed through SafeMessage.
- Fall back to the not-found message on empty version output too
|| instead of ?? — an empty-string version from an unexpected
'--version' output shape should still show the fallback message.
Harden the default Content-Security-Policy so inline scripts run only via a
per-request nonce instead of a blanket 'unsafe-inline', and drop 'unsafe-eval'.
- Generate a per-request nonce (secrets.token_urlsafe) cached on flask.g so the
exact same value is emitted in templates and the CSP response header.
- Substitute a {nonce} placeholder in CONTENT_SECURITY_POLICY at runtime.
- Tag inline <script>/<style> tags, and set window.__webpack_nonce__ so webpack's
dynamically injected assets carry the nonce too.
- New default: script-src 'self' 'nonce-{nonce}' (no 'unsafe-inline'/'unsafe-eval').
- style-src keeps 'unsafe-inline': MUI/React inject un-nonced runtime styles and
inline style="" attributes that cannot be nonced.
- 'unsafe-eval' is not needed by production bundles; the dev ('eval' devtool)
bundles add it via config_local.py (documented in config.py).
Setting window.__webpack_nonce__ in HTML is a no-op: webpack only substitutes the __webpack_nonce__ identifier inside compiled modules, and MUI/emotion does not read it either (it requires an explicit createCache nonce). Same-origin webpack chunks are already allowed by script-src 'self', so the block did nothing. Remove it; the resourceBasePath inline script keeps its nonce.
Development bundles are built with webpack's 'eval' devtool, which the strict nonce policy blocks, forcing developers to manually add 'unsafe-eval' via config_local.py. Handle it automatically in get_content_security_policy() (which runs per request and therefore sees a DEBUG value overridden in config_local): when config.DEBUG is set and the policy uses a nonce, append 'unsafe-eval' to the script-src directive, without duplicating it. Production is unaffected and custom (non-nonce) policies pass through untouched.
Adds unit tests covering nonce generation/caching/per-request freshness, {nonce} substitution, pass-through of custom/None/empty policies, header emission, and the dev-mode behaviour (including the no-script-src edge case, exact script-src name matching, and no-duplication).
The dev-mode 'unsafe-eval' behaviour is now implemented, so these are ordinary regression tests. Remove the 'PLANNED'/'EXPECTED TO FAIL until impl' banners (which incorrectly told maintainers not to make the source pass) and an unused MagicMock import.
The annotation conditions referenced the non-existent top-level
.Values.existingSecret instead of .Values.auth.existingSecret, so the
secret checksum annotation and the empty-secret gating never worked as
intended when an existing secret wasn't supplied.
PostgreSQL rejects an explicit TABLESPACE clause naming the database's
own default tablespace when the index is on a partitioned table
('cannot specify default tablespace for partitioned relations'), so
the SQL pgAdmin generated for a new index could not be executed
against a partitioned table whenever no non-default tablespace was
chosen. The index_constraint and exclusion_constraint create.sql
templates already guard against emitting this redundant clause;
extend the same guard to the plain index create.sql templates.
Closes#10341
Generating a script for a function or procedure that only exists on the
source ("Source Only") routed through FunctionView.sql(), which always
calls reformat_prosrc_code() to wrap the body with a leading and trailing
newline around $BODY$...$BODY$ purely for readability in the SQL panel.
That reformatted body was then executed verbatim against the target, so
PostgreSQL stored the extra newlines in prosrc: the target's body no
longer matched the source's byte for byte, and re-comparing kept
reporting a whitespace-only difference unless "Ignore whitespace" was
ticked.
Schema Diff's update path (CREATE OR REPLACE against an existing target
object) already suppressed this formatting via allow_code_formatting,
added for #7102; this extends the same guard to the create/"Source
Only" path so Schema Diff never adds whitespace of its own to the SQL
it actually applies.
Added a dedicated regression test covering both the "Source Only" and
"Different" cases for a function and a procedure: applying the
generated script now leaves every object comparing as Identical
without needing "Ignore whitespace".
dependLevel drove the order in which generateFinalScript() writes
objects, with higher levels emitted first so dependencies land before
whatever needs them, but nothing has assigned it since the React port
deleted schema_diff_dependency.js: every row got Object.keys' default
undefined bucket, so the script came out in whatever order the rows
happened to be in.
computeDependLevels() rebuilds the levels for the full result set from
each row's dependencies, walking the reverse graph so a row that other
rows depend on always ends up with the highest of their levels plus
one, with a cycle guard for the circular dependencies the script header
already warns about.
Fixes#10295.
#10254 made the Object Explorer collapsible by clicking the current
workspace icon, but left that as the only way to do it. This adds a
shortcut, Ctrl+Alt+B by default, registered as its own
toggle_object_explorer preference so anyone can change it.
Ctrl+Alt+B rather than anything in the Shift+Alt family, which is full: b,
f, o, l, h, q, v, s, n, e, d and g are all taken, b by the existing Object
Explorer shortcut, which keeps its meaning of focusing the tree. Ctrl+Alt+B
is close enough to VS Code's Ctrl+B for the side bar to be guessable, and
keeps a family resemblance to the bracket keys used for tab navigation.
Ctrl+Shift+B would have been the other obvious candidate, but Chrome and
Firefox both use it for the bookmarks bar.
Worth noting for anyone reviewing: on keyboard layouts where AltGr is
Ctrl+Alt, this combination can be swallowed by the layout and produce a
character instead. That is true of any Ctrl+Alt binding, the preference is
user-configurable, and Shift+Ctrl+Alt+B is available if the default turns
out to annoy people.
The shortcut itself only fires an event, since the workspace provider owns
the visibility state, and the provider ignores it in classic layout where
there is no workspace toolbar and the Object Explorer is always shown.
Tested in a browser as well as in the unit tests: from the workspace layout
the key collapses and restores the panel, and the preference is registered
alongside the existing shortcuts.
psycopg's AsyncServerCursor.execute() has never accepted a prepare kwarg
(a server-side DECLARE CURSOR can't be a prepared statement) and raises
TypeError on any unexpected keyword, even one whose value is None.
PR #10030 widened AsyncDictCursor.execute()/_execute() to forward
prepare/binary to whatever cursor it holds, fixing a real
psycopg_pool.ConnectionPool.check_connection breakage. AsyncDictServerCursor
inherits that same execute()/_execute() without overriding it, so every
server-cursor query now unconditionally forwards prepare=None straight into
AsyncServerCursor.execute() and fails with
"TypeError: keyword not supported: prepare".
That TypeError isn't a psycopg.Error, so execute_async()'s
"except psycopg.Error" doesn't catch it. It propagates into the background
QueryThread's generic exception handler, which logs it and builds an
internal_server_error response that is discarded (the thread's return value
goes nowhere) - so the query silently never runs, invisible to the user,
and the async cursor is left with no query having actually executed on it.
Give AsyncDictServerCursor its own _execute() that drops prepare before
delegating, leaving DictCursor/AsyncDictCursor's forwarding for the
pool-checkout case it was written for untouched.
Strengthen test_server_cursor.py's existing scenario to assert the poll
response's actual status/result instead of only the HTTP status code and
the echoed server_cursor flag, and add a fast, DB-less regression test
asserting AsyncDictServerCursor._execute never forwards prepare.
`canDeleteRow for the function/procedure Arguments grid checked whether the
whole function was new rather than whether the individual row was new,
so once a function was saved the delete icon was disabled for every
argument row, including ones added but not yet saved (same bug pattern
already fixed for enum values in #8208). canAdd had the same whole-object
gate, hiding the "+" button entirely once a function was saved, so there
was no way to add a row in the first place.
Pre-existing (already persisted) arguments remain non-deletable, since
PostgreSQL has no way to remove an argument from a function via
CREATE OR REPLACE.
Also fixes _update_arguments_for_get_sql, which only merged the
'changed' key of the arguments diff and silently dropped (or, without
a 'changed' key at all, raised a 500) any newly added argument, so a
row added via the now-enabled "+" button actually survives into the
generated SQL.
Closes#10252
The Schema Diff comparison test wrapped applying its generated script, and
the comparison that follows it, in a bare `except Exception` that discarded
both. It reported a pass whatever the script did, which is why it printed
`syntax error at or near ")"` on every run whilst claiming two tests passed.
It now fails when an object's SQL does not apply, and when applying the lot
leaves the two databases different, with a short list of the differences
that are known not to settle yet so that the list cannot quietly rot.
Objects are applied one at a time and retried rather than as a single
script, because the script is no longer ordered by dependency (#10295), so
an object can fail purely because something it needs comes later on;
retrying tells that apart from SQL that is simply wrong.
Turning the assertions on found the following, each of which is fixed here:
* A range type being dropped and recreated because its kind changed lost
its subtype, because directory_diff() drops a plain value that only one
side of the comparison has, and rendered `CREATE TYPE ... AS RANGE ()`.
Once that was fixed it wrote the catalogue's `-` placeholder out as
`CANONICAL = -`, which the reverse-engineered SQL path already avoids.
Both are now handled where the comparison data is built (#10304).
* The constructor functions, casts and multirange types that PostgreSQL
creates for a range type were compared as though a user had written them,
so the script tried to recreate objects that come into being with their
parent type: 47 of 151 objects in the test's fixtures were these.
Internal dependencies are now excluded alongside extension ones, matching
what pg_dump does.
* Recreating a foreign table declared any column that also differed twice,
because a changed column was appended to the table's existing columns
rather than replacing the entry already there (#10297).
* Raising a sequence's MINVALUE above the value it currently sits at, or
lowering MAXVALUE below it, generated a statement PostgreSQL rejects
outright, taking every other change to that sequence with it. Such a
change is now accompanied by the RESTART it requires (#10298).
* A foreign table column added by Schema Diff lost its collation, because
get_columns.sql calls it collname whilst the column templates render
collspcname (#10300).
* A comparison that threw part way through emitted its failure and then
reported success as well, handing the client a fraction of the databases
as though it were a complete result (#10303).
Two differences remain listed as known: a rebuilt partitioned table keeps
the default partition used as scaffolding for the data copy (#10301), and
CREATE OR REPLACE wraps a function body in newlines, leaving a
whitespace-only difference (#10302).
Fixes#10293
* Stop the partition rebuild script copying from a temporary table
Asserting that the generated script actually applies turned up a
partitioned table rebuild whose script refers to a relation that has
never existed, along the lines of:
CREATE TABLE schema.temp_partitioned_2 (
LIKE schema.temp_partitioned_1 INCLUDING ALL
) PARTITION BY RANGE (col1);
so that applying it fails with 'relation "schema.temp_partitioned_1"
does not exist'.
There are two things going on here, and both are fixed.
PgAdminModule.register() is called once per application instance, whilst
the blueprint objects themselves are module level singletons, so each
sub-class that appends its sub-modules from its own register() (most of
them do, TableModule included) leaves a duplicate entry behind every
time a second application is created in the same process. Nothing in
production notices, because production creates a single app, but the
regression suite creates several, and anything walking self.submodules
then does its work once per duplicate: with four copies of the partition
sub-module, get_sql_from_submodule_diff generated the same partition
rebuild four times over. self.submodules is now de-duplicated as the
blueprint registers, and parentmodules likewise only gains an entry it
does not already hold.
The second call was only harmful because PartitionsView.get_sql_from_diff
stashed its temporary names on the caller's own dictionaries, replacing
the table's real name with a temporary one, so a subsequent call read the
first call's temporary name back as the original. It now works on copies
and leaves the comparison data it is handed alone, which makes it safe to
call more than once regardless of how it is reached.
* Take a mutable copy of request.form, and close cursors on a failed chunk
Two things picked up in review.
Sequence update() passed request.form straight through to get_SQL(), and
request.form is immutable, so as soon as _add_restart_for_new_bounds had
a restart to add (which is the whole point of it) a form encoded update
raised TypeError. It now takes a dict copy, as the msql path already
effectively did.
apply_sql_chunks() left the cursor open when a statement failed, and a
failed statement is retried on the next pass, so a run could accumulate
one open cursor per attempt. The cursor is now closed in a finally,
whether the statement worked or not.
Update APP_REVISION/APP_VERSION_INT in web/version.py and the Helm
chart's version/appVersion, and add docs/en_US/release_notes_9_18.rst
with a toctree entry.
The notes cover everything merged since REL-9_17 (ee7109d40..bd7cde585)
plus the three CVE fixes on this branch: the Webserver auth header
bypass (#10383), Backup argument and connection-string injection
(#10384) and the File Manager save_file symlink TOCTOU (#10385). The
CVE identifiers are still placeholders (CVE-2026-XXXXX) pending
assignment, and #10388 has no entry because its fix is not on this
branch yet.
Changes with no associated issue are listed under Housekeeping and Bug
fixes keyed by commit SHA, following the 9.15 convention, so the
"Additional changes" wrapper is dropped and Dependencies becomes a
top-level section.
Two non-blocking items from asheshv/pgAdmin4-cve#66's approval review:
- The auth_source lockout remedy was circular for the deployments most
likely to hit it: telling a locked-out admin to "sign in through
another authentication source" doesn't help when
AUTHENTICATION_SOURCES = ['webserver'] and there is no other source.
Documented the actual way out - temporarily add 'internal' back,
fix the affected users, remove it again.
- A malformed WEBSERVER_REMOTE_USER (not a non-empty string) was
demoted to info logging along with the client-triggered rejections
in the previous round, but it's a static operator misconfiguration,
not something a client varies per request - at info it would go
unnoticed while every header-based login silently fails. Moved back
to warning, consistent with the other config-error checks.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 2 of asheshv/pgAdmin4-cve#66's review:
- test_webserver_remote_user_trust.py bound WebserverAuthentication at
module-import time. test_auth_gating.py (which sorts and runs first)
evicts pgadmin.authenticate.webserver from sys.modules to observe its
re-import and never restores it, so the trust test held a discarded
copy of the module while patch('...webserver.User') patched the fresh
one - the patch silently missed and hit a real, unconfigured database.
Reproduced the reviewer's "27 passed, 3 failed" package-level run, then
fixed it by importing WebserverAuthentication inside each runTest()
instead, matching this repo's existing deferred-import convention.
Full authenticate package now passes 30/30.
- Documented the auth_source lockout: login() refusing any non-webserver
auth_source protects existing accounts, but a site with pre-existing
internal-auth users (including the default admin) will find nobody can
log in after enabling this. Added a warning to webserver.rst with the
fix (change Authentication source in User Management).
- login()'s user-is-None branch called logger.exception() with no active
exception, writing a useless NoneType traceback and never naming the
failed username. Now a plain warning naming it.
- Demoted per-request rejection logging (untrusted peer, secret mismatch,
misconfigured name) from warning to info, since an unauthenticated
client can trigger any of them on every request; kept genuine config
errors (a malformed WEBSERVER_TRUSTED_PROXIES entry, or the whole
setting not being a list/tuple) at warning.
- Guarded against WEBSERVER_TRUSTED_PROXIES being set to a plain string,
which iterated character-by-character and logged one warning per
character while still safely rejecting the request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes four issues from asheshv/pgAdmin4-cve#66's review:
- HTTP_-prefixed WEBSERVER_REMOTE_USER names (e.g.
HTTP_X_FORWARDED_USER) were permanently unresolvable via the header
path: Werkzeug normalizes '_' to '-' in header lookups but does not
strip the HTTP_ prefix. Normalize the name to its header form before
looking it up.
- A non-str/bytes or non-ASCII WEBSERVER_SHARED_SECRET made
hmac.compare_digest raise, turning every login attempt into an
unhandled 500. Encode both operands and catch the failure, degrading
to a rejected match with a logged warning instead.
- Added a test exercising the real ProxyFix/X-Forwarded-For bypass via
app.test_client(), which the existing scenarios (bare
test_request_context, no ProxyFix in the stack) could not catch.
- Added positive-path tests for login() (a genuine WEBSERVER user still
logs in; a header-asserted name with no matching User row fails
gracefully) plus a few low-priority coverage gaps the review flagged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WebserverAuthentication.get_user() fell back to request.headers when the
WSGI environ lookup missed, letting any client set Remote-User (or an
equivalent HTTP_-prefixed/hyphenated name) and log in as an arbitrary
existing user, including an Administrator, with no credential check.
Gate the header fallback behind an explicit opt-in
(WEBSERVER_REMOTE_USER_FROM_HEADER), a trusted-proxy IP/CIDR allow-list
(WEBSERVER_TRUSTED_PROXIES, checked against the raw pre-ProxyFix peer so
X-Forwarded-For can't spoof it), and an optional shared secret
(WEBSERVER_SHARED_SECRET). Also refuse to log a header-asserted identity
into any account whose auth_source isn't webserver, so a forged header
can never take over an internal-auth admin even if the gate above is
misconfigured.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "--" end-of-options marker (previous commit) stops pg_dump from
reading the database value as an option, but libpq still expands a dbname
containing "=" (e.g. "host=evil port=5433 dbname=postgres") into a
connection string -- letting a user redirect the connection, and the
exported PGPASSWORD credential, to an arbitrary server.
Pass the target database via the PGDATABASE environment variable instead
of as a command-line argument (as import_export already does). libpq
treats PGDATABASE as a literal database name and never expands it, so
both the file-write and connection-redirect vectors are closed. Also
fixes a latent bug where a database name legitimately containing "=" was
broken.
Update the regression tests: assert the database name is absent from
argv and carried in PGDATABASE (order-blind assertIn is insufficient).
Adds two scenarios to the object-backup unit test:
- normal dbname lands as the trailing positional after '--'
- a malicious '--file=...' value stays a trailing positional after '--'
and never overrides the storage-confined '--file'
Uses an order-sensitive assertion on the last args (the existing assertIn
harness is order-blind and would pass even if '--' came after the dbname).
The object-backup path appended the request 'database' field to the
pg_dump argv as a raw positional argument. Under getopt_long argument
permutation, a value like --file=/absolute/path is parsed as an option
and overrides the storage-confined --file, letting a low-privilege user
write pg_dump output outside their storage sandbox.
Insert the '--' end-of-options marker before the database name so
pg_dump always treats it strictly as the positional dbname.
Follow-up to the previous commit; the fix itself is unchanged.
The new test called create_app() inside runTest(). That is redundant --
runtests.py already calls setApp()/setTestClient() on every test object,
independent of setUp() -- and expensive: it did not complete in over two
minutes locally, while the two existing tests in this file run in 1.1s
total. It also routes through create_app_data_directory(), which calls
sys.exit(1) on any data-directory problem, surfacing as SystemExit
mid-test. Use the harness's already-authenticated test client instead,
which also removes the save_file.__wrapped__() unwrapping; that worked
only because pga_login_required applies @wraps outermost over
@mfa_required, so reordering those two lines would have silently pointed
__wrapped__ at the MFA wrapper.
The old test also stubbed check_access_permission to a no-op, which
tests the write guard with the containment check absent rather than the
actual threat. Let the real check run and plant the symlink from inside
it, which reproduces the check-to-open race deterministically.
Assert on the error message rather than a bare status code. 500 is also
returned by the check_access_permission branch, and "outside file
unchanged" holds whenever any error occurs, so the previous assertions
would pass even if the write never happened for an unrelated reason.
Add a positive control. Nothing asserted that an ordinary save still
succeeds, so a broken _open_upload_target() would have left the negative
test green while every Query Tool and ERD save failed. It also pins the
0o600 mode so that documented behavioral change cannot regress silently.
Report the leaf-symlink rejection specifically instead of leaking the
raw strerror. A user whose save is refused saw "Error: Too many levels
of symbolic links", which explains nothing. Note this is a real
behavioral change: a symlink inside the storage root pointing at a
target inside the storage root passed check_access_permission and used
to work, and is now refused -- the same tradeoff already accepted for
uploads.
Verified with runtests.py against PostgreSQL 18.6 in desktop mode:
--pkg misc.file_manager 19/19 pass
--exclude feature_tests 2037 pass, 491 skipped, 1 failure
The single failure is utils.driver.psycopg3 test_encoding (LATIN1 maps
to the iso8859-1 python codec), which reproduces identically on the
unmodified base commit bd7cde585 and is unrelated to this branch.
Both new tests were confirmed non-vacuous by reverting the
_open_upload_target() call to the bare open(): TestSaveFileRejectsLeafSymlink
then fails with "AssertionError: 200 != 500" (the symlink is followed and
the outside target is overwritten) and TestSaveFileWritesRegularFile with
"AssertionError: 420 != 384" (0o644 vs 0o600).
check_access_permission() resolves symlinks via realpath() and validates
containment (CVE-2026-7813), but save_file() re-joins the path afterwards
and wrote through a bare open(), which follows a symlink planted at the
leaf between the check and the write. The upload endpoint already closed
this exact gap with _open_upload_target()'s O_NOFOLLOW-protected open;
save_file() never got the same treatment.
Route save_file()'s write through _open_upload_target() too, so both
File Manager write sinks share the same kernel-enforced protection.
Reported privately per SECURITY.md.
JS (yarn, patch/minor only): babel toolchain, MUI, react/react-dom,
testing-library, axios, webpack ecosystem, jest, eslint plugins, and
misc small libs. Excluded @simonwep/pickr (webpack prod-bundle
interop break on 1.10.x), @testing-library/user-event@14.6.7 and
webpack@5.110.3 (both npm-quarantined at bump time).
Python (>3.9 requirements branch only, Python 3.9 pins untouched):
Authlib 1.8, google-auth-oauthlib 1.4.1, gssapi 1.12 (ungated),
psycopg[c] 3.3.5, selenium 4.48.0, testscenarios 0.7.0.
Skipped: paramiko 3->5 (blocked on sshtunnel's paramiko.DSSKey
usage), azure-mgmt-resource 26.0.0 (drops Python 3.9 support and
conflicts with the <26 upper bound kept for Azure CLI distro
packaging, see #10247).
Two bumped packages required source fixes:
- psycopg 3.3.5 restructured the private
psycopg._encodings._py_codecs attribute from a dict to a tuple of
alias groups, breaking our monkeypatch in
pgadmin/utils/driver/psycopg3/encoding.py with TypeError: 'tuple'
object does not support item assignment. Rewrote get_encoding()/
configure_driver_encodings() to use the derived py_codecs/
pg_codecs dicts instead, which stay plain dicts across psycopg
3.2.x-3.3.5 regardless of _py_codecs's internal shape. Verified
byte-for-byte identical behavior against 3.3.4 for every
encode_dict entry (SQL_ASCII, EUC_TW, UNICODE, ascii) plus
UTF8/LATIN1. Added a DB-free unit test
(pgadmin/utils/driver/psycopg3/tests/test_encoding.py) so a future
psycopg internals change fails fast here instead of only surfacing
as an app-wide import crash in the full regression suite.
- react-frame-component 5.3.2 breaks LayoutIframeTab.jsx (the iframe
wrapper every tool panel - Query Tool, Debugger, PSQL, ERD, Schema
Diff - renders into): the panel's iframe never mounts, hanging any
of those tools indefinitely (React error #130 in the production
bundle). This caused every run-feature-tests-pg CI job to fail.
Reverted to 5.2.6.
Everywhere we build the JS, we asked Yarn to install itself twice:
yarn set version berry
yarn set version <the version from packageManager>
The first call fetches whichever release is "berry" today and the second
immediately replaces it, so every build made two downloads to end up with one
Yarn, and the one that could not be reproduced tomorrow was fetched first.
That first fetch broke CI outright on 17 August 2026, during a GitHub
incident, when it returned an empty body and Yarn 1 died writing it out:
error TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of
type string or an instance of Buffer, TypedArray, or DataView
at writeFile (node:fs:2373:5)
The unpinned call is dropped everywhere it appeared: Make.bat, the macOS and
pip package builds, and the four workflows. The packaging scripts keep their
pinned "yarn set version ${YARN_VERSION}", which still reads packageManager
from package.json, so their behaviour is unchanged beyond losing a download.
The workflows go further and use Corepack, which reads packageManager itself
and provisions exactly that Yarn without writing .yarnrc.yml and
.yarn/releases into the repository root, where the JS project is not. The
Dockerfile already installed and enabled Corepack immediately before both
"set version" calls, so both were redundant there and are simply removed.
CONTRIBUTING.md and README.md already tell contributors to start with
"corepack enable", so this brings the automation in line with the documented
setup. Note that Corepack ships with Node.js only up to (but not including)
25.0.0, so it will need installing explicitly once the runners move past it,
as the Dockerfile already does.
v10 removes the default export in favour of a named one, so the import in
PgTreeView had to change with it; left as it was, useResizeObserver resolves
to undefined and the Object Explorer dies when the hook is called. The rest of
the v10 changes cost us nothing: it drops the /polyfilled entrypoint and its
@juggle/resize-observer dependency, no longer takes react-dom as a peer, and
requires React >= 18.2, which is comfortably met on React 19. The hook still
returns the { ref, width, height } shape the tree destructures.
Verified by hand in Chrome and Safari with the tree expanded through
databases, schemas, tables and tablespaces, resizing the panel and the window.
* Don't follow HTTP redirects on LLM API requests
ALLOWED_LLM_API_URLS is checked against the URL pgAdmin is configured
with, but the providers then issued the request through
urllib.request.urlopen(), whose default handler would follow a Location
header on to a destination that check was never applied to.
No legitimate LLM API replies with a redirect, so refuse them outright
rather than re-validating each hop: urlopen_no_redirect() installs a
handler that raises HTTPError instead, and the outbound calls in all
four providers and in the model-refresh endpoints now go through it.
This is hardening rather than a fix for an exploitable flaw, since
returning the redirect in the first place requires control of a host
that is already on the allowlist. Reported by Ziya Abdullayev.
* Handle 308 redirects on Python 3.10 as well
HTTPRedirectHandler only gained http_error_308() in Python 3.11, so on
the 3.9 and 3.10 interpreters we still support (and which CI runs) a 308
was never recognised as a redirect: it bypassed _NoRedirectHandler's
redirect_request() entirely and surfaced from http_error_default() as a
bare 'HTTP Error 308: Permanent Redirect'. The redirect was still not
followed, so this was never a security gap, but the caller got no
explanation of why the request had failed, and the test asserting our
message failed on every CI job as a result.
Mapping http_error_308 onto http_error_301, exactly as 3.11 itself does,
routes 308 back through redirect_request() so that every redirect status
behaves identically on every version we support.
validate_json_data() only checked that the Username key was present on
a non-shared server, not that it held anything useful, so an empty or
null value imported cleanly and left behind a server that libpq would
silently authenticate as the OS account running pgAdmin rather than
reject outright. Check the value, matching the truthiness check already
used for shared servers and the "Username must be specified" rule
enforced by the server dialog.
The exact ==25.0.0 pin was an incidental side effect of a mass
dependency bump and isn't required by pgAdmin's own code: the cloud
wizard only uses the top-level ResourceManagementClient re-export and
resource_groups.list(), both unchanged between 24.0.0 and 25.0.0.
Pinning to >=24.0.0,<26.0.0 lets distro packagers keep pgAdmin
installable alongside Azure CLI, which still requires
azure-mgmt-resource<25.
Closes#10247
Flask-Security-Too >= 5.7 depends on libpass, a fork of passlib that
installs its files under the same passlib/ import path. Our own
unconditional passlib==1.* pin in requirements.txt therefore causes
both packages to be installed into the same site-packages/passlib/
directory, and parallel installers such as uv can end up with a
mixed or overwritten set of files depending on install order.
pgAdmin's own code never imports passlib directly, and the older
Flask-Security-Too 5.6.x branch (used on Python 3.9) already pulls in
real passlib as its own transitive dependency, so dropping our direct
pin does not change what gets installed there. On newer Python
versions, only libpass is installed, resolving the conflict.
Closes#10239
Service-only connections (pg_service.conf) leave host, port and username
unset, so a per-server Password Exec Command crashed in get() with
TypeError: replace() argument 2 must be str, not None. Fall back to an
empty string for each unset placeholder instead.
Closes#10237
Importing a servers.json definition insisted on a Username attribute for
every server, so a shared server that carried only SharedUsername was
rejected with "'Username' attribute not found for server ...", even though
the dialog treats SharedUsername as the default username for a shared
server. Validation now accepts either attribute when the server is shared,
whilst non-shared servers still require Username.
The loader derives the username it stores in the same way. Username wins
when both are present, since it is the owner's own login and the per-user
override, and SharedUsername is used when Username is absent. That matches
what creating a shared server through the dialog produces, so an imported
shared server prompts for a password on the first connection rather than
sitting on a loading spinner with no username to connect with.
SharedUsername itself does not inherit a legacy Username, because it means
"the default username for the other users of this server" rather than a
fallback for the owner.
Folded in during review:
Validation now looks at the values rather than merely at the keys. Checking
for the presence of a key alone let {"Shared": true, "SharedUsername": null}
and its empty-string equivalent through, and the loader would then store
that as the server's username: on a schema created from the model that is an
integrity error surfaced as a raw "Error creating server" string, whilst on
a database migrated over the years, where the column carries no NOT NULL, it
imports silently and leaves behind a server that cannot connect. The
equivalent pre-existing hole on the non-shared path, where {"Username": ""}
is still accepted, is left for a separate change covering empty usernames in
general.
Tests in web/pgadmin/utils/tests/test_validate_json_data.py cover the
username requirements for shared and non-shared servers, the Service escape
hatch, the integer port check, and shared servers being skipped rather than
rejected for a non-admin user. The JSON format documentation notes that
SharedUsername may stand in for Username.
Fixes#9226
getNodeAjaxOptions() in web/pgadmin/browser/static/js/node_ajax.js, the
shared helper behind every AJAX-backed dropdown, cached responses only
once they had resolved, so when many components asked for the same URL
before the first response landed each of them saw an empty cache and
fired its own identical GET. Every column row's Data Type dropdown
mounting at once on a wide table's Columns tab therefore produced one
get_types request per row: around 150 concurrent duplicates on a
150-column table.
Concurrent GETs are now shared in the Axios wrapper. A module-level Map
tracks requests in flight, keyed on the request axios will actually make:
api.getUri() resolves the URI exactly as the request will, honouring the
instance baseURL, plain-object params, URLSearchParams and any custom
paramsSerializer, whilst the instance's own header configuration and the
per-request headers are hashed in alongside it, so an instance built with
extra headers never shares with a plain one. Params are canonicalised
first so that callers passing the same values in a different order still
share. The entry is removed once the request settles, success or failure,
so nothing leaks and later callers fetch fresh data.
Beyond params and headers there is plenty in an axios config that cannot
be captured in a key and that changes either what a caller gets back or
how it can be aborted: transformResponse, responseType, validateStatus,
timeout, signal and so on. Rather than let those share silently, a
request carrying anything outside the shareable set goes straight through
to api.get().
Each caller still runs its own caching and transform on the shared
response, so cached behaviour and response-shape handling are unchanged.
Fixes#10155.
The test added in #10240 fails in a full run, though it passes when the
authenticate package is tested on its own:
FAIL: runTest (pgadmin.authenticate.tests.test_auth_gating
.AuthSourceGatingTestCase)
AssertionError: 'oauth2' not found in
['internal', 'kerberos', 'ldap', 'webserver']
It cleared sys.modules for each provider and expected load_modules(), which
reaches them with "from . import <provider>", to import and hence re-register
them. That is not enough: IMPORT_FROM finds the attribute already set on the
parent package by whichever earlier test imported the module, and returns the
previous module object without re-executing it, so the provider never
re-registers itself.
Only oauth2 was affected, because it is the one provider imported earlier in a
full run, by the tests under browser/tests. Nothing pre-imports it when the
authenticate package is run alone, which is why the narrow run passed and CI
did not.
Drop the attribute from the parent package alongside the sys.modules entry, so
the import genuinely happens again.
Verified with the full suite as CI runs it, python regression/runtests.py
--exclude feature_tests: both scenarios pass and the runner's own exit status
is clean, with the only remaining failure a local config-directory permissions
check that depends on the developer's config_local.py.
Schema Diff compares tables whose serial columns have been
reverse-engineered back onto the SERIAL/BIGSERIAL/SMALLSERIAL pseudo-type,
and that reprojection left two problems behind for any column declared as
SERIAL.
First, the raw oid of the sequence behind the column's nextval() default
(defseqrelid) was still being compared, and two independently created
databases assign that sequence completely different oids, so structurally
identical columns were reported as different; it is now ignored alongside
its sibling seqrelid.
Second, the SQL generated for a serial column that genuinely does differ
read the column's old properties straight from the catalogue without the
same reprojection, so the real integer type and the real nextval() default
were compared against the pseudo-type and its emptied default. Any genuine
difference, such as a comment or a NOT NULL, therefore also rendered an
invalid ALTER COLUMN ... TYPE bigserial, a spurious DROP DEFAULT, and a set
of sequence options that only an identity column accepts. The changed
column is now normalised back onto the type that ALTER COLUMN can actually
be given, whilst the sequence it owns is left to the sequence object's own
comparison.
Covered by a unit test for the column comparison and by a Schema Diff test
that applies the generated SQL to the target and re-compares.
Fixes#10236
Clicking the Default workspace icon whilst that workspace is already
current now collapses the Object Explorer, and clicking it again brings it
back, in the manner of the VS Code side bar. The choice is remembered in a
new Browser/ObjectExplorerVisible setting, so it survives a refresh, and
File → Reset Layout restores the panel along with the rest of the layout.
The panel is hidden with CSS rather than unmounted, so the tree keeps its
expansion and selection state whilst it is out of the way, and because
rc-dock sizes box children with flex, the main panel takes the freed width
with no gap left behind.
Folded in during review:
The Object Explorer keyboard shortcut no longer does nothing when the
sidebar is collapsed. It asks for the panel to be shown first, through a
pgadmin:object-explorer:show event on pgAdmin.Browser.Events. The focus()
call in bindLeftTree() was already inert, since the panel is a div with no
tabindex, so the useful part of that shortcut is the tree selection that
follows it.
LayoutDocker now fires a RESET event which BrowserComponent listens for,
rather than resetLayout() being monkey-patched onto the shared docker
instance. That removes a mutation of an object other code holds a
reference to.
A failed attempt to persist the setting is logged rather than silently
discarded, since otherwise the sidebar could disagree with the server after
the next refresh with nothing to explain why.
The icon's tooltip says Show or Hide Object Explorer while it acts as a
toggle, instead of describing a workspace switch that will not happen, and
aria-pressed exposes the state to assistive technology.
Tests in web/regression/javascript/workspaces/WorkspaceProvider.spec.jsx
cover the default, the saved setting being honoured, each toggle persisting
its new value, two toggles in one tick resolving in order, and the reveal
event.
The AI documentation covered how to configure each provider but said
nothing about what actually leaves the server once one is configured,
which is the first question anyone working under data residency or
procurement constraints will ask.
This adds a provider-neutral section to the AI Reports documentation
setting out what may be transmitted, which is schema definitions,
configuration settings read from pg_settings, query text, EXPLAIN plan
output and, because the Query Tool assistant can run read-only queries,
row data where the assistant judges it necessary; when nothing is
transmitted at all, which is the default, since no provider is
configured out of the box; and the fact that each provider processes
that data under its own terms and in locations of its own choosing, so
the reader knows to check both those terms and their own organisation's
policies before enabling a cloud provider.
It also makes explicit that the list of providers reflects the APIs
pgAdmin can speak to rather than a recommendation, and cross-references
the new section from the AI Assistant notes in the Query Tool
documentation.
Desktop mode has no use for the kerberos, ldap, mfa, oauth2 or webserver
authentication providers: AUTHENTICATION_SOURCES defaults to internal, MFA is
inert because mfa_enabled() requires SERVER_MODE, and get_logout_url() already
guards its kerberos and oauth2 branches on SERVER_MODE. So skip importing and
initialising them unless SERVER_MODE is set, leaving desktop mode with
internal authentication alone.
The accompanying test needed wiring into our own runner before it would ever
execute: web/pgadmin/authenticate/tests/ had no __init__.py, so pkgutil did
not report it and find_modules() never reached the module, and the test class
derived from unittest.TestCase rather than BaseTestGenerator, so the
TestsGeneratorRegistry metaclass never registered it and get_suite() could not
pick it up. Both are fixed here, and the two scenarios now run and pass under
regression/runtests.py.
The test no longer asserts that pgadmin.authenticate.mfa is absent from
sys.modules in desktop mode, because it is not: both pgadmin/browser/__init__
and pgadmin/user_login_check import pgadmin.authenticate.mfa.utils
unconditionally. The original assertion only held because the test itself
popped the module first.
Two exclusions, both for bumps we have already established we cannot take.
pickr 1.10 switched to a dual CJS/ESM build whose CommonJS entry point exports
a namespace object rather than the class, so Babel's interop leaves
_pickr.default as an object and every dialog carrying a colour field dies on
mount. That took master's feature tests down for three days (#10264, reverted
in #10278). Because 1.9 -> 1.10 is a minor bump it rejoined the grouped
minor-and-patch PR immediately afterwards, buried among twenty other packages,
so minor updates are excluded here as well as majors until withColorPicker.js
is adapted to the new export shape.
Babel 8 requires Node ^22.18.0 || >=24.11.0, whilst Node 20 remains our
minimum, and every 8.x package peer-depends on @babel/core ^8.0.0 whilst we
are on 7.x. Taking any of them individually, as Dependabot proposed for
preset-env, eslint-parser and plugin-syntax-jsx, leaves an unsatisfiable peer
requirement and a mixed 7/8 tree. Babel 8 needs a coordinated migration across
all ten @babel/* packages once we are on Node 22.
@testing-library/jest-dom 7 declares "engines": {"node": ">=22"}, whilst Node
20 remains our minimum and is what most of the buildfarm runs. The bump passes
our JS tests on Node 20 in practice, so this is a deliberate choice not to
depend on an officially unsupported combination rather than a reaction to a
failure.
The "^6.9.1" constraint in web/package.json already prevents 7.x from being
installed; what Dependabot proposes is widening that constraint, which is the
part we do not want, so ignore major updates for this package until the
buildfarm moves to Node 22. See #10271 and #10210.
We were carrying 27 open Dependabot PRs, the great majority of them single
patch bumps of transitive packages, and the review cost of that queue is
entirely out of proportion to its risk. Every genuine problem found whilst
clearing it (paramiko 5.0 breaking sshtunnel, use-resize-observer 10.0 dropping
its default export, jest-dom 7.0 requiring a newer Node) was a major bump.
Group minor and patch updates into a single weekly PR per manifest, and leave
major updates arriving individually so each still gets its own review. Grouping
applies to version updates only, so security updates are unaffected and
continue to arrive as separate PRs.
This reverts commit 34dba5740e0e0e3bb6dd06afe4d84f9e4c0a9a6e.
pickr 1.10.1 breaks every dialog carrying a colour field, which includes
Register - Server, so the dialog renders as "Something went wrong." and all
feature tests time out waiting for its fields.
The cause is a dual-package interop change rather than anything in our code.
In 1.9.1 the UMD factory ended with "return e = e.default", so module.exports
was the Pickr class itself; Babel's _interopRequireDefault saw no __esModule
marker, wrapped it, and _pickr.default was the constructor. In 1.10.1 the
factory instead returns a namespace object carrying a default getter, still
without an __esModule marker, so the wrapper nests it one level deeper and
_pickr.default is that namespace object:
TypeError: _pickr.default is not a constructor
at initPickr (pgadmin/static/js/helpers/withColorPicker.js:69)
Nothing in the JS unit tests, eslint or the webpack build can see this,
because it only fails when the component mounts, which is why the bump went in
with a green board and only the feature tests caught it.
Moving to 1.10.1 later is perfectly possible, but it needs the import in
withColorPicker.js adapted to the new shape, and it must be validated by
opening a dialog rather than by a green Jest run.
rcedit was passed "%APP_VERSION%"", which stamped ProductVersion as
e.g. 9.17" on Windows installer builds.
Co-authored-by: Cursor <cursoragent@cursor.com>
paramiko 5.0 removed DSSKey entirely, whilst sshtunnel 0.4.0 still refers to
paramiko.DSSKey in SSHTunnelForwarder.get_keys(), which _consolidate_auth()
calls from the constructor. A major bump therefore does not merely drop DSA
key support, it raises AttributeError before any SSH tunnelled connection can
be established, and nothing in CI covers SSH tunnels so it looks green.
sshtunnel has had no release since 0.4.0 in 2019, so there is nothing newer to
move to on that side.
Ignore major paramiko updates until sshtunnel is fixed or replaced. The
exclusion is repeated under the /web/regression entry because
web/regression/requirements.txt starts with "-r ../../requirements.txt", so
that entry sees the root pins too.
Flask-Security-Too 5.8.2, released on 12 August 2026, fixed a long-standing
inversion in its login forms: `LoginForm.validate()` previously read a `True`
return from `UserMixin.is_locked()` as "not locked, carry on", and the base
implementation unconditionally returned `True`. Our `User.is_locked()` was
written against that inverted convention, so as soon as CI began resolving
5.8.2 through the loose `Flask-Security-Too==5.8.*` pin, an unlocked user
returned `True`, form validation failed, the login POST redirected with a 302
and every subsequent request arrived as `AnonymousUser`. The server-mode data
isolation tests caught it, though the breakage is not limited to tests: on
5.8.2 nobody could log in at all.
`User.is_locked()` now returns `True` when the account is locked, matching the
corrected upstream contract, and the dependency is floored at 5.8.2 so that we
cannot silently resolve a release which reads the value backwards. The two
conventions are mutually exclusive, hence a floor rather than a version check
in the model. The regression tests are updated to assert the fixed contract.
Python 3.14 is now supported, so add the trove classifier for it to the
pip packaging metadata and move the desktop builds onto it: the macOS
bundle now defaults to 3.14.7, and the Windows build looks for an
interpreter in C:\Python314 by default, with both build READMEs updated
to match. The minimum supported version is unchanged at 3.9.
Whilst here, the SonarQube scanner's Python compatibility list had drifted
somewhat, still naming 3.7 and 3.8 and stopping at 3.11, so it has been
brought into line with the versions we actually support.
pkg/mac/build-functions.sh reverted to bd841b882's state - keeps the
notarization diagnostic fix (print REQUEST_STATUS + fetch notarytool
log on failure), drops everything from 0296de48e through 6891b5e12.
Five iterations trying to fix _fixup_imports's otool handling of
"pgAdmin 4 Helper (GPU)" (newline-based path list, otool-classic
fallback via bare name, via resolved absolute path, absolute-path
retries for otool/install_name_tool) all failed to get the appbundle
build past this file. Confirmed along the way: the path itself is
byte-for-byte correct and the file verifiably exists at the moment
otool runs (od -c / ls -la evidence from a live buildfarm run); both
otool and otool-classic, called directly with correct quoting and a
resolved absolute path, still fail identically on it. That's a real
bug/limitation in Apple's tooling on this build node, not fixable by
further changes to how this script constructs or passes the path.
Reverting rather than continuing to guess. The stale nspkg.pth /
--system-site-packages fix in pkg/linux/build-functions.sh (verified
working, pgadmin4-rpm-build #180 succeeded) is untouched by this.
pgadmin4-appbundle-build #116 (macos-arm64) failed even with
otool-classic invoked directly via its real resolved path
(669c2f244's fix) - identical error, byte-for-byte, to calling plain
`otool -L`:
can't open file: .../pgAdmin 4 Helper (No such file or directory)
That rules out PATH resolution and otool's internal re-exec dispatch
as the cause too. Both otool -L and otool-classic -L, invoked directly
with correct argv quoting and a verified-correct, verified-present
path, fail identically on this one binary. Whatever's actually broken
is inside these tools' own handling of "(" in a path they're given.
Untested variable so far: this script pushd's into the bundle dir and
passes relative paths ("./Contents/...") throughout. Try a plain
absolute path with each tool before falling back to the diagnostic
dump - cheap to test, and applies equally to the two
`install_name_tool -change` calls further down that edit this same
file in place, since they could hit the identical issue.
Still not proven - if this also fails, the diagnostic dump (retained
as final fallback) will at least confirm parens themselves are the
blocker regardless of path form, which would mean sidestepping via a
temporary parens-free symlink is the next thing to try.
pgadmin4-appbundle-build #115 (macos-arm64) failed even with the
otool-classic fallback from 2d17e9e15:
otool-classic output: .../build-functions.sh: line 260:
otool-classic: command not found
otool-classic only exists inside the active Xcode toolchain
(/Applications/Xcode.app/.../XcodeDefault.xctoolchain/usr/bin/
otool-classic - the exact path visible in otool's own internal error
message), not as a bare command on $PATH. The previous fallback never
actually tested whether the real otool-classic binary can open the
file - it just failed on a missing command.
Resolve the real path via `xcrun -f otool-classic` (falling back to
deriving it from `xcode-select -p` if xcrun's lookup is unavailable)
and invoke that directly.
Not yet verified against a real buildfarm run.
pgadmin4-appbundle-build #114 (macos-arm64) failed on the same
"pgAdmin 4 Helper (GPU)" binary as before, but this time the
diagnostics from d4364555a actually ran and proved the path itself is
not the problem:
Raw bytes of the path (od -c): plain ASCII throughout, single
spaces, no non-breaking/unicode look-alikes, no trailing garbage.
ls -la of the exact path: file exists, correct size/perms,
timestamped seconds before otool ran - not a race condition either.
otool -L still failed via its internal otool-classic re-exec on that
exact, verified-correct, verified-present file. That's conclusively a
bug in otool's own internal dispatch, not anything this script builds
or passes to it - we can't fix Apple's tool, but we can avoid
triggering its broken code path.
Retry via `otool-classic -L "${TODO_OBJ}"` called directly (our own
argv-based quoting, not otool's internal re-exec, whatever that does
differently) when the initial `otool -L` fails. Every other binary in
the bundle keeps going through plain `otool -L` unchanged - this only
engages for the specific case that's already failing anyway. The
diagnostic dump (od -c / ls -la / otool-classic's own output) is kept
as the final fallback in case even the direct call fails too.
Not yet verified against a real buildfarm run.
pgadmin4-appbundle-build #113 (macos-arm64) failed again on the same
otool -L call, but none of the diagnostic output from 401afbfa3 showed
up. Instead:
The command "OTOOL_OUTPUT=$(otool -L "${TODO_OBJ}" 2>&1)" failed in
"_fixup_imports" with exit code 1.
That's this script's own ERR trap firing on the assignment statement
itself. Under set -e, `VAR=$(cmd)` still propagates cmd's non-zero
exit to the trap immediately - the previous diagnostic's separate
`OTOOL_STATUS=$?` line right after never got a chance to run, so
nothing was ever printed, same as the original bare failure.
Move the assignment into the condition of the `if` itself
(`if ! OTOOL_OUTPUT=$(...); then`) - bash explicitly exempts a
command being tested by if/while/until (or negated with !) from
errexit, so this time the diagnostic block will actually execute.
Still not verified - watching the next buildfarm run.
pgadmin4-appbundle-build #112 (macos-arm64) failed with otool-classic
unable to open a path that our own logging showed as correct
immediately beforehand ("pgAdmin 4 Helper (GPU)"). Rather than work
around it blindly (an otool-classic fallback was tried and reverted -
unverified, and risks masking a real problem instead of fixing it),
capture real evidence the next time this happens:
- raw byte dump (od -c) of ${TODO_OBJ} right before otool runs, and
again on failure - catches non-ASCII or invisible whitespace that
a plain `echo` can't show
- ls -la of the exact path and its containing directory on failure -
confirms whether the file actually exists at that exact moment
(rules out/in a race with something still writing it)
- otool's actual stdout/stderr and exit code, instead of letting its
own error text be the only signal
No fallback behavior - fails loudly (exit 1) with this diagnostic
dump so we can tell what's actually happening from the next real
buildfarm run instead of speculating further.
pgadmin4-appbundle-build #110 (macos-x64) failed with:
error: otool-classic: can't open file: ./Contents/Frameworks/pgAdmin
error: otool-classic: can't open file: ./Contents/Frameworks/Electron
_fixup_imports built its worklist with `awk -F':| '`, a regex
alternation that splits on a literal ':' OR any bare space. `file`'s
actual output format is "path: description" (colon-space as one
token), so any space *inside* the path itself - "pgAdmin 4 Helper
(Plugin).app", "Electron Framework.framework", both routine in macOS
app bundles - also got treated as a split point, truncating $1 to
whatever preceded the first space.
The resulting list was then joined with a single space (ORS=" ") and
iterated via an unquoted `for x in $list`, which word-splits on
spaces again - doubly ambiguous between a path's own spaces and the
list's separator, unrecoverable however it's parsed.
Fix: split only on the literal ": " token file emits (`awk -F': '`,
not `-F ':| '`), and switch the whole worklist to newline-separated
instead of space-separated, iterated with `while IFS= read -r`
instead of `for x in $unquoted_list`, at both the outer (executable)
level and inner (library-copy) level (`TODO="${TODO}"$'\n'"..."`).
Not yet verified against a real buildfarm run - can't reproduce the
macOS codesigning environment locally. Watching the next
pgadmin4-appbundle-build run.
The previous fix (bd841b882) tried to delete the specific broken
sphinxcontrib-jsmath nspkg.pth file from the system site-packages
before creating the venv. It didn't work: pgadmin4-rpm-build #177
failed identically on el-10. The delete silently no-opped - that
directory is root-owned on the build node and the build user doesn't
have write access there, so `find -delete` failed while `-print` (which
runs first) still logged the match, making the fix look like it ran.
Root cause is the --system-site-packages flag itself: it uses Python's
site.addsitedir() internally, which does not just add a directory to
sys.path - it also scans that directory for every *.pth file and
executes any "import ..." lines found in them. That's what runs the
broken nspkg.pth's stale namespace-package bootstrap code and corrupts
sys.path before core stdlib resolves, breaking pip's own subprocess.
We only need --system-site-packages for OS-provided packages that
don't have reliable pip wheels (e.g. dbus-python, a hard runtime
dependency per pkg/debian/build.sh's python3-dbus dep, needs
libdbus-1-dev to build from source). A *plain path line* (no "import")
in a .pth file only appends that directory to sys.path - it does not
trigger a further .pth scan of it. So: create the venv without
--system-site-packages, then write the system site-packages
directories as plain lines into a .pth file inside the venv's own
site-packages (which the build user does own). Same OS-package
availability, without ever asking Python to treat the system directory
as a site directory - the broken nspkg.pth is simply never read.
Not yet verified against a real buildfarm run - can't reproduce the
el-10 environment locally. Watching the next pgadmin4-rpm-build run.
macOS build (build-functions.sh): _notarize_pkg only printed
"Notarization failed." on rejection, giving no indication why. Print
the actual REQUEST_STATUS and fetch the full notary log via
`notarytool log` so future failures are actually diagnosable from the
Jenkins console instead of just "status: Invalid".
Linux build (build-functions.sh): _create_python_virtualenv creates
venvs with --system-site-packages, which pulls in the entire system
site-packages dir - including any stale, improperly-uninstalled
package's namespace-package .pth hook. That legacy pip/setuptools
mechanism runs at interpreter startup, before core stdlib is
guaranteed to resolve; a leftover sphinxcontrib-jsmath nspkg.pth on
the el-10 build node corrupted sys.path early enough to break pip's
own subprocess bootstrap, failing the whole build with a misleading
"No module named 'importlib'"/"'traceback'" error
(pgadmin4-rpm-build #176). Remove that exact known-broken file before
creating the venv - not every *-nspkg.pth, since this touches the
*system* Python install on a build node shared by other jobs, and any
other such file could still be load-bearing for something unrelated.
--system-site-packages itself is left untouched - it's required so
venvs can see OS-provided packages not available as clean pip wheels
on every target platform.
evaluate_and_patch_config() called keyring.get_password() synchronously
at `import config` time to detect a selected-but-unusable OS keyring
backend. On Debian 13 (and similar headless/RDP sessions with no live
D-Bus/GNOME-Keyring session), that call - and even the keyring import
itself - can block forever, freezing the whole desktop app before it
ever starts.
Revert evaluate_config.py to the pre-9.17 synchronous check (backend
name only, no get_password call). Move the usability probe into
pgadmin.utils.keyring_probe, run from create_app() in a background
daemon thread so it never delays startup, isolated in its own process
via subprocess.Popen(sys.executable, '-c', ...) so a hang can actually
be killed - a thread-only timeout can't do this, it would leave
CPython's per-module import lock held forever and wedge any later
`import keyring` in the parent process too.
subprocess.Popen (plain fork+exec), not multiprocessing.Process, is
required here: create_app() runs at the top level of pgAdmin4.py while
it's still being imported, and multiprocessing's spawn start method
refuses to start a child before the current process finishes
bootstrapping its __main__ module. An earlier version of this fix used
multiprocessing and crashed the probe thread with RuntimeError on every
single test run, which corrupted the SQLAlchemy/sqlite session for the
rest of app init and surfaced as an unrelated-looking "attempt to write
a readonly database" error on module_preference inserts.
config.USE_OS_SECRET_STORAGE is only ever read from request handlers
requiring an authenticated session, never at import time or inside
create_app() itself, so the async resolution race is safe in practice.
Tests mock subprocess.Popen for the timeout/kill/config-fallback
orchestration (deterministic, no real 3s wait or backend dependency),
plus one test class that runs the real probe script in a real
subprocess against a fake keyring module injected via PYTHONPATH, so
the script body itself has real coverage.
Verified with the full regression suite (--exclude feature_tests) in
both desktop mode (2134 passed, 0 failed) and server mode (2251
passed, 0 failed); remaining skips are pre-existing pgAgent-dependent
job tests.
Each of the Kerberos/LDAP/OAuth2/webserver login-mocking test classes
sets self.app.PGADMIN_EXTERNAL_AUTH_SOURCE (the Flask app instance
attribute before_request() actually reads) in setUp, but tearDownClass
only restored app_config.PGADMIN_EXTERNAL_AUTH_SOURCE -- a different
object (the config module). Whichever of these classes ran last
(test_webserver_with_mocking, alphabetically) left the live app
attribute stuck at WEBSERVER for the rest of the suite, making
before_request() route every subsequent unauthenticated /login request
through authenticate.login()'s webserver auto-auth path instead of
rendering the login form -- surfacing as an unrelated CSRF-harvest
failure in test_close_requires_auth much later in the run.
Full server-mode regression: 2507 tests, 0 failures/errors (was 2).
get_test_user() created secondary test clients via app.test_client()
but never called setApp() on them, so any test using a second user
crashed with 'NoneType has no attribute config' the first time it
needed to fetch a CSRF token (44 errors in a full server-mode run).
TestSqlEditorCloseRequiresAuth.setUp() logs out the shared
class-level tester before asserting a CSRF token was harvested; when
that assertion failed, unittest skipped tearDown() entirely (by
design, tearDown only runs if setUp succeeds), leaving the tester
logged out for the rest of the suite and cascading into 188 unrelated
failures. Switched to addCleanup(), which always runs.
Full server-mode regression: 2507 tests, was 232 failures/errors,
now 2 (both isolated to this same test's own CSRF-harvest timing in
full-suite order, unrelated to these two fixes).
_is_query_parens_balanced() always treated \' inside a single-quoted
string as an escaped quote, matching psql only when
standard_conforming_strings=off. Under the actual default (scs=on,
every supported PostgreSQL version), psql treats \ as a literal
character, so 'a\' closes the string right there. A crafted query
export payload like SELECT 'a\') TO PROGRAM 'cmd' x' passed the
balance check while the real ) it hid closed the wrapping \copy (...)
context in psql, exposing a live TO PROGRAM clause for RCE.
Since the correct interpretation depends on a server setting we
can't reliably know, reject any backslash inside a single-quoted
string outright instead of guessing.
Reported by Arpit Jain (arpitjain099).
sqlparse's string-literal lexing can disagree with PostgreSQL's: under
standard_conforming_strings = on (the default), a backslash before a
quote is an ordinary character to PostgreSQL but sqlparse treats it as
escaping the quote, so a payload like
SELECT '\';COMMIT;CREATE TABLE pwn(x int);SELECT 1 --'
passes _validate_readonly_query as a single SELECT while PostgreSQL
executes it as four statements -- the smuggled COMMIT ends the wrapping
BEGIN TRANSACTION READ ONLY and the trailing ROLLBACK is a no-op,
reintroducing the write/RCE bypass the bf47924444 fix was meant to
close (reported by Kai Aizen / SnailSploit).
Run the LLM-supplied query with prepare=True, forcing psycopg3's
extended query protocol. PostgreSQL's own Parse step -- not a
client-side approximation of it -- rejects any text containing more
than one statement, independent of how it's lexed. Threaded through
execute_2darray as an opt-in parameter (default None) so no other
caller's behavior changes. Also set SESSION CHARACTERISTICS AS
TRANSACTION READ ONLY as defense-in-depth against a smuggled COMMIT.
prepare=True alone is not sufficient: psycopg3's PrepareManager.get()
returns Prepare.NO -- checked before it even inspects the prepare
argument -- whenever the connection's prepare_threshold is None, which
is pgAdmin's per-server default ("Prepare threshold" is blank unless an
administrator sets it). On a default-configured server the extended
protocol never actually engaged, so the bypass stayed live. Force
prepare_threshold=0 on the LLM's single-use connection in
_connect_readonly() so the extended protocol -- and PostgreSQL's
single-statement Parse-step guarantee -- is unconditional on this
connection, without touching the server-wide setting or any other
session/caller.
Adds regression coverage at three levels: unit tests pinning that
prepare=True is always passed and that prepare_threshold=0 is forced
on the connection; and an end-to-end test driving the real
/sqleditor/nlq/chat/<trans_id>/stream route (LLM client mocked, real
tool-dispatch path) with the exact smuggled-COMMIT payload, confirming
the guarantee holds from the HTTP entry point down to the driver call.
Follow-up to #10142. Geometry Viewer's custom tile provider was only
configurable per-user (Preferences), with no way for an administrator
to set an organization-wide default (e.g. an internal tile server)
that applies out of the box for every user.
config.DEFAULT_GEOMETRY_VIEWER_PROVIDER is a plain dict; the five
custom_tile_* preference defaults (url, name, crs, attribution,
max_zoom) are sourced from it instead of hardcoded literals.
Preference.get() only reads a per-user DB row if the user has
explicitly saved one, otherwise it falls back to this default --
override semantics, one active provider, no naming-conflict surface.
config_local.py/config_distro.py/PGADMIN_CONFIG_* replace this config
variable wholesale, not merged key-by-key, so a naive
DEFAULT_GEOMETRY_VIEWER_PROVIDER['name'] lookup would KeyError-crash
preference registration (and app startup) on any partial admin
override, or AttributeError on a wrong-typed value.
resolve_geometry_viewer_provider_defaults() guards against this:
falls back per-field on missing/wrong-type values, validates crs
against the 3 supported choices, validates max_zoom is a non-bool int
in [0, 25], degrading to the original hardcoded defaults instead of
crashing.
No new attacker-reachable surface: DEFAULT_GEOMETRY_VIEWER_PROVIDER is
filesystem/deployment-level admin config, same trust boundary as
DEFAULT_BINARY_PATHS/OAUTH2_CONFIG. Existing DOMPurify sanitization on
name/attribution and the http(s):// + {x}/{y}/{z} URL validation in
GeometryViewerUtils.js (from #10142) apply uniformly to config-sourced
and per-user values alike.
Adds test_geometry_viewer_provider_defaults.py (9 scenarios: fully
valid, partial override, non-dict, invalid CRS, out-of-range/non-int/
bool max_zoom, non-string fields, empty dict).
Extends the tool-RBAC fix (permission enforced only on a blueprint's
"front door" route, with the rest relying on @pga_login_required alone)
to four permission types the original fix didn't cover:
* tools_ai ("AI Reports"): none of the 14 Security/Performance/Design
report routes in the llm blueprint checked this permission at all --
only the frontend menu was hidden behind AllPermissionTypes.TOOLS_AI
(llm/static/js/ai_tools.js). A user an admin had denied AI access to
could call the report/report-stream routes directly and have real
server/database/schema content sent to whichever external LLM
provider is configured, bypassing both the access control and the
data-exposure/cost boundary the permission exists to enforce.
* tools_import_export_servers: only 'save' was gated; 'get_servers' and
'load_servers' relied on @pga_login_required alone. Lower impact than
the above: get_servers only returns the calling user's own servers,
and load_servers only previews an already-uploaded file without
persisting (the actual write stays gated at 'save').
* tools_search_objects: only 'search' was gated; 'types' (a static list
of supported object-type names for the dropdown, no DB content) was
not.
* change_password: the self-service password-change route
(browser.change_password) never checked this permission, so revoking
"Change Password" from a role in Role management had no effect --
users under that role could still change their own password. Lower
severity than the others since the route is inherently self-scoped
(can only ever affect the caller's own password).
Extends tools/tests/test_tool_permissions_required.py with 17 new
scenarios for the first three, and adds a dedicated
browser/tests/test_change_password_permission_required.py for the last
(different blueprint, different permission category).
Verified against a live PostgreSQL server in SERVER mode: all 40
scenarios (37 + 3) pass with the fix applied. Negative control:
reverting the four fixes reproduces exactly the reported gaps -- 17 of
the 20 pre-existing scenarios still pass unaffected, the 17 new ones
fail, and change_password returns 200 instead of 403 -- confirming the
tests actually catch the regression rather than passing vacuously.
Supersedes 7 open Dependabot PRs by applying the safe ones and
properly fixing the one with a broken lockfile, in one CI cycle:
- setuptools ==82.* -> ==83.* (#10144, #10145 - duplicate PRs, same
patch). Only touches the `python_version > '3.9'` line; the
`<82; python_version <= '3.9'` gate for Python 3.9 is untouched,
so this doesn't affect Python 3.9 support.
- fast-uri 3.1.2 -> 3.1.4 in both /web and /runtime (#10183, #10195)
- fixes two real CVEs (GHSA-v2hh-gcrm-f6hx, GHSA-4c8g-83qw-93j6).
- tar 7.5.16 -> 7.5.21 (#10182) - patch series, decompression-bomb
and unbounded-recursion hardening only.
- shell-quote 1.8.4 -> 1.10.0 (#10185) - additive opt-in option +
parser fixes, no breaking changes.
- svgo 3.3.3 -> 4.0.2 (#10184) - Dependabot's own PR left yarn.lock
internally inconsistent (dropped the workspace-level `svgo` entry
while merging version-range blocks), so `yarn install --immutable`
failed in CI with "the lockfile would have been modified by this
install". Regenerated properly via `yarn up`/`yarn dedupe` here.
The direct `svgo`/`svgo-loader` deps are not actually wired into
any webpack rule (verified via grep) - the real SVG pipeline is
`@svgr/webpack` -> `@svgr/plugin-svgo` -> svgo 3.3.3, which this
bump does not touch - so the major version jump has no build
impact. `yarn.lock` now correctly keeps that separate 3.3.3
resolution alongside the deduped 4.0.2 one.
Not included (structurally blocked, tracked separately):
- paramiko 3.5.1 -> 5.0.0 (#9927): paramiko 5 removes DSSKey
entirely; sshtunnel 0.4.0 (dormant since 2021) still references
paramiko.DSSKey, so `import sshtunnel` would crash immediately.
- pywinpty 2.0.* -> 3.0.* (#10082, #10084): the existing pin cites
https://github.com/andfoy/pywinpty/issues/545, confirmed still
open ("process read and write not working as expected in 3.x").
Verified: `yarn install --immutable` clean in both /web and
/runtime, `yarn run linter` clean, full `yarn run bundle:dev`
compiles successfully.
Add test_name_literal_sql_escaping.py covering the templates fixed in the
CVE-2026-12044 follow-up: index Statistics (coll_stats.sql, both
dialects), publications (pg + ppas), and subscriptions dependency/
get-position lookups.
Each scenario renders the real template with a stacked-statement
apostrophe payload and asserts (1) the object name appears exactly as
qtLiteral escapes it and (2) the rendered SQL parses as exactly one
statement -- the property that actually prevents statement smuggling.
Verified the semantic assertion fails on the pre-patch raw-interpolation
form (parses as 2 statements) and passes on the fixed form, so the test
genuinely guards the fix rather than trivially passing.
Complements test_stats_template_regclass_cast.py (single-index
pgstatindex path) and the lint guard in test_sql_string_literal_lint.py.
Pure template-render test, no DB required.
Also independently verified during review: the fix's escaping neutralises
a live stacked-statement injection on PostgreSQL 16 (pre-patch slept 5s,
post-patch 0.0s) for both index-stats and pub/sub paths, and confirmed
the browser tree label and Statistics grid render object names as
React-escaped JSX text, so the HTML/XSS probe in an object name does not
execute in those paths. EPAS/ppas runtime path not exercised (ppas
publication templates are byte-identical to the pg variants and are
covered at render level by this test).
The 9.16 fix for CVE-2026-12044 hardened qtLiteral and switched sixteen
COMMENT ON / pgstattuple / pgstatindex templates to it, but the fix
missed several sinks that were previously excused in
test_sql_string_literal_lint.py's ALLOWLIST on the (incorrect)
assumption that schema/table/publication/subscription names sourced
from pg_catalog via the browser tree could never contain apostrophes.
They can, since PostgreSQL permits arbitrary characters in quoted
identifiers, so a low-privileged user able to CREATE TABLE, CREATE
PUBLICATION, or CREATE SUBSCRIPTION can plant a name that breaks out of
the unescaped '{{ name }}' interpolation once any user views that
object's Statistics or Dependencies tab.
Switch the following templates to qtLiteral(conn):
- schemas/tables/templates/indexes/sql/{16_plus,default}/coll_stats.sql
(Index Statistics -- reported as "SQL injection in pgAdmin index
Statistics (incomplete fix for CVE-2026-12044)")
- publications/templates/publications/{pg,ppas}/default/sql/
{dependencies,get_position}.sql
- subscriptions/templates/subscriptions/sql/default/
{dependencies,get_position}.sql
publications/__init__.py and subscriptions/__init__.py now pass
conn=self.conn into the dependencies.sql render_template call so the
qtLiteral filter has a connection to quote against. Remove the
corresponding ALLOWLIST entries in test_sql_string_literal_lint.py now
that these sinks are properly escaped instead of merely assumed safe.
Patch supplied pre-written; manual testing by the reporter reported
both the SQL-injection and HTML-injection probes as positive, but the
exact pre/post-fix state tested was not fully disambiguated in this
session -- treat as reported-but-not-independently-verified pending
follow-up confirmation.
In SERVER mode pgAdmin enforces authentication per route via
@pga_login_required; the before_request hook only handles desktop
auto-login and the Kerberos/Webserver redirect, so a route shipped
without the decorator is reachable unauthenticated (CWE-306). This is
the same defect class as CVE-2026-12046 (the sqleditor close/
update_connection routes).
A sweep found further omissions, now fixed with @pga_login_required:
* Constraints blueprint: nodes, proplist (obj), delete -- the routes
named in the "SQL injection in pgAdmin index Statistics" follow-up
report as an incomplete fix for CVE-2026-12046. delete is a
state-mutating DELETE (removes table constraints); nodes/proplist
return object information. Adds the missing pga_login_required import
to this module.
* preferences.get_all_cli (GET)
* debugger.close (DELETE)
* schema_diff.close (DELETE)
posix=False in shlex.split() never strips quote characters, so any
Windows hook path quoted to handle spaces (or any quoted argument)
came out with literal quotes still attached, and unquoted spaced
paths were split into multiple argv elements either way.
Use a shlex.shlex instance with posix=True (correct quote-stripping)
and escape='' (so backslashes in Windows paths are not treated as
escape characters). Document the quoting requirement in config.py
and add regression tests for quoted/unquoted spaced paths.
The MASTER_PASSWORD_HOOK setting lets administrators specify an external
command that returns a per-user encryption key, with %u in the configured
string replaced by the current user's name. The previous implementation
substituted the username into the command string and executed the result
with subprocess.Popen(..., shell=True). Because the username can originate
from an external authentication source (OAuth/OIDC claims, Kerberos,
webserver auth), a username containing shell metacharacters allowed an
authenticated user to execute arbitrary commands as the pgAdmin service
account in deployments where the hook uses %u.
Tokenise the trusted hook string into an argument vector first, substitute
the untrusted username into the individual arguments, and execute with
shell=False. The username is therefore always confined to a single argv
element and any shell metacharacters it contains are inert.
Note for administrators: hooks that previously relied on shell features
(pipes, redirection, environment-variable expansion, globbing) in the
MASTER_PASSWORD_HOOK string itself will no longer have those interpreted;
such logic should be moved into the hook script. The documented form,
'<PATH>/script.sh %u', is unaffected.
Adds regression tests covering usernames containing ';', '$()', backticks,
pipes, '&&' and newlines, plus an end-to-end marker-file proof that no
shell execution occurs.
Reported-by: B1gN0Se
Extends the tool-permission enforcement pattern from the reported CVE
fix to the backup/restore/maintenance/import_export blueprints, which
had the identical front-door-only gap: only create_*_job was gated,
while objects()/check_utility_exists()/get_import_export_settings()
relied on @pga_login_required alone.
Also clears password/save_password/tunnel_password on adhoc server
clones, not just ownership fields - Server.clone() copies every
column, so a non-owner cloning another user's shared server was still
persisting that user's stored credentials into a row the non-owner
now owns.
Adds missing socket test scenarios (erd tables, psql socket_input/
socket_set_role/resize) that the existing test docstring claimed to
cover but didn't.
Add regression coverage for the authorisation fixes:
* test_tool_permissions_required: a consolidated, per-blueprint check
that logs in as a user with no roles (hence no tool permissions) and
asserts every gated backend HTTP route across the query tool, grant
wizard, schema diff, ERD, PSQL and debugger returns 403. This catches
any future route added to these blueprints without the decorator.
* test_tool_socket_permissions_required: asserts the schema diff
compare_database/compare_schema and psql start_process Socket.IO
handlers refuse a user lacking the tool permission.
* test_adhoc_connect_server_ownership: asserts that an adhoc connect
triggered by a non-owner against an administrator-owned shared server
persists a server row owned by the caller and not shared.
All three are skipped in DESKTOP mode, where every request is
auto-authenticated as the all-permissions DESKTOP_USER.
When /misc/workspace/adhoc_connect_server is given a sid, it clones the
existing server. Server.clone() copies every column of the source row,
including user_id, shared and shared_username. When a non-owner triggered
an adhoc connect against an administrator-owned shared server, the clone
inherited the administrator's ownership and shared flag, so pgAdmin
persisted a new, administrator-owned, shared adhoc server row created at
the behest of another user; the row is committed before the connection is
attempted, so it survived even when the connection failed.
Force the cloned adhoc record to belong to the current user and to be
private (user_id, shared, shared_username) before committing, mirroring
the new-server branch, so a non-owner can no longer persist a
cross-tenant server record.
The tool permissions (tools_query_tool, tools_grant_wizard,
tools_schema_diff, tools_erd_tool, tools_psql_tool, tools_debugger) were
enforced only on a single "front door" route per tool, whilst the rest of
each tool's backend workflow relied on pga_login_required alone. An
authenticated user who had been denied a tool could therefore still drive
the tool through its other routes and Socket.IO handlers:
* Query Tool: View/Edit Data via sqleditor.initialize_viewdata and the
rest of the view-data chain, bypassing the gate on initialize_sqleditor.
* Grant Wizard: object discovery (objects), SQL preview (modified_sql)
and the actual privilege change (apply) were ungated; only acl_list
was protected. This allowed real GRANTs to be generated and applied.
* Schema Diff: initialize, servers, get_server, connect_server,
connect_database, databases, schemas, ddl_compare and the
compare_database/compare_schema socket handlers were ungated; only
panel was protected.
* ERD: initialize, prequisite, sql (table DDL generation) and the
tables socket handler were ungated; only panel was protected.
* PSQL: the panel route and the entire /pty Socket.IO namespace
(start_process, socket_input, socket_set_role, resize) had no tool
permission check at all, so a denied user could still obtain an
interactive psql session.
* Debugger: the directly addressable get_arguments/set_arguments/
clear_arguments routes were ungated.
Apply permissions_required to the HTTP routes and socket_permissions_required
to the Socket.IO handlers so the tool permission is enforced consistently
across each tool's surface. The permission check is the outermost
decorator, so it runs before any connection or transaction lookup.
Flask-Security's permissions_required only guards HTTP routes; pgAdmin's
Socket.IO event handlers had no permission-aware equivalent and relied on
socket_login_required, which checks authentication but not the tool
permission. Add socket_permissions_required as the socket counterpart of
permissions_required so that event handlers can enforce the same
tool-level RBAC as the routes.
It reads the user's permissions via has_permission() rather than
flask_principal's Permission().can(), so it does not depend on the
principal identity having been loaded onto the socket request context,
and it honours pgAdmin's Administrator bypass. On failure it disconnects
and raises ConnectionRefusedError, mirroring socket_login_required.
Geometry Viewer's base-layer choices were hardcoded (Empty, Street,
Topography, Gray Style, Light Color/Dark Matter), with no way to
point at a private/internal tile server or a provider not in that
built-in list.
Add a geometry_viewer preferences category (custom_tile_url, _name,
_crs, _attribution, _max_zoom) under sqleditor. When a valid custom
tile provider URL is configured (must be http(s) and contain {x},
{y}, {z}), it's used as the default base layer; built-in tiled
layers are hidden unless the custom provider's CRS is also
EPSG:3857 (Web Mercator), since they can't be reprojected together.
Non-mercator custom providers (e.g. EPSG:4326) only apply to SRID
4326 geometry data; other SRIDs still render on the existing blank
Cartesian plane.
Custom name/attribution are DOMPurify-sanitized before being passed
to Leaflet (both are innerHTML sinks). URL is not sanitized but is
gated by the http(s):// prefix check, so javascript:/data: URLs are
rejected; it's consumed as a tile-image src template, not an HTML
sink.
Adds GeometryViewerUtils.spec.js covering URL validation, defaults,
sanitization, CRS resolution/fallback, and base-layer composition.
The failure path for the second query (node.sql, resolving the
trigger function's node data) reported errormsg=rset — the result
dict from the first, already-successful get_function_oid.sql query
— instead of res, the actual error from the failed query. Any
failure here (permissions, catalog access) surfaced a stale,
misleading payload instead of the real cause.
Replace the per-server ServerGroupModule.has_shared_server() loop and
scattered ad hoc shared-server checks with two centralized helpers,
get_server_groups_for_user() and get_server_groups_for_user_query(),
in server_access.py — a single SQL EXISTS query instead of iterating
servers per group. get_server_group() reuses the same shared-access
logic, and returned groups now carry is_shared_group metadata so the
browser tree can render the correct icon and derive edit/delete
permissions consistently.
update() now resolves the target group via get_server_group(gid,
hide_shared=True), so a non-owner can no longer rename another
user's shared group; delete() already blocked deleting shared or
first-user groups and now reports clearer errors for both cases.
Desktop mode's non-SERVER_MODE query path uses
literal(0).label('is_shared_group') (a bare int has no .label()).
Adds test_sg_permissions.py covering owned/shared/desktop group
visibility and access.
* docs: add basic starter contributing guide
Add a CONTRIBUTING.md file to document the contribution workflow for pgAdmin 4.
Includes:
- Getting started instructions
- Issue reporting guidelines
- Development workflow and coding practices
- Pull request requirements
- Development environment setup
- Database migration guidance
- Security reporting process
- Community support resources
This provides a single reference for new and existing contributors.
* add language tag on the opening fence of the Windows command block
* Add architecture overview, define $PGADMIN4_SRC, and link GitHub URLs
* docs: update contribution guidance
Clarify contributor workflow, fix the runtime link, and add references to existing contributor docs.
On packaged Linux installs the bundled venv is created with
--system-site-packages (issue #7173) so it can reach system packages such
as dbus-python. That also exposes the deprecated system oauth2client and
whatever pyOpenSSL ships alongside it. googleapiclient imports oauth2client
optionally, and on Ubuntu 24.04 that drags in a pyOpenSSL too old for our
bundled cryptography, aborting startup at blueprint registration with
"AttributeError: module 'lib' has no attribute 'GEN_EMAIL'".
pgAdmin only ever authenticates to Google via google-auth and
google-auth-oauthlib, so park a None sentinel under
sys.modules['oauth2client'] before importing googleapiclient in both the
web module and the standalone pgacloud provider. The optional import then
fails cleanly and googleapiclient falls back to google-auth.
Closes#10110
Expanding a Trigger node under a Table to view its function threw
'Response' object is not iterable, crashing the tree.
get_children_nodes() can legitimately return a Flask Response instead
of a list (e.g. gone("Could not find the specified trigger
function") when the trigger's function has no matching row in the
trigger-function node query, as happens for a genuine internal-
language function such as suppress_redundant_updates_trigger).
NodeView.children() / PGChildNodeView.children() unconditionally
sorted whatever get_children_nodes() returned, crashing on a
Response instead of passing it through.
Separately, TriggerView.get_children_nodes() rendered the trigger-
function node.sql template (which quotes fnid via qtLiteral(conn))
without passing conn into the template context. This call site
predates qtLiteral requiring a connection; after qtLiteral was
hardened in 658bb585d to raise ValueError instead of silently
degrading when conn is missing, this broke node.sql rendering for
every trigger, not just the internal-language case.
Fix both: check isinstance(children, flask.Response) in children()
before sorting/iterating and return it as-is, and pass
conn=self.conn into the node.sql render call, matching every other
render_template call site in the file.
Fixes#10117
Extension UI gives error with PG 19
column reference "comment" is ambiguous LINE 5: e.comment
Issue caused because in pg19 :
SELECT * FROM pg_catalog.pg_available_extensions();
now outputs an additional column `location`
after default_version and comment is now 4th column
_verify_bundle_linkage's host-library scan only matched files by
.so/.dylib name suffix, so a shipped executable (Contents/MacOS/*)
or a Python.framework payload with a host-linked dependency but no
matching suffix could slip through unchecked — the same class of
bug #10135 was added to catch.
Detect Mach-O executables/libraries by content via `file` instead
(same pattern the codesign step in this file already uses),
regardless of extension or executable permission bits, so readable
non-executable dylibs are covered too.
9.16 failed to start on Intel Macs (#10123): cryptography had no
prebuilt Intel macOS wheel, pip compiled it from source, and its
openssl-sys build linked the builder's Homebrew OpenSSL into
_rust.abi3.so instead of the bundled one. _fixup_imports deliberately
skips _rust.abi3.so, so the dangling reference shipped unnoticed and
the app died on startup for anyone without that external dylib.
Add _verify_bundle_linkage, run after the bundle is assembled and
relocated but before code-signing: walks every .so/.dylib and fails
the build if any install-name points at a build-host prefix
(/usr/local, /opt/homebrew, /opt/local, $SLAVE_HOME). OS libraries
and @loader_path/@rpath/@executable_path references pass untouched.
Turns this class of bug into a build-time failure instead of a
runtime one.
Column-owns-sequence was detected by guessing the sequence name as
<table>_<col>_seq and string-matching it in nextval(...). Renaming
a table/column broke the guess (false negative, #10100); an
unrelated sequence with the same guessed name broke it the other
way (false positive, #10101).
Check col.get('seqrelid') (real pg_depend ownership) instead, and
exclude identity columns. SERIAL is now emitted iff a genuine
ownership dependency exists, independent of naming.
Fixes#10100, #10101
selectFilterOption() computed newOptions inside the functional
setSelectedFilters(prev => {...}) updater, then used that same
variable to dispatch TRIGGER_CHANGE_FILTER synchronously. Since the
updater runs lazily on next render, every chip toggle broadcast an
empty filter, so the tree always showed "No difference found" until
a full re-Compare.
Compute the new selection synchronously from current state and use
it for both the state update and the dispatched event.
Fixes#10102
getSelectionFromState() joined ranges with the chosen EOL but sliced
each range via state.sliceDoc(), which always uses the document's
own \n regardless of the LF/CRLF preference. Introduced in #8691
when the copy path switched from doc.sliceString(..., lineSep) to
sliceDoc(...) to fix multi-cursor copying.
Slice with state.doc.sliceString(range.from, range.to, lineSep) so
the chosen EOL applies within a range too; the #8691 multi-cursor
join behavior is unchanged.
Fixes#10158
OAuth2 provider settings are only read from the OAUTH2_CONFIG list;
bare top-level keys like OAUTH2_CLIENT_ID or
OAUTH2_SSL_CERT_VERIFICATION are silently ignored. This is an easy
trap in container deployments where every other option is set via
an individual PGADMIN_CONFIG_<KEY> env var.
Add warn_on_misplaced_oauth2_config(), called once at OAuth2 module
startup, that logs when per-provider OAUTH2_* keys exist at the top
level with no provider configured in OAUTH2_CONFIG. No behavior
change for correctly configured or unconfigured deployments.
Documents the supported container approach in oauth2.rst.
Closes#10053
Disconnect handlers in server.js/database.js built the transitional
tree-node label as an HTML string and passed it to setLabel(), which
writes via label.textContent — correctly escaping markup, so the
literal "<span class='text-muted'>[Disconnecting...]</span>" text
was shown instead of a greyed-out label.
Pass plain text plus a className: 'text-muted' instead; setLabel()
applies the className to the label span (default file-name class
otherwise), so muted styling comes from CSS, not injected markup.
Fixes#10106
The unqualified "View Data" action always ran with no LIMIT, unlike
"First/Last 100 Rows" which hardcode limit=100 — unusable on large
tables with no way to cap it.
Add view_data_default_row_limit preference (int, default 0 =
unlimited) under sqleditor Options. GridCommand.__init__ applies it
as self.limit when cmd_type == VIEW_ALL_ROWS and the value is
positive; objectquery.sql already renders LIMIT only when > 0, so
no template changes needed. First/Last 100 Rows untouched.
Closes#10104
evaluate_and_patch_config() only disabled USE_OS_SECRET_STORAGE when
keyring.get_keyring().name == 'fail Keyring'. On Debian 13 over RDP, a
real backend (e.g. SecretService) is selected because no D-Bus/GNOME
Keyring session is running, so the name check passes even though every
actual keyring call fails — leaving the crypt key unset and every
operation raising a bare CryptKeyMissing.
Probe the selected backend with a harmless read of a never-existing
entry after selection. A healthy backend returns None without
prompting; an unusable one raises, and we disable
USE_OS_SECRET_STORAGE so the app falls back to the master-password /
in-app crypt key mechanism instead of failing outright.
Fixes#10107
triggerExecution() only fell back to getQueryAt(cursor) when
getSelection() was falsy. Since #7293/#8691 (non-continuous
highlighted-block support), getSelection() flattens and joins all
ranges, so multiple empty cursor ranges can produce a truthy but
whitespace-only string (e.g. "\n"), skipping the fallback entirely —
ALT+F5 silently tried to execute whitespace. Same stale check existed
in checkUnderlineQueryCursorWarning(). Also fixes a latent
state.selection.head reference (EditorSelection has no such
property; correct field is state.selection.main.head).
Treat a whitespace-only selection as empty before the fallback check
in both functions. Real highlighted selections always contain
non-whitespace, so #7293/#9570 behavior is unaffected.
Fixes#10109
Consolidates 20 open dependabot PRs (JS + Python) into one bump, applied
directly rather than cherry-picked (PR branches were stale and would have
reverted unrelated fixes like the yarn packageManager pin). Adds further
same-major patch/minor bumps found by auditing beyond dependabot's own PR
list.
JS (web + runtime): axios, brace-expansion, form-data, undici, js-yaml,
dompurify, @babel/core, webpack, sharp, electron, eslint, react-checkbox-tree,
autoprefixer, eslint-plugin-jest, globals, jest, jest-environment-jsdom, svgo,
terser-webpack-plugin, typescript-eslint, webpack-bundle-analyzer,
@date-io/date-fns, @szhsin/react-menu, @tanstack/react-query, @types/react,
ajv, anti-trojan-source, ip-address, marked, moment-timezone, papaparse,
postcss, react, react-dom, react-draggable, react-timer-hook, sql-formatter,
zustand.
Python: certifi, selenium (version-gated: 4.45.0 requires Python >=3.10,
4.44.0 kept for <=3.9 to preserve Python 3.9 support).
Reverted / excluded, with reasons:
- azure-mgmt-resource 26.0.0: moved ResourceManagementClient from
azure.mgmt.resource to azure.mgmt.resource.resources, breaking
pgadmin/misc/cloud/azure/__init__.py at import time. Caught by the Python
regression suite. Kept at 25.0.0.
- @simonwep/pickr 1.10.0: switched its build tool to tsup, which marks its
UMD bundle as an ES module via Symbol.toStringTag instead of the
__esModule flag Babel's interop helper checks for. Babel double-wraps the
export, so `new Pickr(...)` resolves to a non-constructor and crashes
every dialog that mounts a color picker -- reproduces only in the
production/minified webpack build, not the dev bundle or Jest. Pinned to
~1.9.1 (tilde, not caret) so a future install can't silently float back to
1.10.x.
- paramiko 3->5 (#9927): structurally blocked by sshtunnel 0.4.0 still
referencing paramiko.DSSKey.
- @mui/material / @mui/x-date-pickers 7/8->9 (#10091, #10092): known
UI-breaking, needs the accompanying component fixes tracked on a separate
branch, not a bare version bump.
- react-arborist, @tanstack/react-virtual, react-frame-component: same-major
bumps available but excluded -- core object-browser tree, already-fragile
virtualization code, or tilde-pinned range respectively.
- A handful of JS packages hit Yarn 4.15's registry quarantine gate (blocks
just-published versions); backed off to the next-older version instead of
forcing through.
Added core-js as an explicit devDependency: it was never declared despite
webpack's Babel config (useBuiltIns: 'usage', corejs: 3) requiring it --
it only worked because @simonwep/pickr 1.9.1 happened to pull it in
transitively, which broke when pickr was briefly bumped.
Verified: eslint (web + runtime), full JS test suite (149/149 suites,
916/916 tests), webpack production build compiles clean, Python regression
suite (2388/2388, excl. Selenium), Selenium feature_tests (17/19 pass; the
2 failures trace to a local pldbgapi-extension gap, unrelated to any
bumped dependency).
#10156 fixed CI build failures caused by Yarn 4.x fetching a newer patch
with different builtin compat hashes, breaking --immutable lockfile
validation, but only patched pkg/linux/build-functions.sh and the GHA
workflows. Make.bat, pkg/mac/build-functions.sh, pkg/pip/build.sh, and
Dockerfile still hardcoded 'yarn set version 4', which is what broke the
Windows Jenkins snapshot build (job 1669).
* fix: pin Yarn version from packageManager field in build scripts
Replace hardcoded 'yarn set version 4' in build-functions.sh with a
dynamic lookup from each workspace's package.json packageManager field.
Also syncs runtime yarn version to 4.15.0 to match web/package.json.
Fixes CI build failures caused by Yarn 4.x fetching a newer patch that
produces different builtin compat hashes, breaking --immutable lockfile
validation.
The Server and SharedServer models each declared a relationship named
'servers' that actually pointed at the parent ServerGroup, with backrefs
('server'/'sharedserver') providing the collections. The naming was the
inverse of what it modelled and made the call sites read oddly.
Redefine the relationships in the natural direction using explicit
back_populates: ServerGroup gains 'servers' and 'sharedservers'
collections (carrying the existing delete-orphan cascade), and Server and
SharedServer each gain a 'servergroup' reference. The two call sites that
read the group name are updated from server.servers.name to
server.servergroup.name accordingly.
This is a naming/modelling cleanup with no functional or schema change, so
no migration is required.
Add an opt-in Gateway API HTTPRoute template to the Helm chart as an
alternative to the existing Ingress, addressing #9942. It is disabled by
default (httpRoute.enabled: false) so existing installs are unaffected.
The template mirrors the existing ingress.yaml conventions: the
pgadmin4.fullname backend, commonLabels/commonAnnotations propagation, and
a hostname that falls back to ingress.hostname when httpRoute.hostnames is
unset. parentRefs is required (a fail guard fires when it is empty),
apiVersion defaults to gateway.networking.k8s.io/v1, and the default rule
forwards "/" (PathPrefix) to the pgAdmin service. Custom hostnames and
rules can be supplied for full control. The new values are documented in
the chart README.
Set an X-Remote-User response header containing the authenticated username
on every request when the LOG_AUTHENTICATED_USER config option is enabled
(disabled by default). This allows the HTTP access log to be configured to
include user identity via standard log format directives
(%({x-remote-user}o)s in gunicorn, %{X-Remote-User}o in Apache) without
requiring any changes to pgAdmin's session or authentication behaviour. The
default gunicorn access log format is updated to surface the header.
The username is sanitised to a header-safe value: it is transliterated to
Latin-1 (HTTP headers are Latin-1 only) and any non-printable characters,
including CR/LF, are stripped, so unusual usernames cannot cause a 500 on
every response.
A signed, notarised and stapled pgAdmin 4.app was rejected by Gatekeeper
with "invalid destination for symbolic link in bundle". Gatekeeper walks
every symlink in the bundle and rejects the whole app if any link does
not resolve to a real file inside it; notarisation does not catch this, so
a broken link slips through stapling and only surfaces as a Gatekeeper
failure on the end user's machine.
The embedded Python.framework ships such links: an arm64-only build still
carries a bin/python3-intel64 launcher symlink (whose target
_strip_architecture deletes when it removes the foreign-arch files), and
the bundled Tcl/Tk frameworks carry PrivateHeaders links pointing at a
Versions/Current that has none.
Add a _prune_dangling_symlinks step that removes every dangling symlink in
the bundle after architecture stripping and before signing, then fails the
build if any remain, so this cannot slip past notarisation again.
PR #10047 made `Preferences.save_cli()` validate against the registered
preference object, but `setup.py set-prefs` only enters `app_context()`
and never calls `run_before_app_start()`, leaving `Preferences.modules`
empty. Every CLI write then fell into the "Module 'X' is no longer in
use." path and was reported as "Invalid value provided".
Trigger registration explicitly (run_before_app_start enters both
app and test_request contexts internally, satisfying registration
callbacks that touch current_user) and propagate the typed error
message from save_cli so users see the actual reason a value was
rejected.
The PDF docs build in the Check documentation builds workflow began
failing after the 9.16 release notes added "Mai Phạm Hiền" as the
reporter for CVE-2026-12049. pdflatex's default utf8 inputenc maps
Latin-1 and Latin-Extended-A but rejects the precomposed Vietnamese
code points U+1EA1 (a with dot below) and U+1EC1 (e with circumflex
and grave) with:
! LaTeX Error: Unicode character ạ (U+1EA1)
not set up for use with LaTeX.
Declare the two characters in the LaTeX preamble via
\DeclareUnicodeCharacter so they typeset correctly:
ạ -> \d{a} (a with combining dot below)
ề -> \`{\^e} (e with circumflex + grave)
Picked this over switching to xelatex because it is a single-file
change, keeps the existing pdflatex toolchain, and the CI workflow
already has the apt packages it needs (no .github/workflows change).
Verified by running make docs-pdf in an ubuntu:22.04 container that
mirrors check-doc-builds.yml (same apt packages, locale-gen
en_US.UTF-8, sphinx + sphinxcontrib-youtube): 682-page pgadmin4.pdf
produced cleanly, zero Unicode-character errors in the log.
Master commit 7b9b8cdd0 (#10054 / Issue #10050) rebased the
version-specific SQL templates so the new default targets PG 14, and
collapsed the 11_plus/, 12_plus/, 14_plus/ buckets into default/.
After merging that into cve-9.16, the SQL-string-literal lint
introduced for CVE-2026-12044 (658bb585d, 2ae0d3610) reported:
4 NEW violations at the new default/ paths --
subscriptions/sql/default/create.sql :: '{{ data.streaming}}'
subscriptions/sql/default/update.sql :: '{{ data.streaming}}'
grant_wizard/pg/default/sql/function.sql :: '{{ kind }}'
grant_wizard/ppas/default/sql/function.sql :: '{{ kind }}'
25 STALE entries pointing at the removed _plus/ paths --
aggregates/sql/{11,12}_plus/create.sql (data.initial_val,
data.moving_initial_val)
tables/index_constraint/sql/11_plus/properties.sql
(constraint_type)
tables/sql/{11,12}_plus/properties.sql (pg_get_partkeydef)
subscriptions/sql/14_plus/{create,update}.sql (data.sync,
data.streaming)
grant_wizard/{pg,ppas}/11_plus/sql/function.sql (func_type,
icon, kind)
search_objects/sql/{pg,ppas}/11_plus/search.sql,
search_objects/sql/ppas/12_plus/search.sql (search_text,
obj_type, LABELS_SCHEMACOL)
Each new entry carries the same justification as the corresponding
removed _plus/ entry (no semantic change to what is allowlisted; only
the path universe shrank). The four 'data.streaming' and 'kind'
additions are not new violations in the source-code sense -- the
expressions themselves were already allowlisted at the version-suffix
paths -- they only became visible at default/ paths after the rebase.
Verified: test_sql_string_literal_lint passes on PG 17 and PG 18
(33/33 each); pycodestyle clean.
The login page hardcoded the 'fab' (brands) Font Awesome style for the
OAuth2 button icon, so non-brand icons could not be used. Use the
configured OAUTH2_ICON as-is when it already specifies a style class
(e.g. 'fas fa-key'), and fall back to 'fab' when only an icon name is
given, preserving backward compatibility.
The CLI set-prefs path (save_pref -> Preferences.save_cli) wrote the raw
value to the configuration database without any type validation and
always reported success, unlike the GUI path which validates via
_Preference.set(). Route save_cli through the same set() validation
(set() now accepts an explicit user_id so it works outside a request
context), and make setup.py set-prefs check the result and report
preferences whose value was invalid.
* Fix View/Edit Data crash on a stale/non-filter session transaction object
initialize_viewdata restores the filter and data-sorting from any command
object previously stored in session['gridData'] under the same trans_id. It
assumed that object was always a filter-capable (View/Edit Data) command and
accessed old_trans_obj._row_filter / ._data_sorting directly.
That assumption is wrong: the same trans_id may have been used by the Query
Tool (a QueryToolCommand, which does not inherit SQLFilter), or the session
may contain an incompatible object persisted by an older version after an
upgrade. In those cases the attribute access raised AttributeError, returning
a 500 from the endpoint and - in desktop mode, where this runs during startup
- preventing the application from loading at all.
Guard the restore with isinstance(old_trans_obj, SQLFilter) (short-circuited
before the did/obj_id checks) so a non-filter object is simply skipped. Add a
regression test that seeds a pickled QueryToolCommand under the trans_id and
asserts initialize/viewdata returns 200.
Closes#9744
* test: avoid hard-coded PK constraint name colliding across the suite
The regression test created its table with a fixed 'table_pk' primary key
constraint name, which collides with the same name used by TestViewData in
this package when the full suite runs against one database. That made the
CREATE TABLE fail in CI (table not found -> IndexError on the OID lookup).
Let PostgreSQL auto-name the primary key instead.
---------
Co-authored-by: Ashesh Vashi <ashesh.vashi@enterprisedb.com>
Add 14 release-note entries that were merged on master but not yet
captured in the 9.16 notes: 1 new feature (#2431), 6 housekeeping
(#9817, #9866, #9917, #9959, #10014, #10023) and 7 bug fixes (#9701,
#9782, #9933, #9952, #9985, #10013, #10030). Entries inserted in
numeric order within each section.
Also add an "Additional changes (no associated issue) -> Dependencies"
section mirroring the 9.15 format, listing net direct dep bumps
between REL-9_15 and HEAD across Python (requirements.txt,
tools/requirements.txt, web/regression/requirements.txt), web/
package.json, and runtime/package.json. Transitive yarn resolutions
and the setuptools pin (covered by bug fix#9829) are excluded.
The Forgot Password and Reset Password pages had no way to navigate back
to the login page. Added a "Back to login" link (using the login URL
already used by the login form) to both pages.
Webpack 5 asset modules include the leading dot in the [ext] token, so
the 'img/[name].[ext]' and 'fonts/[name].[ext]' templates produced
filenames with a double dot (e.g. Roboto-Bold..ttf). Use '[name][ext]'
so the emitted filenames are correct.
The base (default) SQL templates previously targeted PostgreSQL < 12.
Re-base them so the default target is 14 - the oldest supported server
version - by collapsing every version bucket <= 14 (11_plus, 12_plus,
13_plus, 14_plus and the old default) into a single `default`, keeping
per file the content a v14 server resolves today. Buckets for newer
versions (15_plus, 16_plus, 17_plus, 18_plus) are retained as overrides.
The transformation is behaviour-preserving for every server version >= 14:
template (and test-fixture) resolution is byte-identical before and after
for all supported versions, verified programmatically across every bucket
container and confirmed by the resql, ERD and Schema Diff suites against
PostgreSQL 18.
Also drop PostgreSQL/EDB Advanced Server 13 from the 9.16 supported-server
list and repoint the sqleditor explain_plan tests (which referenced the
removed 12_plus/13_plus buckets) at the new default template.
Closes#10050
* Propagate column renames to FK and unique constraints. #9060
In the new-table dialog, the primary key already updated its column
references when a column was renamed, but foreign key and unique
constraint definitions did not, leaving them pointing at the old name.
Mirror the PK rename-propagation in the foreign_key and unique_constraint
depChange handlers (and add 'columns' to the unique constraint deps so it
fires on column changes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address review feedback for column rename propagation (#9060)
- Remap unique constraint INCLUDE columns on rename. The INCLUDE list
holds bare name strings (not {column} objects), so renaming an
included column previously emitted stale DDL. Now mirrors the
primary key INCLUDE handling.
- Add regression tests covering rename propagation in depChange for
foreign_key and unique_constraint, including the unique constraint
INCLUDE case.
- Correct the release note wording from "Create/Edit Table" to
"Create Table"; the propagation only runs on the new-table path
(state.oid === undefined).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashesh Vashi <ashesh.vashi@enterprisedb.com>
setuptools 82 stops shipping pkg_resources, which passlib (via
Flask-Security-Too on Python 3.9) imports, so a clean install on Python
3.9 failed with "No module named 'pkg_resources'". Python 3.9 is still a
supported target (RHEL/Rocky/AlmaLinux 8 and 9 build with system Python
3.9). Split the pin by Python version, mirroring the existing
Flask-Security-Too split.
The fix for #9570 (stop Alt+F5 showing a crosshair cursor) changed the
rectangular-selection eventFilter to require Alt+Ctrl and moved the
crosshair cue to Control. That broke the long-standing Alt+drag block
(column) selection and left an inconsistent Ctrl crosshair cue.
Restore the default rectangularSelection() (Alt+drag) and drop
crosshairCursor entirely - the crosshair-on-Alt was exactly the artifact
#9570 wanted gone, and CodeMirror's crosshair cannot be limited to an
active drag. This brings back block selection (#9864, #10029) while
keeping #9570's intent (no crosshair on the Alt+F5 shortcut).
removeOneToManyLink looked up a column by the FK's stored local_column
name and read .attnum unconditionally. After a column rename the stored
name no longer matches, so _.find returned undefined and .attnum threw,
blocking deletion of the table/link. Use optional chaining so a stale FK
simply doesn't match the link being removed and deletion proceeds.
Apply muted server foreground colour to column type labels (#9766)
Issue #9766 asked for both the object counts and the column type text
to follow the server's custom foreground colour. The dynamic per-server
CSS rule only recoloured the file-name and children-count spans, leaving
the column type text (span.text-muted) at its default low-contrast
colour.
Recolour span.text-muted to a reduced-emphasis blend of the foreground
colour using color-mix, so the datatype still reads as de-emphasised
secondary text while following the server colour.
The breadcrumbs popup is an absolutely-positioned, informational overlay
at the bottom-left of the object explorer, so it intercepted pointer
events and blocked clicks on the tree items beneath it. Set
pointer-events: none so clicks pass through to the tree.
The Query Tool's JSON cell editor pretty-printed jsonb values by parsing
and re-stringifying them with json-bignumber. While that preserves big
integers, it normalizes decimals through a JS float, so trailing
fractional zeros are dropped (10.00 -> 10, 3.140 -> 3.14). Because the
reformatted text is what gets written back, opening an unrelated jsonb
document and saving it silently rewrote numbers it never edited - which
can break applications that rely on the canonical jsonb text.
Switch the editor to lossless-json, which preserves the exact numeric
representation (big integers and trailing zeros alike), and pass it to
the underlying vanilla-jsoneditor as its parser so the in-editor format
action and tree/table modes are lossless too. The lossless helpers are
centralized in a small json_utils module with unit tests.
Closes#9854
The Query History panel formats entry dates/times with
Date.prototype.toLocaleDateString()/toLocaleTimeString(). On runtimes
whose default locale (derived from the OS/environment) is malformed,
these throw "RangeError: Incorrect locale information provided".
Because getDateFormatted()/getTimeFormatted() are called during render
(via getGroups/getGroupHeader/getDatePrefix), the uncaught exception
unmounts the whole SQL editor React tree, leaving the user with a blank
white screen and losing any unsaved query work.
Guard both helpers and fall back to a moment-based format (moment uses
its own locale data and does not depend on the broken Intl default) so
the editor keeps working instead of crashing.
Closes#7596
After a backend/pod restart the in-memory crypt key is gone, so
manager.connection() raises CryptKeyMissing. The new-connection
endpoints (_check_server_connection_status and get_new_connection_*)
swallowed it in a broad "except Exception", logged a full ERROR
traceback, and returned a generic error the client cannot recognise.
The standard recovery (a 503 CRYPTKEY_MISSING response that the client
uses to transparently re-establish the key and retry) therefore never
fired, leaving a spurious "Crypt key is missing" message in the Query
Tool and noisy tracebacks in the log.
Re-raise CryptKeyMissing (along with ConnectionLost /
SSHTunnelConnectionLost) before the generic handler, matching the
pattern already used by the query execution path, so these endpoints
emit the standard CRYPTKEY_MISSING response and the client recovers
gracefully.
Closes#10027
The macOS app is built for a single architecture (matching the build
machine, via ${ARCH}), but relocatable-python pulls the python.org
universal2 installer, so the entire Python.framework ships both arm64
and x86_64 slices. PostgreSQL-sourced dylibs may be universal too. The
foreign slice is dead weight that bloats the bundle and DMG.
Add a _strip_architecture step, run after _complete_bundle and before
code-signing (lipo invalidates signatures, so the existing sign passes
re-sign the thinned binaries). It removes the universal2 stragglers
(python*-intel64 launcher, config-*-darwin/python.o) and lipo-thins
every fat Mach-O in the bundle to the build arch, preserving file modes
and warning on anything lacking the target slice. Already single-arch
inputs (Electron and its helpers) are skipped.
When a tool (Query Tool, View/Edit Data, etc.) opens a connection for a
server whose password was not saved, it relies on the password cached on
the server manager. If that cached password is unavailable, the tool
prompts for it. The entered password was POSTed to the connect_server
endpoint, which short-circuited with "Server connected" whenever the
server's primary connection was already established -- silently discarding
the entered password. The tool's own connection therefore still had no
password and re-prompted immediately, producing an infinite prompt loop
in which the re-entered password appeared to be rejected.
Cache the entered password on the server manager (encrypted) in that
short-circuit path so the tool's connection can reuse it. The password
overwrites any cached value, so a regenerated short-lived cloud auth
token (AWS RDS IAM / Azure Entra) takes effect immediately.
Opening a huge JSON/JSONB cell in the Query Tool's cell editor parses,
pretty-prints and renders the entire document on the main thread. For
pathologically large values (e.g. a jsonb object with 100k keys) this
blocks the UI thread and pgAdmin becomes completely unresponsive, with
no chance for the user to back out.
Guard the JSON editor: when the raw cell value exceeds a size threshold,
render nothing until the user confirms via a warning dialog. If they
cancel, the editor is closed without doing the expensive work. Small
values are unaffected and open immediately as before. The editor already
uses commitOnOutsideClick: false, so the confirm dialog does not dismiss
the editor.
The size-threshold logic is a small, separately tested helper.
Closes#9868
refreshMenus() rebuilt the application menu from the module-level
cachedMenus, which is only populated once the renderer sends its menu
definition via the 'setMenus' IPC. When a menu refresh was triggered
before that happened - e.g. an auto-update event, or the user closing
the window while the UI was still loading - cachedMenus was undefined
and bindMenuClicks() crashed with 'Cannot read properties of undefined
(reading map)', surfacing as an uncaught-exception dialog.
Guard refreshMenus() so it bails out when there are no cached menus to
rebuild.
Closes#9762
The SQL-standard body detection used a regex that matched 'return'
anywhere in the body, so a plain SQL body containing a RETURNING clause
(or an identifier like 'returned_value') was wrongly treated as a
SQL-standard (BEGIN ATOMIC / RETURN) body. The generated CREATE OR
REPLACE statement then dropped the AS $BODY$ ... $BODY$ wrapper,
producing invalid SQL and a syntax error on save.
Anchor the RETURN form to the start of the body so only genuine
SQL-standard bodies are detected. Add unit tests for the detection.
Closes#10059
Resolves open Dependabot security advisories for transitive npm
dependencies that have no direct manifest entry (so Dependabot cannot
auto-open fix PRs for them).
tar (6 x HIGH): an old tar@6.2.1 was pulled in via
ttf2woff2@4.0.5 -> node-gyp@9.4.1 (and node-gyp's
make-fetch-happen@10 -> cacache@16 chain). ttf2woff2 6+ switched to an
ESM/default export that breaks @vusion/webfonts-generator's callable
usage, so rather than bump ttf2woff2 we override its node-gyp to
^11.2.0 via a scoped resolution. That modernises the whole sub-tree
(node-gyp 11, make-fetch-happen 14/15, cacache 19/20) onto tar@7.5.16
while keeping ttf2woff2 at 4.0.5 so webfont generation still works.
flatted (1 x HIGH): bumped 3.4.1 -> 3.4.2 in the Electron runtime
(GHSA-rf6f-7fwh-wjgh).
Corrected "varible" -> "variable" and rewrote the broken sentence to
explain that PGPASS_FILE is the path to a pgpass file that is copied into
the container and used as the .pgpass file.
doc: Correct the macOS Replace shortcut in the Query Tool toolbar docs. #9817
The Edit toolbar table listed Replace as "Option + Cmd + F (MAC)" /
"Ctrl + Shift + F (Others)", but the default Replace shortcut is
Cmd/Ctrl + R, per register_editor_preferences.py.
Tighten the release-notes prose for the two security entries whose
original one-liner didn't carry the impact framing settled during
Dave Page's 2026-06-11 CVSS review.
#10068 (CVE-2026-12048): re-lead with the critical-severity chain.
Default pgAdmin Content-Security-Policy allows inline script and an
iframe srcdoc inherits the embedding origin, so attacker JavaScript
ran same-origin to the victim's authenticated pgAdmin session and
could read every saved server connection credential and issue
arbitrary SQL against every server the victim was connected to.
The previous wording described the sinks but not the impact.
#10078 (CVE-2026-12044): re-lead with the stored
pgstattuple/pgstatindex sink (low-privilege user names a table or
index foo'bar, superuser viewer triggers SQL under the superuser
role). That sub-defect is what earns the score; the description-
field self-injection is bundled because the fix is the same. The
previous wording led with the self-injection only.
Both rewrites match the descriptions used in the CVE JSON records
submitted to the PostgreSQL CNA.
Extend the read-only query validator's regression suite with 17 more
scenarios lifted from a v2 of the original #10022 patch that was
never committed (recovered from an uncommitted draft).
Five new accept scenarios cover parser corner cases:
- leading whitespace
- trailing semicolon followed by trailing whitespace
- leading line comment
- semicolon inside a string literal (must not split the statement)
- (SELECT 1) UNION (SELECT 2), confirming the leading-keyword
traversal walks past parentheses
Twelve new reject scenarios pin commands that directly attack the
BEGIN TRANSACTION READ ONLY wrapper rather than merely attempting a
generic write:
- bare COMMIT / END / ROLLBACK / ABORT / BEGIN as single statements
(previously tested only as the leading verb of a multi-statement
payload)
- START TRANSACTION (synonym for BEGIN)
- SAVEPOINT (savepoint manipulation)
- SET LOCAL transaction_read_only = off
- SET SESSION default_transaction_read_only = off
- DISCARD ALL (resets session state -- search_path, prepared
statements, etc.)
- multi-statement payloads where the leading statement is an
allowed verb (SELECT or WITH) followed by ROLLBACK and a write
-- closer to a real attack shape than the trailing-COMMIT PoC
If a future refactor of _ALLOWED_LEADING_KEYWORDS or the statement-
counting logic lets any of these slip through, the new scenarios
fail loudly. Module is now 77 passed / 0 failed / 0 skipped.
Layered follow-up to the COMMENT description SQLi fix. Closes three
related gaps the original patch did not cover.
1. Stats templates — apostrophe-in-identifier SQLi (10 sites).
The pgstattuple/pgstatindex call sites in the stats templates rendered
the target relation as an embedded single-quoted literal, e.g.
pgstattuple('{{schema_name}}.{{table_name}}')
pgstatindex('{{conn|qtIdent(schema)}}.{{conn|qtIdent(name)}}')
A user with CREATE privilege on a schema could plant a table or index
named `foo'bar` (PostgreSQL permits any character except NUL inside a
quoted identifier). Any viewer who then opened that object's stats
panel would render an unbalanced SQL literal. qtIdent does NOT escape
apostrophes — it escapes the embedded double quotes that delimit a
quoted identifier — so the outer single-quoted literal still broke
out. Same bug class as the COMMENT description vector, just gated on
DDL privilege instead of dialog access.
Fix replaces all 10 sites with the canonical regclass form:
pgstattuple({{ tid }}::oid::regclass)
pgstatindex({{ exid|cid|idx }}::oid::regclass)
The OID is already passed by each handler (tables/utils.py,
exclusion_constraint/__init__.py, index_constraint/__init__.py,
indexes/__init__.py, views/__init__.py). The cast eliminates the
embedded string literal entirely — the bug class can no longer recur
through these sites.
Files:
tables/sql/{default,16_plus}/stats.sql
views/templates/mviews/{pg,ppas}/default/sql/stats.sql
tables/templates/exclusion_constraint/sql/{default,16_plus}/stats.sql
tables/templates/index_constraint/sql/{default,16_plus}/stats.sql
tables/templates/indexes/sql/{default,16_plus}/stats.sql
2. Lint regex — embedded-Jinja blind spot.
The original lint regex `'\{\{[^}]+\}\}'` only matched literals where
the Jinja interpolation filled the entire single-quoted body. It
missed the embedded form `'foo{{ x }}bar'` — exactly the shape used
by the stats templates above and by `'%{{ search_text }}%'` in
search_objects.
The broadened regex `'[^'\n{}]*(?:\{\{[^}]+\}\}[^'\n{}]*)+'` catches
both forms. The `[^'\n{}]` body constraint is what stops the regex
from spanning a Jinja control block `{%...%}` — without it the regex
would walk across long `{% if %}` chains and produce huge false-
positive matches that span unrelated literals.
Allowlist extended with one-line justifications for every additional
occurrence the broadened regex now surfaces (search_objects manual
escape, CATALOGS.LABELS_SCHEMACOL macro, gettext-translated catalog
labels, opcintype oidvector pairs, the pg_get_partkeydef CASE
expression where the interpolation is between two separate literals
rather than inside one).
3. qtLiteral — close the inner-except silent-failure hole.
The fail-fast for a missing `conn` was the right shape but the inner
`except Exception: print(value)` still silently returned the raw
input when `psycopg.sql.Literal(value).as_string(conn)` raised
(unadapted custom type, encoding error, etc.). Same SQL-injection
sink the outer fail-fast just plugged, gated on a different failure
mode. Removed the except so the exception propagates — callers can
now react explicitly instead of seeing an unescaped value flow into
their SQL.
Tests:
test_stats_template_regclass_cast.py — 10 scenarios that render
each stats template with an apostrophe-bearing identifier payload
and assert (positive) the pgstat* call uses ::oid::regclass and
(negative) no `pgstat*('...'` form recurs, and the apostrophe
payload never appears inside a literal in the rendered SQL.
test_qtliteral_no_silent_unescape.py — asserts qtLiteral raises on
an unadaptable value rather than silently returning it.
42 tests / 0 failures across the touched packages.
Fixes a SQL injection vulnerability where authenticated users could
break out of SQL string literals in COMMENT ON ... IS '<description>'
clauses by submitting an apostrophe-laden description through pgAdmin
dialogs. The original report covered domains; the patch expands the
fix to every site of the same pattern.
Three layers of defense:
1. Site fixes (16 places) — Replace '{{ x.description }}' with
{{ x.description|qtLiteral(conn) }} across templates for domains,
domain constraints, foreign tables, languages, event triggers,
and the views OID-lookup query. Plumbs conn=self.conn through
every render_template call that needed it. Also fixes a `{ % elif`
Jinja typo in foreign-table schema diff that was preventing the
elif branch from being reachable.
2. Driver hardening — qtLiteral (in utils/driver/psycopg3/__init__.py)
used to silently return the raw unescaped value when conn was
falsy. Now raises ValueError with a message pointing at the two
fixes (render_template(..., conn=) or pass conn as the second
argument). Surfaces this whole bug class loudly going forward,
and immediately uncovered 8 latent plumbing bugs in
schemas/__init__.py, schemas/functions/__init__.py,
schemas/tables/utils.py, foreign_servers/__init__.py, and 7 sites
in roles/__init__.py — all now fixed.
3. Regression tests (3 new files):
- test_comment_description_sql_escaping.py — renders each
previously-vulnerable template with an apostrophe-injection
payload and asserts the escaped fragment is present (15
scenarios).
- test_sql_string_literal_lint.py — walks every *.sql template,
flags every '{{ ... }}' single-quote-wrapped Jinja
interpolation, and compares against a curated allowlist (75
entries, each with a justification — OIDs, fixed enums,
server-derived identifiers, SQL-comment headers, etc.). New
occurrences fail the test until either qtLiteral is used or
an allowlist entry is added.
- test_qtliteral_requires_conn.py — unit test asserting the new
fail-fast behavior.
Reported by Jasser Chebbi (j3seer).
The 'close' (DELETE /sqleditor/close/<trans_id>) and
'update_sqleditor_connection' (POST /sqleditor/initialize/sqleditor/
update_connection/...) endpoints were the only state-mutating SQL Editor
routes missing @pga_login_required. Both reach pickle.loads on
session['gridData'][trans_id]['command_obj'] via
close_sqleditor_session() and check_transaction_status() respectively.
Combined with a forged session file (precondition: SECRET_KEY leak +
write access to sessions/), this gave an unauthenticated attacker a
pickle deserialization sink and arbitrary code execution in the pgAdmin
process. Adding the login decorator forces is_authenticated/MFA checks
before the unsafe deserialization path is reached, matching the
convention used by every other endpoint in the module.
Includes a server-mode regression test that harvests a CSRF token from
GET /login (mirroring the attacker's path) and asserts both endpoints
reject the unauthenticated request before reaching the route body.
Self-skips in DESKTOP mode because pgAdmin's before_request hook
re-authenticates DESKTOP_USER on every request there, so no auth
decorator can be exercised in an unauthenticated state. Wired into the
existing server-mode CI workflow alongside the data-isolation tests.
Reported by Fernando Bortotti <fernando.bortotti@bsd.com.br>.
Promote the post-connection-SQL sanitiser to a generic helper and apply
it to every cloud-module endpoint that propagates AWS / Azure / Google
SDK exception text into a JSON response field. Closes the RDS
HTML-injection vector reported against /rds/verify_credentials/ and
sweeps the same pattern across the verify-credentials, deploy, regions,
and update-server paths so no remaining cloud endpoint embeds raw
SDK / OS exception text into the response.
Sanitiser
- Move web/pgadmin/utils/driver/psycopg3/text_sanitize.py to
web/pgadmin/utils/text_sanitize.py.
- Rename sanitize_driver_message -> sanitize_external_text; the
function now describes its real role (HTML-escape text from any
external/untrusted source — driver, cloud SDK, OS process).
- Move tests to web/pgadmin/utils/tests/test_text_sanitize.py.
- Update the single existing importer in psycopg3/connection.py.
Backend wrap sites
- misc/cloud/rds/__init__.py — verify_credentials info= (the
reported case: AWS STS IncompleteSignature echoes access_key
verbatim into the exception string) and get_regions errormsg=
(boto3 Session().get_available_regions exception).
- misc/cloud/azure/__init__.py — verify_credentials and
check_cluster_name_availability errormsg= (3 sites).
- misc/cloud/google/__init__.py — verify_credentials
(PermissionError + generic Exception path-resolution branches and
the get_auth_url error), verification_ack, projects, regions,
instance_types, database_versions — 8 sites covering every path
that propagates SDK / file-resolution exception text.
- misc/cloud/__init__.py — central /deploy endpoint (errormsg=resp
where resp is str(e) bubbled up from deploy_on_rds /
deploy_on_azure / deploy_on_google) and update_cloud_server
(errormsg=server where server is the str(e) from db.session.commit
failure).
Frontend
- components/FormComponents.jsx — declare plainText in
FormFooterMessage.propTypes (it already passes through via
spread).
- misc/cloud/static/js/CloudWizard.jsx — add plainText to the three
FormFooterMessage sites. INFO messages there are gettext literals,
so plain-text rendering is uniformly safe; the ERROR path now
follows the SafeMessage contract for backend-derived strings.
Tests
- rds/tests/test_rds_verify_credentials_xss.py — wiring test that
patches RDS.validate_credentials to return an AWS-shaped error
embedding an <iframe> payload, asserts the response info field is
entity-encoded and contains no raw markup. Includes a happy-path
guard against the sanitiser altering 'verified'.
- azure/tests/test_azure_verify_credentials_xss.py — same pattern,
Azure.__init__ touches current_user so the Azure class is patched
to a stand-in that yields the HTML-bearing validation error.
- google/tests/test_google_verify_credentials_xss.py — covers the
PermissionError and generic-Exception branches of the
client_secret_file resolution path (most directly user-influenced).
- test_post_connection_sql_xss.py — docstring + comment updates for
the renamed function.
Frontend rendering is already DOMPurify-guarded at the NotifierMessage
sink, so the reported PoC payload would not execute even without
these backend changes; the fixes here are the defence-in-depth layer
the report's section-5 "Suggested directions" calls out, ensuring API
consumers other than the browser (audit logs, third-party clients)
never see raw markup.
876 / 876 JS tests + ESLint clean. Cloud / utils / server-groups
Python test packages pass (24 + 60 + 102, with pre-existing skips
unchanged).
Mitigates a stored XSS vector where a malicious PostgreSQL server's
ErrorResponse — or any backend-derived string — could inject HTML into
pgAdmin's DOM via the notifier, Explain visualiser, or form-input error
paths. Combines three complementary layers of defence.
Frontend — DOMPurify at every HTMLReactParse sink:
- NotifierMessage (toasts) and FormFooterMessage
- FormInput help / error messages, FormNote
- ModalProvider AlertContent + confirmDelete
- ToolErrorView (tool open failure)
- Explain/Analysis NodeText (plan extra-info renderer)
- SQL editor confirm dialogs (transaction, promotion, close-running)
- Dialogs/ConfirmSaveContent
- PreferencesHelper modal alert
- SelectThemes helper text
No HTMLReactParse call site renders text unsanitised after this.
Frontend — plain-text contract for backend-derived strings:
- New components/SafeMessage.jsx exports SafeMessage (plain text,
preserves newlines via pre-wrap) and SafeHtmlMessage (sanitised
HTML via DOMPurify + html-react-parser).
- NotifierMessage and ModalProvider.AlertContent accept a `plainText`
prop. When true the body renders via SafeMessage.
- Notifier gains errorText / alertText / warningText / infoText /
successText methods that plumb {plainText: true} through. These
are the correct choice whenever the message may originate from a
PostgreSQL server, driver, OS process, remote API, or any other
untrusted channel.
- pgRespErrorNotify and pgNotifier use alertText / errorText
internally. The redundant _.escape and \\n -> <br/> substitution
in pgNotifier are gone — the SafeMessage container's pre-wrap
preserves newlines without further transformation.
- FormInput.errorMessage and ToolErrorView render as plain text
unconditionally; their inputs are always validation / driver
errors.
- ~50 callers across browser, tools, dashboard, debugger, misc, llm,
preferences, schema diff, and the SQL editor are migrated to the
*Text variants where they pass a backend-derived field
(err.response.data.errormsg, parseApiError(error), res.errormsg,
res.info, res.data.result, err.message, interpolated PG object
names).
- Browser auto-update notification rewritten to plain text — the URL
is shown inline instead of being interpolated into an <a href>
attribute. The gettext template-literal anti-pattern on that path
is fixed too (URL is a separate %s argument).
Cleanup:
- helpers/Notifier.jsx — remove the dead AlertContent function and
its seven imports (Box, CloseIcon, DefaultButton, PrimaryButton,
HTMLReactParser, CheckRoundedIcon). The function was defined but
never instantiated; the real top-level alert path goes through
ModalProvider.AlertContent. Eliminates a false-positive sink that
recent audits cite — the cited line numbers reference a function
that never runs.
Explain — escape Recheck Cond / Exact Heap Blocks:
- nodeExplainTableData concatenates plan-node fields into HTML
strings parsed by html-react-parser. Every sibling field (Filter,
Index Cond, Hash Cond, Join Filter, Rows Removed by Filter, Hash
Buckets/Batches/Peak Memory) wraps the value in _.escape();
Recheck Cond and Exact Heap Blocks were the two outliers
concatenating raw. An attacker who introduces a table with a
crafted column name and triggers a Bitmap Heap Scan over it could
inject HTML into Explain output that a victim later views in the
visualiser. With the rest of the XSS fix this would otherwise be
neutralised by DOMPurify at the sink — escaping at construction
matches the sibling fields and provides defence in depth.
Backend — HTML-escape PG-returned text in post-connection-SQL:
- New web/pgadmin/utils/driver/psycopg3/text_sanitize.py exports
sanitize_driver_message. Strips C0 control characters
(0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, 0x7F) — preserving TAB, LF,
CR so multi-line error messages stay readable — then HTML-escapes
via html.escape(..., quote=True).
- execute_post_connection_sql logs the raw PG-returned text so the
server log stays human-readable, and applies the escape only to
the value crossing into the JSON response body, where downstream
consumers may render it as HTML:
<iframe srcdoc="..."> -> <iframe srcdoc="...">
Real Postgres errors (which never contain literal markup) round-
trip unchanged for human readability. Protects third-party JSON
consumers (audit logs, API clients, support tickets) that do not
have the frontend's DOMPurify.
Tests
- components/SafeMessage.spec.js — 25 payloads (iframe srcdoc, SVG
onload, MathML href javascript:, embed/object data:, marquee
onstart, template/noscript, CSS expression, etc.).
- Notifier.spec.js — 9 cases (errorText / alertText plumbing,
pgRespErrorNotify 410 vs non-410, pgNotifier raw errormsg pass-
through, "Unknown error" fallback).
- ToolErrorView.spec.js — 3 cases.
- ModalProvider.spec.js — 4 cases.
- FormComponents.spec.js extended with NotifierMessage plainText
mode + FormInput errorMessage plain-text rendering.
- Explain regression test renders Analysis with HTML-laden extraInfo
and asserts no <iframe> / <script> reaches the DOM.
- Python: test_sanitize_driver_message.py (21 sanitiser + 5
execute_post_connection_sql wiring scenarios with a real Flask +
flask-babel app context and a stand-in connection).
- test_post_connection_sql_xss.py — end-to-end integration: register
a server, set post_connection_sql to a query referencing a relation
whose name contains an iframe payload, POST to the connect endpoint,
and assert the response JSON errormsg is entity-encoded and never
raw markup.
- fake_pgadmin.js notifier mocks and enable_disable_triggers_spec.js
spies updated for the new API.
875 / 875 JS tests pass; ESLint clean.
When a table inherits a column from a parent, the generated table
SQL/EDIT script omitted the column's default. Emit a post-create
ALTER TABLE ... ALTER COLUMN ... SET DEFAULT for inherited columns
that carry a default, and show the default in the inherited-column
comment, across all version buckets.
Generated columns (colconstype 'g') are excluded from both the new
ALTER statement and the comment default, consistent with the inline
column-definition logic, since SET DEFAULT is invalid for a generated
column.
When a database connection was silently dropped while pgAdmin sat idle
(common on Linux Desktop behind a firewall/NAT), the Object Explorer and
Query Tool would hang on an infinite spinner instead of offering to
reconnect, because connected() only checks local driver state and misses
stale/half-open TCP sockets.
- ping() now performs a real network check (SELECT 1) guarded by the
connection's transaction_status, so an in-progress query or open
transaction is never disrupted; on failure it tears down the dead
connection and returns False.
- The Object Explorer children() endpoint uses ping() to detect a dead
connection up front and returns 503 CONNECTION_LOST; the tree shows a
reconnect dialog (deduplicated per server) and marks the node
disconnected.
- TCP keepalive defaults are applied to all connections so the OS
surfaces dead sockets in seconds rather than the full retransmission
timeout.
- The Query Tool re-checks connection status when its tab becomes
visible again, and its layout/visibility listeners are now cleaned up
on unmount.
Refs #9700, #8279. Adds a 9.16 release note.
The passfile kwarg passed to Connection.connect() was only ever used as
a gate for the passexec fallback; it was never forwarded to
create_connection_string(), which builds the DSN's passfile from the
ServerManager's connection parameters. This made the gate inconsistent
with the passfile actually used for the connection.
Use the ServerManager passfile for the credential gate so the check
matches what is used to connect. The manager passfile now takes
precedence over both passexec and any passfile kwarg; warnings are
emitted when either is ignored in its favor.
The entries for #9875 (EXPLAIN/blank-line query extraction) and #9988
(OAuth2 metadata URL guidance) were added to release_notes_9_9.rst,
which is an already-released version. Move them to the in-progress
9.16 notes where they belong.
The two init containers in the Helm deployment template had hardcoded
securityContext blocks, unlike the main container which already renders
its context from .Values.containerSecurityContext via the
renderSecurityContext helper. Switch the init containers to the same
pattern so operators can customise (or disable) their security context.
Default behaviour is unchanged: containerSecurityContext defaults to
enabled with values identical to the previous hardcoded block, and the
helper continues to gate appArmorProfile on
global.compatibility.appArmor.enabled. Verified with helm template that
the rendered init-container securityContext is unchanged for the default
values, honours the appArmor toggle, and is omitted entirely when
containerSecurityContext.enabled=false.
Adds a 9.16 release note.
getQueryAt now uses the syntax tree as the primary check for whether a
blank-line boundary cut through a SQL statement. It is split into two
helpers: _findQueryBoundaries (the original scan, parameterized by
stopAtBlankLine) and _needsExpansion, which detects when a Statement
node straddles the extracted range and re-scans ignoring blank lines.
A STATEMENT_STARTERS keyword list guards the case where the parser
merges semicolon-less queries into one Statement, and WRAPPER_STARTERS
(EXPLAIN, ANALYZE, WITH) force expansion when the Statement extends past
the range. Also fixes a Lezer boundary bug (tree.iterate is inclusive at
boundaries) with a node.to > startPos check. Adds 21 tests covering
EXPLAIN, negative no-merge cases, comments, and boundary edge cases.
Some OpenAI-compatible providers emit empty or null name/arguments/id
fields in streaming continuation deltas to keep the response schema
stable. pgAdmin's accumulator overwrote the real tool name (captured in
the first delta) with the later null, producing a tool call named "null"
that could not be dispatched.
Skip falsy name/arguments/id when accumulating (matching the OpenAI
Python SDK, which ignores nulls the same way) so the values captured in
the first delta survive. Also guard against a null `function` object in
a delta, which previously raised TypeError. Without the id guard a null
id in a continuation delta clobbered the real id, which the final build
then replaced with a random uuid rather than the provider's id.
Adds a unit test covering the null-continuation, multi-chunk-arguments,
and null-function cases, and a 9.16 release note.
When OAUTH2_SCOPE contains 'openid' but OAUTH2_SERVER_METADATA_URL is
not set, Authlib fails deep inside id_token verification with a cryptic
`Missing "jwks_uri" in metadata` error that names neither the config
knob nor the fix.
Add a pre-flight check at the entry of _authorize_access_token: if the
scope includes 'openid' and no (non-whitespace) metadata URL is set,
raise a RuntimeError with actionable guidance before any network
round-trip. server_metadata_url is the only way pgAdmin feeds JWKS to
Authlib, so this carries no regression risk for correctly-configured
providers. Clarify the OAUTH2_SERVER_METADATA_URL comment in config.py
and add regression coverage.
The container entrypoint substituted ${PGADMIN_CONFIG_CONFIG_DATABASE_URI}
into a double-quoted Python string for `python3 -c`. Combined with the
config_distro.py convention (where the env var's value must itself be a
Python literal, i.e. users set it to 'postgresql+psycopg://...'), the
entrypoint re-wrapped the already-quoted value, producing a string with
literal quotes inside that SQLAlchemy could not parse -- and the Python
crash made the first-launch check capture an empty string, silently
skipping PGADMIN_DEFAULT_EMAIL / PGADMIN_DEFAULT_PASSWORD setup.
Read the env var inside Python via os.environ so the shell no longer
participates in Python-literal quoting (also removing a shell-injection
surface), and use ast.literal_eval to unwrap the legacy quoted form while
letting raw values pass through. external_config_db_exists now stays
"False" on any Python failure so first-launch setup still runs.
Adds a 9.16 release note.
An old configuration database (e.g. a pre-9.0 pgadmin4.db lingering in
%appdata%) can carry a stale foreign key such as server.user_id ->
user_old, a table removed long ago. SQLAlchemy's MetaData.reflect()
defaults to resolve_fks=True, which auto-follows the reflected table's
foreign keys and reflects their targets too; the orphan target trips
NoSuchTableError and aborts the startup migration, surfacing in the GUI
as the misleading "Server could not be contacted".
None of the migrations use the FK-target tables -- each operates only on
the explicitly requested table(s). Pass resolve_fks=False at all 14
meta.reflect() call sites across the 12 migration files so reflection no
longer cascades into broken FK targets. Behaviour is unchanged for
healthy databases.
Adds a 9.16 release note.
This internal design proposal was inadvertently included with the
session and file-manager hardening changes and should not have been
added to the repository.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes the PostgreSQL 11+ TOAST_TUPLE_TARGET storage parameter in the
Materialized View properties dialog, mirroring the existing FILLFACTOR
support: an integer field (128-8160) in the Definition group, extracted
from reloptions in properties.sql, emitted in the CREATE ... WITH clause,
and SET/RESET on edit. The recreate-on-definition-change path preserves
the value.
The dialog field and SQL are applied to the version buckets that
supported servers actually resolve to. The original change only touched
the legacy default/ bucket (PG < 12), so it had no effect on any
supported server (13-18, which resolve to 13_plus/15_plus); the
create/properties/update templates are now updated in the 12_plus,
13_plus and 15_plus buckets too. Template output is byte-identical to
the previous SQL when toast_tuple_target is unset.
Adds a 9.16 release note.
Wire the relpersistence change through to the table update SQL across all
version buckets (default, 11_plus, 12_plus, 15_plus) so the Unlogged toggle
emits ALTER TABLE ... SET LOGGED/UNLOGGED on every supported server.
Closes#9677.
Schema Diff's "Generate Script" emitted CREATE TABLE DDL with
the libpq-expanded form of SERIAL columns:
col integer NOT NULL DEFAULT nextval('table_col_seq'::regclass)
instead of preserving the SERIAL declaration. Applied to a clean
target, that script fails with `relation "table_col_seq" does
not exist` because the sequence is never created — exactly the
symptom in issue #9896.
SERIAL detection already existed in
columns/utils.py:get_formatted_columns (added in 115208c8d for
ERD generation, Nov 2023), but was gated behind a
``with_serial_cols`` parameter that defaulted to False. Only ERD
and the Schema Diff *comparison* phase opted in via explicit
True. The Schema Diff *Generate Script* path and the browser
tree's *CREATE Script* view went through `_get_resql_for_table`
→ `_formatter(data)` without specifying it, so SERIAL detection
was skipped and #9896 reproduced.
There is no use case where pgAdmin should knowingly emit the
expanded libpq form: SERIAL is purely a CREATE-time macro that
PostgreSQL expands into an integer column plus an implicit
sequence owned by the column. The detection logic is reversing
that expansion, which is the correct interpretation in all
contexts (DDL emission, comparison, ERD, properties dialog).
Flip the default to True at all four declaration sites:
- columns/utils.py: get_formatted_columns
- utils.py: BaseTableView._formatter
- utils.py: BaseTableView.fetch_tables
- __init__.py: TableView.fetch_tables
All four existing explicit-True callers (ERD x2, Schema Diff
compare x2) are unaffected. Silent callers that previously got
the buggy False — `_get_resql_for_table` (Schema Diff Generate
Script and browser CREATE Script), `select_sql` / `insert_sql` /
`update_sql` in the table node, and one path in the partition
flow — now correctly get SERIAL detection. The SELECT/INSERT/
UPDATE generators are unaffected regardless: they emit only
column names, which SERIAL detection never touches (it rewrites
the column type and clears `defval`, leaving the name intact).
The table edit path is likewise safe: `get_sql` diffs each
changed column against a freshly re-fetched old column state
that bypasses SERIAL detection, so the reprojected `old_data`
columns are never consulted for column DDL.
Adds a resql regression in pg/{12_plus, 14_plus, 16_plus}: a
CREATE TABLE scenario covering ``serial`` / ``bigserial`` /
``smallserial`` columns plus a plain ``text`` column, with the
expected reverse-engineered DDL preserving the SERIAL keywords.
Would have failed before this fix.
Known coverage gaps (not blockers for this PR):
- PG11 (uses ``pg/11_plus`` / ``pg/default``): test intentionally
not added there because PG11 still emits ``WITH (OIDS = FALSE)``
which the existing expected-output convention reflects, and
without a live PG11 instance I can't verify the expected SQL
variant. The source-side fix still applies to PG11.
- PPAS (``ppas/*`` test dirs): not added because PPAS output may
diverge subtly (oraplus / hyphenated identifiers) and I don't
have a PPAS instance to verify against. The source-side fix
applies equally to PPAS.
- The SERIAL detection heuristic is name-based
(``<table>_<col>_seq``); sequence/table/column renames break
detection. Pre-existing in 115208c8d; could be replaced with
proper ``pg_depend``-based detection in a follow-up.
Fixes#9896
In Tools > Schema Diff, the top-level group rows ("Schema Objects",
"Database Objects") rendered blank difference counts even when their
child object-type groups (Functions, Tables, Sequences, ...) listed
Source-Only / Target-Only / Different objects, giving no signal at the
parent level that anything differed.
The results tree has three levels: top-level object-group rows, mid-level
object-type rows, and leaf object rows carrying a status. setRecordCount()
only handled leaf children, so mid-level rows counted correctly but
top-level rows (whose children are mid-level rows with no status) stayed
at zero. The render gate 'identicalCount' in row also skipped count
rendering for top-level rows because generateGridData() never seeded
those fields on them.
Fix: setRecordCount() now recognises counted-group children (recursing to
refresh their counts so filter changes propagate, then rolling the totals
up); leaf-only behaviour is unchanged. generateGridData() seeds the four
count fields on every top-level group row so the render gate fires. Adds
Jest coverage driving the now-exported setRecordCount directly, including
the reporter's exact tree and a filter-refresh case.
Fixes#9892.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the EDB BigAnimal cloud-deployment integration from the Cloud
Wizard. AWS RDS, Azure Database, and Google Cloud SQL remain as the
supported deployment targets. Covers the backend blueprint and pgacloud
provider, the frontend wizard steps/components/constants/icons, the
documentation page and screenshots, and stray BigAnimal references in
docstrings.
The BigAnimal-only "Cluster Type" wizard step is removed and the
remaining steps renumber from 0-5 to 0-4. As a side effect, the Azure
cluster-name availability check (previously dead code at activeStep == 2,
skipped past by the now-removed step) is now exercised. Two latent bugs
in that check are fixed at the same time: it inspected res.data.success
although checkClusternameAvailbility already resolves res.data, so a name
collision was never reported; and it passed an Error object to gettext(),
which cannot render it. It now uses res.success and surfaces
error.message with a readable fallback.
A 9.16 Housekeeping release-note entry is added documenting the removal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Create release_notes_9_16.rst with empty sections and add it to the
release notes toctree, so that individual fix/feature PRs can add their
entries without each recreating the file.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix exponential backoff logic and ping race condition in desktop startup
* Adaptive polling mechanism in startDesktopMode().
- Fix exponential backoff to cap at 1000ms instead of cycling back to 100ms
- Move pingInProgress flag before the async pingServer() call to prevent race conditions
* Update showErrorDialog to use clearTimeout consistent with setTimeout-based scheduling
psycopg.Connection.execute(query, params, *, prepare=None, binary=False)
delegates to its underlying cursor as
cur.execute(query, params, prepare=prepare). The DictCursor /
AsyncDictCursor in pgadmin.utils.driver.psycopg3.cursor narrowed the
signature to (self, query, params=None), so any caller that uses the
high-level Connection.execute() path with a cursor_factory=DictCursor
connection hits
TypeError: execute() got an unexpected keyword argument 'prepare'
The most visible victim is psycopg_pool.ConnectionPool.check_connection,
which sends conn.execute("") to validate every checkout — every
connection then looks broken to the pool and getconn() times out.
Forward prepare and binary (keyword-only) to the underlying
psycopg.Cursor / psycopg.AsyncCursor. Defaults match psycopg's own,
so existing callers see no behavior change.
Adds a regression test asserting both classes expose the kwargs as
keyword-only parameters. The test docstring leads with psycopg.Cursor
substitutability (DictCursor is a Cursor subclass; both kwargs must be
accepted to remain substitutable) and notes that Connection.execute is
just the most visible failure path — it forwards prepare but handles
binary by setting cur.format instead of forwarding it.
The previous commit (2e14bd95d) made the MFA test package discoverable
and resilient but left one scenario --
'Validation view of a MFA method should return a HTML tags' -- as a
documented unittest.SkipTest. The skip reason was that
validate.html -> security/render_page.html -> base.html references
'current_app' and 'csrf_token()' in Jinja, and the dummy Flask app
built by test_create_dummy_app() does not expose either: pgAdmin's
real create_app() injects current_app via an @app.context_processor
(web/pgadmin/__init__.py:922) and Flask-WTF registers csrf_token() in
the Jinja env. The bare Flask(name, ...) used by the dummy app has
neither, so the GET-path render fails with
UndefinedError: 'current_app' is undefined.
Add the same two globals to the dummy app:
* A @app.context_processor returning {'current_app': current_app} so
the Flask proxy is bound at render time, mirroring what
create_app() does in production.
* A jinja_env.globals['csrf_token'] = lambda: 'dummy-csrf-token' that
matches what Flask-WTF would otherwise install. Tests asserting on
the rendered HTML can rely on the marker being present and stable.
With both globals available the template renders, /mfa/validate
returns a 200 HTML response, and the scenario goes from SkipTest to
a real assertion. The fix is intentionally scoped to the dummy harness
in tests/utils.py -- no production code change.
Result on this worktree: pgadmin.authenticate.mfa.tests now runs as
13 passed / 0 failed / 0 skipped (was 12/0/1). Full suite goes from
1818/0/444 to 1819/0/443.
The MFA test directory was missing __init__.py, so
find_modules('pgadmin', False, True) in regression/runtests.py could
not walk into it. The test classes therefore never reached the
TestsGeneratorRegistry registry and the entire pgadmin.authenticate.mfa
test suite was silently absent from the default test run. The
regression for #10028 added in this branch was caught by that gap.
Adding the missing __init__.py exposes a second pre-existing problem:
TestMFATests.setUp called BaseTestGenerator.setUp, which posts to
/browser/server/connect/... and asserts a 200 response. The MFA
scenarios all run against a dummy Flask app (or pure mocks), so that
endpoint is not registered and the connect_server assertion fires
before any check_*() function runs. Skip the BaseTestGenerator setUp
since these scenarios deliberately do not need a real PostgreSQL
server.
Three further fixes that surface once the suite actually runs:
* mfa_enabled() and init_app() both short-circuit when SERVER_MODE
is False. test_config.json defaults to DESKTOP mode, so every
scenario in the suite was taking the disabled path. Force
SERVER_MODE=True for the duration of the TestMFATests class and
restore the previous value in tearDownClass.
* check_validation_view_content patched flask.current_app to capture
logger.exception() calls. Those only fire on the POST path of
/mfa/validate; the test exercises only the GET path, so the patch
was dead code -- and additionally turned flask.current_app into a
MagicMock, which broke Jinja's lookup of current_app in
validate.html. Drop the patch and the now-unused ValidationException
import.
* check_validation_view_content still cannot render validate.html
against the bare dummy Flask app because the template references
current_app.config and extends Flask-Security's
security/render_page.html, neither of which the dummy app provides.
Skip the scenario with a clear unittest.SkipTest reason rather than
paper over with a brittle patch; rebuilding the dummy harness to
expose those globals is its own follow-up.
Result on this worktree: pgadmin.authenticate.mfa.tests now runs as
12 passed / 0 failed / 1 skipped (the dummy-app/template gap),
up from a silent 0/0/0. Full suite goes from 1806/0/443 to 1818/0/444.
The MFA flow honoured the user-supplied "next" query/form parameter
without checking that it pointed back inside pgAdmin, so an attacker
who got a logged-in user to click /mfa/validate?next=<external> could
land them on an attacker-controlled host straight out of the auth
flow -- a trusted-domain redirect ideal for credential-phishing
follow-on.
Add a single _is_safe_redirect_url helper that allows only same-origin
http(s) targets (relative paths included) and rejects:
* external hosts in absolute and protocol-relative ("//host") form;
* non-http schemes a browser will still follow (javascript:, data:,
mailto:);
* userinfo tricks ("http://localhost@attacker.example/");
* backslash variants ("/\\host", "\\\\host") that some browsers
normalize to forward slashes, enabling protocol-relative bypasses;
* empty / missing targets.
Gate every redirect that consumes the user-supplied next value through
the helper -- the GET and POST branches of /mfa/validate and the POST
branch of /mfa/register -- falling back to the internal browser index
when the target is anything other than safe. The registration POST
keeps its existing 'internal' sentinel ("rendered from the in-app
dialog") since that string is matched exactly and is not a URL.
Ship the validator with a dedicated table-driven unit test covering
each accept/reject category and an integration test that POCs the
attacker payload from the report and asserts the response redirects
to the internal index instead of attacker.example.
Reported by: Mai Phạm Hiền <mai.phamhien171@gmail.com>
Reviewed by: Kundan Sable <kundan.sable@enterprisedb.com>
The SQL injection fix for create_restore_point now calls
pg_catalog.pg_create_restore_point so that a non-default search_path
on the connection cannot redirect the call to a shadow definition.
Apply the same hardening to the other pg_* calls in the same file
that were still unqualified: pg_reload_conf in reload_configuration,
and pg_xlog_replay_pause / pg_wal_replay_pause / pg_xlog_replay_resume
/ pg_wal_replay_resume in wal_replay.
These were not exploitable on their own -- the SQL string is static
and not user-derived -- but resolving them via pg_catalog removes any
dependency on the connection's search_path being trustworthy.
ServerNode.create_restore_point interpolated the user-supplied "value"
field directly into a SQL string with str.format(), so an authenticated
pgAdmin user could inject additional statements through that endpoint.
The injection ran with the user's own database role -- so it did not
cross a privilege boundary and granted no capability the role does not
already have via the Query Tool -- but it bypassed the documented SQL-
execution path and any application-layer controls a deployment may
have built around the Query Tool.
Pass the name as a bound parameter, and qualify the function with
pg_catalog so a non-default search_path on the connection cannot
redirect the call to a shadow definition.
Add a regression test that mocks the driver and asserts the call
arrives as ("SELECT pg_catalog.pg_create_restore_point(%s);", (name,))
with no payload spliced into the SQL text, plus defence-in-depth
assertions that the SQL string contains neither pg_sleep nor the PoC's
closing-quote signature, so a future refactor that reintroduces string
formatting is caught.
Reported by: Geo <cve@sageby.com>
Reviewed by: Kundan Sable <kundan.sable@enterprisedb.com>
The AI Assistant's execute_sql_query tool runs LLM-generated SQL inside
a BEGIN TRANSACTION READ ONLY wrapper. However, the LLM-supplied query
was sent to psycopg as-is, so a multi-statement payload beginning with
COMMIT, END, ROLLBACK, or ABORT terminated the read-only transaction
and ran subsequent statements in autocommit mode. With ordinary write
privileges this allowed unauthorised data modification; for a superuser
or pg_execute_server_program role this chained to remote code execution
on the database host via COPY ... TO PROGRAM.
Validate the LLM-supplied query before any connection work happens:
* The input must parse to exactly one non-empty/non-comment statement.
* The leading real token (after stripping leading whitespace, comments,
and punctuation) must be one of SELECT, WITH, EXPLAIN, SHOW, VALUES,
TABLE. Everything else -- DML, DDL, CALL, COPY, DO, SET/RESET, the
transaction-control verbs, and the rest -- is rejected up front.
PostgreSQL's READ ONLY mode continues to enforce the remaining cases
(data-modifying CTEs, EXPLAIN ANALYZE on writes, volatile side effects)
at runtime, so the validator is the load-bearing check for multi-
statement / top-level escapes and READ ONLY is the backstop for the
rest.
Add a 60-scenario regression suite under
web/pgadmin/llm/tests/test_database_tool_security.py covering the
original PoC payloads (COMMIT/END/ROLLBACK/ABORT/SET/BEGIN), multi-
statement masked by comments, the full allow- and deny-list of leading
keywords, dollar-quoted literals containing semicolons, and degenerate
inputs (empty, whitespace-only, comment-only, quoted identifier).
Reported by: Isaac Chen <isaac9503@gmail.com>
Reviewed by: Kundan Sable <kundan.sable@enterprisedb.com>
Combined fix for 8 packages flagged by GitHub Dependabot (collapsing
6 of them from open dependabot bump PRs and 4 from transitive
vulnerabilities with no existing PR). All eight are transitive — no
direct dep changes — so we override via `resolutions` in web/package.json
and let yarn collapse duplicate-version entries during install.
Resolved (pre → post via resolution):
Runtime:
ws 8.20.0 -> 8.21.0 (patched 8.20.1)
Dev:
@xmldom/xmldom 0.7.13 -> 0.8.13 (patched 0.8.13)
serialize-javascript 6.0.2,
7.0.5 -> 7.0.5 (patched 7.0.5)
ip-address 10.1.0,
10.2.0 -> 10.2.0 (patched 10.1.1)
postcss 8.5.8,
8.5.15 -> 8.5.15 (patched 8.5.10)
qs 6.15.0 -> 6.15.2 (patched 6.15.2)
@tootallnate/once 2.0.0 -> 2.0.1 (patched 2.0.1)
tar (7.x lineage) 7.5.13 -> 7.5.16 (patched 7.5.11)
The tar 6.2.1 lineage (consumed via ^6.1.2/^6.1.11) is unaffected by
these CVEs (alert ranges are 7.x-only), so the resolution is scoped
`tar@npm:^7.5.4` to leave it on 6.2.1.
Supersedes open dependabot PRs #9956 (ws), #9962 (tar), #9966
(@tootallnate/once), and #9974 (qs) — one CI cycle instead of four.
Verification:
- yarn install — clean (only pre-existing peer-dep warnings about
@mui/system, aspen-core, eve, etc.; no new ones)
- yarn run test:js-once — 824 / 824 pass across 140 test suites
- yarn run bundle:dev — webpack compiled successfully
- All 8 packages confirmed at safe versions via lockfile audit;
duplicate entries collapsed (yarn.lock net -64 lines)
Out of scope (cannot fix here):
- paramiko (#276#278): no patched version exists; bump-to-5.0.0
PRs #9927/#9930 audited 2026-05-20 and deferred to Q4 2026 over
SSH bastion compat risk
- elliptic (#176): no patched version, dev-only, low severity
- flatted (#224): alert is stale; lockfile already at 3.4.2 (patched);
will auto-dismiss on next dependabot rescan
* chore(deps): bump Python deps to latest 3.9-compatible
Picks up five Python dependency bumps that are 3.9-safe (still resolve
under Python 3.9 per PyPI requires_python). Four supersede open
dependabot PRs:
- certifi 2026.4.22 -> 2026.5.20
(no gate; CA bundle refresh; supersedes dependabot #9977 / #9979)
- typer 0.25.* -> 0.26.*
(py > 3.9 row only; supersedes dependabot #9995 / #9999)
- testscenarios 0.6.1 -> 0.6.2
(py > 3.9 row only; supersedes dependabot #9980)
- urllib3 2.6.* -> 2.7.* (py > 3.9 row only)
Picks up two HIGH-severity security fixes in urllib3 2.7.0
(2026-05-07): GHSA-mf9v-mfxr-j63j (decompression-bomb safeguards
bypassed under drain_conn / Brotli stream patterns) and
GHSA-qccp-gfcp-xxvc (ProxyManager.connection_from_url did not
strip sensitive headers on cross-host redirects). 2.7.0 requires
Python >=3.10, which the existing 'python_version > 3.9' gate
already enforces.
- Flask-Security-Too 5.4.* -> 5.6.* (py <= 3.9 row only)
Closes a roughly 2-year gap between the 3.9 row (last pin from
March 2024) and the py > 3.9 row (already on 5.8.*). 5.5/5.6
only touched flows pgAdmin doesn't use (register V2, MFA / WebAuthn
templates, username recovery/changing, secret_key rotation) and
config pgAdmin overrides (default hash bcrypt->argon2 sidestepped
by SECURITY_PASSWORD_HASH = 'pbkdf2_sha512'). The contract changes
that mattered (LoginForm.validate -> is_active, UserMixin.is_locked
hook, single-kwarg find_user) are all already exercised in
production via the existing FST 5.8.* / Python 3.10+ deployments.
The existing run-python-tests-pg.yml workflow hardcodes
SERVER_MODE = False in config_local.py. Every test that gates
itself on `config.SERVER_MODE` — including the data-isolation
suites — skips itself in CI today. That gap is what allowed the
admin-bypass regression in 9a76ed8 to ship (see #9933, #10006):
the change to web/pgadmin/utils/server_access.py changed
access-control behaviour but the only tests covering it were
server-mode-only and therefore never ran.
This workflow plugs that gap with a narrow, cheap server-mode
CI job:
- Single OS (ubuntu-22.04), single PG version (18) — no matrix
- SERVER_MODE = True in config_local.py
- Runs only the two data-isolation test modules:
browser.server_groups.tests.test_sg_data_isolation
browser.server_groups.servers.tests.test_server_data_isolation
Locally with SERVER_MODE=True both modules finish in well under
half a second (3 + 6 tests), so the marginal CI cost is dominated
by the PG/python setup, not the tests themselves.
Future access-control changes to server_access.py (or related
helpers) will fail this workflow if they regress the existing
isolation guarantees, before they reach master
Two related bits of long-stale plumbing in web/regression/:
* test_advanced_config.json.in — the README has told users for years to
copy this template to test_advance_config.json (no "d") and customise
it, but a repo-wide grep for either spelling shows zero code, test,
or CI references. The .in template is dead, and the README/.gitignore
also disagree about whether the copied filename has a "d" in it,
which is a typo trap that has gone unnoticed precisely because nobody
actually performs the copy step.
* test_greenplum_config.json — Greenplum support was removed from
pgAdmin years ago (only references left in the tree are historical
release-notes entries from versions 1.4 through 4.12). Nothing reads
the config any more.
Drop the dead template, prune both stale lines from
web/regression/.gitignore, and simplify the README to describe just
the server-side test_config.json that the framework actually consumes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps the desktop runtime to electron 42 (dependabot PR #9945) and
closes a supply-chain gap in the Linux/Mac packaging scripts that
predated this bump.
Why the bump is safe:
- macOS UNNotification API change — pgAdmin's runtime does not use
Electron's Notification API (only a UI toast comment in
src/js/pgadmin.js:211; no `new Notification(...)` anywhere).
- postinstall no longer downloads electron — production packaging
fetches the binary directly via wget from GitHub releases, never
via electron's postinstall script.
- Offscreen rendering scale-factor change — no OSR usage anywhere
in runtime/src/js/.
While verifying, found that pkg/linux/build-functions.sh and
pkg/mac/build-functions.sh resolve the packaged electron version
via:
ELECTRON_VERSION="$(npm info electron version)"
This pulls whatever currently carries the `latest` dist-tag on the
npm registry. Any newly published electron release — including a
hypothetical malicious one — would land in shipped binaries without
review, regardless of what runtime/package.json pins.
Replace with sed-based extraction from runtime/package.json and
fail loudly if extraction returns empty. The Windows installer
(pkg/win32/installer.iss.in) does not have this issue (it bundles a
pre-built tree, no electron download step).
Net change in runtime/yarn.lock is mostly deletions — electron 42
ships with @electron/get 5.x, which dropped a large transitive
dependency tree associated with the old postinstall download path.
Verified:
- eslint (runtime): clean (silent)
- yarn install (runtime): resolved to electron 42.2.0 within
^42.1.0 range
- sed extraction smoke-tested: returns 42.1.0 from current
runtime/package.json
In server mode, administrators were auto-granted visibility into every
user's private server groups and servers via four _is_admin() bypasses
in server_access.py. This made the Object Explorer show a separate
top-level "Servers" entry per user when logged in as admin, exposing
private connections the admin should have no access to.
The Administrator role in pgAdmin governs management of pgAdmin itself
(users, preferences) — it is not intended to inherit other users'
database credentials and connection state. Cross-user visibility
requires explicit sharing (Server.shared=True), same as for any user.
Remove the admin bypass from get_server, get_server_group,
get_server_groups_for_user, and get_user_server_query. Drop the now-
unused _is_admin() helper. Update docstrings to make the policy
explicit.
Add a regression test (admin attempts to fetch a non-admin user's
private server group → expect HTTP 410). The original isolation test
only covered non-admin → admin, which is why the regression
introduced by 9a76ed8 was not caught.
react-checkbox-tree v2 marks the package as "type": "module" but its
"require" exports condition still points at a UMD bundle. babel-loader
turns our ESM imports into require() calls, so webpack picks the UMD
file and treats it as ESM (because of "type": "module"). The UMD
wrapper's module.exports = factory(...) never runs in that context, and
the default export ends up undefined - causing CheckBoxTree to render
"Element type is invalid" in dialogs like Import/Export Servers.
Alias react-checkbox-tree to its ESM bundle (lib/index.esm.js, exposed
by the package's own "./*": "./*" exports map) so webpack picks the file
that actually has a default export.
Closes#9972
The container previously applied CAP_NET_BIND_SERVICE to the python
interpreter so the non-root pgadmin user could bind to ports 80/443.
Some platforms refuse to honor file capabilities:
- --cap-drop=ALL / OpenShift restricted-v2 SCC zero the bounding set,
so the kernel returns EPERM on exec of any capability-tagged binary.
This makes the image fail to start (issue #9657).
- --security-opt=no-new-privileges / allowPrivilegeEscalation: false
causes the kernel to silently strip file capabilities on exec, so
the binary runs but a subsequent bind() to <1024 still fails.
Split the interpreter so neither default behavior nor restricted-runtime
support has to give up the other:
- Dockerfile copies python3.X to /usr/local/bin/python3-cap and applies
setcap to the copy. /usr/local/bin/python3.X stays un-capped, so
/venv/bin/python3 (which symlinks to it) execs cleanly under
restricted SCCs. A parallel /venv/bin/python3-cap symlink keeps the
venv activation working when the capped interpreter is used.
- entrypoint.sh reads /proc/self/status at startup. If NoNewPrivs is
set, or CAP_NET_BIND_SERVICE is missing from the bounding set,
gunicorn is invoked through the un-capped python and (when
PGADMIN_LISTEN_PORT is unset) the default port falls back to 8080
for plain HTTP or 8443 for TLS. A startup message records the
choice.
- Existing deployments with the default 80/443 mapping are unaffected:
on every unrestricted runtime the bounding set still contains
NET_BIND_SERVICE and gunicorn runs through the capped interpreter
exactly as before.
- PGADMIN_LISTEN_PORT, if set, is honored in both paths.
Docs gain a "Restricted Security Contexts" subsection covering the new
auto-detected fallback and the OpenShift / --cap-drop=ALL invocation.
Fixes#9657
The 6 GB ceiling set by #9967 was too aggressive for the macOS x64
VM's total RAM. Build #1295 on `pgabf-macos-x64` failed in
`_build_runtime` at `unzip electron-vX.X.X-darwin-x64.zip` with exit
code 2 — never even reached webpack. That points at OS-level memory
pressure spilling out of the Node process and starving the rest of
the build: at 6 GB reserved, the box runs out of RAM long before
Terser actually needs the full ceiling.
Drop back to 4 GB, which still gives Terser a full extra gigabyte
beyond the original 3 GB setting that OOM-killed webpack in #1294,
but leaves enough headroom for the other steps in the appbundle
build to coexist.
Only the macOS appbundle path changes (see pkg/mac/build-functions.sh);
linux/pip/Makefile and dev-machine builds keep the 3 GB the `bundle`
npm script ships with.
macOS x64 appbundle builds keep dying inside webpack's TerserPlugin at
92% (asset processing). Build #1294 on `pgabf-macos-x64` reached
`<s> [webpack.Progress] 92% [0] sealing asset processing TerserPlugin`
and was killed without producing a V8 fatal-error preamble, which
points at the OS reaping the Node process under memory pressure rather
than V8 hitting its own heap ceiling.
TerserPlugin is already running single-threaded (see
web/webpack.config.js, `parallel: false`), so we can't claw memory back
by reducing parallelism. Bump the V8 old-space ceiling from 3072 MB to
6144 MB inside the macOS appbundle build only — the helper in
pkg/mac/build-functions.sh bypasses `yarn run bundle` and calls
`yarn run webpacker` directly (see commit d96e8634), so this knob is
independent of the npm script and does not affect linux/pip/Makefile
or dev-machine builds. They keep the 3 GB the `bundle` script has been
shipping with for years.
If this still doesn't get the x64 box past Terser we'll switch the
minimiser to esbuild via terser-webpack-plugin's `minify` option; that
is a larger and more invasive change so we are trying the cheap fix
first.
The macOS x64 appbundle build can fail inside `yarn run bundle` while
producing zero console output -- the Jenkins log goes straight from
"yarn install ... Done with warnings" to the EXIT trap's failure
message, leaving no signal as to whether linter, webpack, or a native
module load was the culprit (build #1293 on pgabf-macos-x64 is the
prompting example).
Split the bundled script into its constituent steps and merge stderr
into stdout so any error text reaches the console even if Jenkins'
shell step drops a tail buffer:
yarn install
yarn run git:hash # cheap source-hash capture, moved up front
yarn run linter
yarn run webpacker
`git:hash` is a pure `git log` redirect (see web/package.json) with no
node-module dependency, but `yarn run` needs node_modules so it stays
after install. Pulling it before the heavy steps means the commit_hash
file lands on disk even if webpack later bails out.
Env vars NODE_ENV=production and NODE_OPTIONS=--max-old-space-size=3072
are set explicitly to mirror the cross-env wrapper inside the top-level
"bundle" npm script, so build output stays byte-identical to before.
No-op for successful builds; pure diagnostics win on failure.
The shared polling helpers in:
- web/pgadmin/tools/backup/tests/test_backup_utils.py
- web/pgadmin/tools/import_export/tests/test_import_export_utils.py
- web/pgadmin/tools/maintenance/tests/test_create_maintenance_job.py
- web/pgadmin/tools/restore/tests/test_create_restore_job.py
all share the same race that surfaced on macos-latest / pg16 in
PR #9955's CI run:
- Wait budget was 2.5s (5 iterations x 0.5s; maintenance used 5s).
- The break condition was `execution_time' in the_process`, but
`execution_time` is the elapsed time of a *running* bgprocess --
it is set before the wrapped pg_dump / pg_restore / psql / COPY
actually finishes. The completion signal is `exit_code` becoming
non-None.
- So the helper could return control while the wrapped command was
still running, and the next assertion -- e.g.
`assert_equal(the_process['exit_code'] in [0, 1], True)` -- would
fire on `None in [0, 1]`, i.e. `False != True`.
Some scenarios masked the bug by listing `None` in their
`expected_exit_code` set (a tell that someone noticed the polling was
unreliable and worked around it by widening accepted exit codes).
Scenarios that didn't include `None` were the ones that flaked.
Fix all four helpers identically:
- Poll for up to 60 iterations x 0.5s = 30s, generous enough for
the slowest CI runner.
- Break only when `the_process.get('exit_code') is not None`, the
actual completion signal.
- Narrow `except Exception` to `except StopIteration`, which is the
only thing `next(...)` here can raise.
No call-site changes needed; the helper contract (returns once the
job is done; raises if the bgprocess never finished) is unchanged in
spirit and strictly more reliable in practice.
Verified:
- pycodestyle on the four files: 0 violations.
This fixes the failure observed in the macos-latest / pg16 leg of
PR #9955's CI run (run 26154521710, job 76930277702), which was
unrelated to that PR's lockfile-only changes.
Supersedes dependabot #9926 (and its /web/regression duplicate
#9932). Inherited via `-r ../../requirements.txt`, so the single
edit covers both.
cryptography 48 is a smaller bump than its major-version label
suggests:
- Removed Python 3.8 support. pgAdmin requires Python 3.9+ across
the supported platforms, so this is a no-op for us. (3.9.0 and
3.9.1 specifically are excluded by the new metadata; nothing
in pgAdmin's CI / packaging runs those exact patch versions.)
- Stricter X.509 CRL parsing: a CRL whose inner
`TBSCertList.signature` does not match the outer
`signatureAlgorithm` now raises `ValueError` instead of
being parsed and rejected later during signature verification.
- Added ML-KEM and ML-DSA post-quantum primitives (additive).
pgAdmin's cryptography surface area is narrow and CRL-free:
- web/pgadmin/settings/__init__.py Fernet
- web/pgadmin/utils/session.py Fernet, hashes, HKDF
- web/pgadmin/utils/crypto.py Cipher, AES, CFB8
No imports of `cryptography.x509`, `CertificateRevocationList`,
or `load_pem_x509_crl` anywhere in the tree, so the stricter CRL
parsing in 48 cannot affect pgAdmin.
The OpenSSL 1.1.x / LibreSSL < 4.1 removal that I initially
flagged as a concern actually happened in cryptography 47, which
master is already on. No platform-support regression from this
bump.
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
2340 changed files with 147357 additions and 107088 deletions
echo "ones that were reviewed when the lock was written."
echo "Usually that means pgbuild rebuilt the tag in place,"
echo "which it does for PostgreSQL whenever a new minor comes"
echo "out, that tag carrying only the major. Satisfy yourself"
echo "that a rebuild is what happened before re-running,"
echo "because this is also the one check standing between a"
echo "tampered dependency and a signed pgAdmin build, and then"
echo "record the new archive, read the diff and commit it:"
echo
echo " tools/refresh_pgbuild_lock.py ${PKG%%-*}"
exit 1
fi
echo "Verified ${ARCHIVE} (sha256 ${ACTUAL})"
# The first tag of the right shape is the newest, the listing
# coming back in reverse chronological order. The rolling tag is
# skipped because it names no version.
NEWEST=""
while read -r CANDIDATE; do
case "${CANDIDATE}" in
*-latest)
continue
;;
${PKG}*-macos-${PGBUILD_ARCH})
NEWEST="${CANDIDATE}"
break
;;
esac
done < "${TAGS}"
if [ -n "${NEWEST}" ] && [ "${NEWEST}" != "${TAG}" ]; then
echo "::warning title=pgbuild dependency out of date::${KEY} is pinned to ${TAG}, but pgbuild has since published ${NEWEST}. This build is using the pinned version, which is what the lock file is for, and nothing is broken. To move to the newer build, run tools/refresh_pgbuild_lock.py ${PKG%%-*}, review the change to pkg/pgbuild-deps.lock and commit it."
fi
tar -xzf "${RUNNER_TEMP}/${ARCHIVE}" -C /opt/pgbuild
done
ls -l /opt/pgbuild
echo "prefix=/opt/pgbuild" >> "${GITHUB_OUTPUT}"
- name:Install the pgbuild dependencies (Windows)
id:install-windows
if:runner.os == 'Windows'
shell:powershell
env:
PGBUILD_ARCH:${{ inputs.arch }}
POSTGRES_MAJOR:${{ inputs.postgres-major }}
# See the macOS step: this is for the out-of-date check's rate limit
# alone, and the downloads themselves need no authentication.
GH_TOKEN:${{ github.token }}
run:|
# Without this, Invoke-WebRequest spends most of a 90 MB download
# rendering a progress bar into a log nobody will read.
Write-Output "::warning title=pgbuild dependency out of date::$key is pinned to $($pin.Tag), but pgbuild has since published $newest. This build is using the pinned version, which is what the lock file is for, and nothing is broken. To move to the newer build, run tools/refresh_pgbuild_lock.py $family, review the change to pkg/pgbuild-deps.lock and commit it."
Thank you for your interest in contributing to pgAdmin 4.
## Contents
- Getting Started
- Reporting Issues
- Making Changes
- Pull Requests
- AI-assisted Contributions
- Development Setup
- Database Migrations
- Security Issues
- Community and Support
- License
## Getting Started
Before starting work, please review the project documentation and familiarize yourself with the architecture and development workflow.
### Architecture
pgAdmin 4 is written as a web application with Python (Flask) on the server side and ReactJS, HTML5 with CSS for the client side processing and UI.
Although developed using web technologies, pgAdmin 4 can be deployed either on a web server using a browser, or standalone on a workstation. The [runtime/](runtime/) subdirectory contains an Electron-based runtime application intended to allow this, which will fork a Python server process and display the UI.
For a fuller introduction to the codebase, see the [code overview](docs/en_US/code_overview.rst).
If you are new to the codebase, consider starting with smaller bug fixes or documentation improvements before working on larger changes.
The Sphinx documentation also includes contributor guidance for [coding standards](docs/en_US/coding_standards.rst), [submitting pull requests](docs/en_US/submitting_pull_requests.rst), [code review](docs/en_US/code_review.rst), and [translations](docs/en_US/translations.rst).
## Reporting Issues
When reporting a bug:
- Search existing issues first.
- If a similar issue exists, add any additional information or context you have. Do not create a duplicate issue.
- Provide clear steps to reproduce the issue.
- Include relevant environment and version information.
- Attach screenshots, logs, or error messages when appropriate.
For feature requests, describe the problem being solved and the expected behavior.
## Making Changes
When working on a contribution:
- Keep changes focused and easy to review.
- Follow existing coding patterns and conventions, including the [coding standards](docs/en_US/coding_standards.rst).
- Avoid unrelated refactoring and changes in the same commit.
- Keep commit messages clear and descriptive.
- Update tests or documentation when necessary.
## Pull Requests
Before opening a pull request:
- Ensure your changes build successfully.
- Test your changes locally.
- Review your code before submission.
- Update your branch with the latest changes from `master` upstream when appropriate.
When opening a pull request:
- Clearly describe the change.
- Explain the reason for the change.
- Include screenshots or recordings for UI-related changes when applicable.
- Submit any changes as Pull Requests against the `master` branch of the [pgadmin-org/pgadmin4](https://github.com/pgadmin-org/pgadmin4) repository.
Use a clear pull request title that describes the change. Short prefixes such as `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, and `chore:` are useful when they match the change being made. Branch names are contributor-local and do not need to follow a project-specific naming convention.
For more detail, see the [pull request submission guide](docs/en_US/submitting_pull_requests.rst) and [code review guide](docs/en_US/code_review.rst).
## AI-assisted Contributions
Contributors are responsible for understanding, testing, and standing behind everything they submit, whether or not AI tools were used. Please do not open large numbers of auto-generated issues or pull requests without first discussing that work with the maintainers.
## Development Setup
In the following documentation and examples, `$PGADMIN4_SRC` is used to denote the top-level directory of your copy of the pgAdmin source tree (either from a tarball or a git checkout).
### Prerequisites
1. Install Node.js 20 and above (https://nodejs.org/en/download)
3. Python 3.9 and above (https://www.python.org/downloads/)
4. PostgreSQL server (https://www.postgresql.org/download)
Start by enabling Corepack, if it isn't already; this will add the yarn binary to your PATH:
```bash
corepack enable
```
### Building Web Assets
pgAdmin is dependent on a number of third-party JavaScript libraries. These, along with its own JavaScript code, CSS code and images must be compiled into a "bundle" which is transferred to the browser for execution and rendering. This is far more efficient than simply requesting each asset as it is needed by the client.
To create the bundle, you will need the 'yarn' package management tool to be installed. Then, you can run the following commands on a *nix system to download the required packages and build the bundle:
```bash
$ cd$PGADMIN4_SRC
$ make install-node
$ make bundle
```
On Windows systems (where "make" is not available), the following commands can be used:
```bat
C:\> cd $PGADMIN4_SRC\web
C:\$PGADMIN4_SRC\web> yarn install
C:\$PGADMIN4_SRC\web> yarn run bundle
```
### Python Environment
In order to run the Python code, a suitable runtime environment is required. Python version 3.9 and later are currently supported. It is recommended that a Python virtual environment is set up for this purpose, rather than using the system Python environment. On Linux and Mac systems, the process is fairly simple - adapt as required for your distribution:
1. Create a virtual environment in an appropriate directory. The last argument is the name of the environment; that can be changed as desired:
```bash
$ python3 -m venv venv
```
2. Now activate the virtual environment:
```bash
$ source venv/bin/activate
```
On Windows:
```bash
venv\Scripts\activate
```
3. Some of the components used by pgAdmin require a very recent version of *pip*, so update that to the latest:
```bash
(venv) $ pip install --upgrade pip
```
4. Ensure that a PostgreSQL installation's bin/ directory is in the path (so pg_config can be found for building psycopg3), and install the required packages:
Create a local configuration file for pgAdmin. Edit `$PGADMIN4_SRC/web/config_local.py` and add any desired configuration options (use the `config.py` file as a reference - any settings duplicated in `config_local.py` will override those in `config.py`). A typical development configuration may look like:
```python
import os
import logging
# Change pgAdmin data directory
DATA_DIR = '/Users/myuser/.pgadmin_dev'
# Change pgAdmin server and port
DEFAULT_SERVER = '127.0.0.1'
DEFAULT_SERVER_PORT = 5051
# Switch between server and desktop mode
SERVER_MODE = True
# Change pgAdmin config DB path in case an external DB is used.
This configuration allows easy switching between server and desktop modes for testing.
### Running pgAdmin
The initial setup of the configuration database is interactive in server mode, and non-interactive in desktop mode. You can run it either by running:
```bash
(venv) $ python3 $PGADMIN4_SRC/web/setup.py
```
or by starting pgAdmin 4:
```bash
(venv) $ python3 $PGADMIN4_SRC/web/pgAdmin4.py
```
Whilst it is possible to automatically run setup in desktop mode by running the runtime, that will not work in server mode as the runtime doesn't allow command line interaction with the setup program.
At this point you will be able to run pgAdmin 4 from the command line in either server or desktop mode, and access it from a web browser using the URL shown in the terminal once pgAdmin has started up.
Setup of an environment on Windows is somewhat more complicated unfortunately, please see `pkg/win32/README.md` for complete details.
### Building the documentation
In order to build the docs, an additional Python package is required in the virtual environment. This can be installed with the pip package manager:
```bash
$ source venv/bin/activate
(venv) $ pip install Sphinx
(venv) $ pip install sphinxcontrib-youtube
```
The docs can then be built using the Makefile in `$PGADMIN4_SRC`, e.g.
```bash
(venv) $ make docs
```
The output can be found in `$PGADMIN4_SRC/docs/en_US/_build/html/index.html`
### Runtime Development
For Electron runtime development:
1. Navigate to the `runtime` directory.
2. Install dependencies:
```bash
yarn install
```
3. Copy:
```text
dev_config.json.in
```
to:
```text
dev_config.json
```
4. Update the paths to your Python executable and `pgAdmin4.py`.
Start the runtime:
```bash
yarn run start
```
### Building packages
Most packages can be built using the Makefile in `$PGADMIN4_SRC`, provided all the setup and configuration above has been completed.
To build a source tarball:
```bash
(venv) $ make src
```
To build a PIP Wheel, activate either a Python 3 virtual environment, configured with all the required packages, and then run:
```bash
(venv) $ make pip
```
To build the macOS AppBundle, please see `pkg/mac/README.md`.
To build the Windows installer, please see `pkg/win32/README.md`.
## Database Migrations
In order to make changes to the SQLite DB, navigate to the 'web' directory:
```bash
(venv) $ cd $PGADMIN4_SRC/web
```
Create a migration file with the following command:
```bash
(venv) $ FLASK_APP=pgAdmin4.py flask db revision
```
This will create a file in `$PGADMIN4_SRC/web/migrations/versions/`. Add any changes to the `upgrade` function.
When making schema changes, update the `SCHEMA_VERSION` value in:
```text
web/pgadmin/model/__init__.py
```
There is no need to increment the `SETTINGS_SCHEMA_VERSION`.
## Security Issues
**Do not report security vulnerabilities through GitHub issues.**
Potential security issues should be reported to:
`security@pgadmin.org`.
Note that this **address should only be used for reporting security issues** that you believe you've found in the design or code of pgAdmin, pgAgent, and the pgAdmin website. It **should not be used to ask security questions**.
If you are unsure whether an issue is a security vulnerability, please lean on the side of caution and report it to the above address. The pgAdmin team will review the report and respond as appropriate.
## Community and Support
For development discussions, use:
`pgadmin-hackers@postgresql.org`
For support options, visit the [pgAdmin support page](https://www.pgadmin.org/support/).
Use the [GitHub issue tracker](https://github.com/pgadmin-org/pgadmin4/issues) for reporting bugs and requesting features, but please follow the guidelines outlined in the "Reporting Issues" section above.
## License
By contributing to pgAdmin 4, you agree that your contributions will be licensed under the project's existing and future license terms.
ECHO!PGADMIN_PYTHON_DIR!\Scripts\virtualenv.exe does not exist.
ECHO Please install the virtualenv package in Python.
EXIT /B 1
)
REM The checks are made by :REQUIRE_DIR rather than inline, because the
REM message names the directory that was looked for, and a default such as
REM "C:\Program Files (x86)\Inno Setup 6" carries a closing parenthesis.
REM Inside a parenthesised block that parenthesis ends the block, as cmd
REM expands the variable when it parses the block rather than when it runs it,
REM so the block failed to parse whether or not the directory was there.
CALL :REQUIRE_DIR"%PGADMIN_INNOTOOL_DIR%""Please install InnoTool and set the PGADMIN_INNOTOOL_DIR environment variable."||EXIT /B 1
CALL:REQUIRE_DIR"%PGADMIN_VCREDIST_DIR%""Please install Microsoft Visual Studio and set the PGADMIN_VCREDIST_DIR environment variable."||EXIT /B 1
CALL:REQUIRE_DIR"%PGADMIN_KRB5_DIR%""Please install MIT Kerberos for Windows, from the winpgbuild project or elsewhere, and set the PGADMIN_KRB5_DIR environment variable."||EXIT /B 1
CALL:REQUIRE_DIR"%PGADMIN_POSTGRES_DIR%""Please install PostgreSQL, from the winpgbuild project or elsewhere, and set the PGADMIN_POSTGRES_DIR environment variable."||EXIT /B 1
SET"PATH=%PGADMIN_POSTGRES_DIR%\bin;%PATH%"
@@ -161,10 +200,14 @@ REM Main build sequence Ends
CD"%TMPDIR%"
REM Note that we must use virtualenv.exe here, as the venv module doesn't allow python.exe to relocate.
FOR/f"tokens=*"%%i IN('npm info electron version')DOSET"ELECTRON_VERSION=%%i"
REM Resolve the electron version from runtime\yarn.lock, NOT from the npm
REM registry and NOT from the range in runtime\package.json. The registry's
REM `latest` dist-tag lands any newly published electron release in shipped
REM binaries without review, whilst the package.json range is only a lower
REM bound, so yarn is free to resolve it to a build other than the one we
REM ship. The lockfile is the single source of truth, and `yarn info`
REM reports its resolution without touching the network or needing
REM node_modules.
REM
REM It must run inside runtime\ though: outside a yarn project, `yarn info`
REM silently falls back to querying the registry and still exits 0, so check
REM the lockfile is present first and validate what comes back.
IFNOTEXIST"%WD%\runtime\yarn.lock"(
ECHO ERROR: "%WD%\runtime\yarn.lock" not found; cannot resolve the pinned Electron version.
EXIT /B 1
)
SET"ELECTRON_VERSION="
PUSHD"%WD%\runtime"||EXIT /B 1
FOR/f"delims="%%i IN('yarn info electron --json ^| node -e "const lines=require('fs').readFileSync(0,'utf8').split('\n').filter(Boolean);const pkg=lines.map(l=>{try{return JSON.parse(l);}catch(e){return null;}}).find(o=>o&&typeof o.value==='string'&&o.value.startsWith('electron@npm:'));const version=(pkg&&pkg.children&&pkg.children.Version)||'';process.stdout.write(/^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?$/.test(version)?version:'');"') DO SET "ELECTRON_VERSION=%%i"
POPD
IF"%ELECTRON_VERSION%"==""(
ECHO ERROR: Could not resolve the pinned Electron version from "%WD%\runtime\yarn.lock".
If pgAdmin detects per-provider OAuth2 settings at the top level while no
provider is configured in ``OAUTH2_CONFIG``, it logs a warning at startup
to highlight the misconfiguration.
OAuth2 vs OpenID Connect (OIDC)
================================
@@ -63,17 +82,52 @@ and secure.
"OAUTH2_API_BASE_URL", "Oauth2 base URL endpoint to make requests simple, ex: *https://api.github.com/*"
"OAUTH2_USERINFO_ENDPOINT", "User Endpoint, ex: *user* (for github, or *user/emails* if the user's email address is private) and *userinfo* (for google). **For OIDC providers**, this is optional if the ID token contains sufficient claims (email, preferred_username, or sub)."
"OAUTH2_SCOPE", "Oauth scope, ex: 'openid email profile'. **For OIDC providers**, include 'openid' scope to receive an ID token."
"OAUTH2_ICON", "The Font-awesome icon to be placed on the oauth2 button, ex: fa-github"
"OAUTH2_ICON", "The Font Awesome icon to be placed on the oauth2 button. A brand icon name (e.g. fa-github) uses the brands style by default; to use another style, include the style class explicitly (e.g. 'fas fa-key')."
"OAUTH2_BUTTON_COLOR", "Oauth2 button color"
"OAUTH2_USERNAME_CLAIM", "The claim which is used for the username. If the value is empty, **for OIDC providers** pgAdmin will use: 1) email, 2) preferred_username, or 3) sub (in that order). **For OAuth2 providers** without OIDC, email is required. Ex: *oid* (for AzureAD), *email* (for Github), *preferred_username* (for Keycloak)"
"OAUTH2_AUTO_CREATE_USER", "Set the value to *True* if you want to automatically
create a pgAdmin user corresponding to a successfully authenticated Oauth2 user.
Please note that password is not stored in the pgAdmin database."
"OAUTH2_AUTO_LOGIN", "Set the value to *True* to send users straight to the OAuth2 provider
instead of showing the pgAdmin login page. This only takes effect when *oauth2* is the only
value in AUTHENTICATION_SOURCES and exactly one provider is configured. The default is *False*.
See :ref:`oauth2_auto_login` below."
"OAUTH2_ADDITIONAL_CLAIMS", "If a dictionary is provided, pgAdmin will check for a matching key and value on the **ID token first** (for OIDC providers), then fall back to the userinfo endpoint response. In case there is no match with the provided config, the user will receive an authorization error. Useful for checking AzureAD_ *wids* or *groups*, GitLab_ *owner*, *maintainer* and *reporter* claims."
"OAUTH2_SERVER_GROUP_CLAIM", "The name of a claim listing the server groups whose shared servers the user may access. If set, shared servers owned by other users are only visible and accessible if the name of their server group is in the claim. See :ref:`oauth2_server_group_claims` below."
"OAUTH2_SERVER_GROUP_CLAIM_MAPPING", "An optional dictionary mapping values of the OAUTH2_SERVER_GROUP_CLAIM claim to a server group name or a list of server group names. Values without a mapping are used as server group names unchanged."
"OAUTH2_SSL_CERT_VERIFICATION", "Set this variable to False to disable SSL certificate verification for OAuth2 provider.
This may need to set False, in case of self-signed certificates."
"OAUTH2_CHALLENGE_METHOD", "Enable PKCE workflow. PKCE method name, only *S256* is supported"
"OAUTH2_RESPONSE_TYPE", "Enable PKCE workflow. Mandatory with OAUTH2_CHALLENGE_METHOD, must be set to *code*"
"OAUTH2_LOGOUT_URL", "The provider's logout endpoint, used to end the user's session with the provider
when they log out of pgAdmin. *{redirect_uri}* and *{id_token}* in the value are replaced with the pgAdmin
URL and the user's ID token. Ex: *https://example.com/realms/master/protocol/openid-connect/logout?post_logout_redirect_uri={redirect_uri}&id_token_hint={id_token}* (for Keycloak)"
Accounts
========
An OAuth2 user is only ever logged in to the OAuth2 account whose username is
the one resolved from their identity. When *OAUTH2_USERNAME_CLAIM* is set, that
claim is used, taken from the ID token if present and otherwise from the
userinfo profile. When it is not set, an OIDC provider uses the first of
``email``, ``preferred_username`` or ``sub`` that is present, and a non-OIDC
provider uses the email address. The user is never logged in to an ``INTERNAL``
or other account with the same username. If there is no such OAuth2 account and
*OAUTH2_AUTO_CREATE_USER* is *False*, the login fails. When you provision an
administrator account for an OAuth2-only deployment, give it the username your
provider resolves to, which may not be the email address.
The administrator account created when pgAdmin is first set up is an
``INTERNAL`` account. If you use OAuth2 authentication only, create an OAuth2
administrator account and deactivate the initial one:
|`Issue #2431 <https://github.com/pgadmin-org/pgadmin4/issues/2431>`_ - Added an option to colourize panel and tab headers based on the connected server's colour, making it easier to tell which server a tab is connected to at a glance.
|`Issue #7641 <https://github.com/pgadmin-org/pgadmin4/issues/7641>`_ - Allow the OAuth2 login button icon to use any Font Awesome style (e.g. ``fas fa-key``), not only brand icons.
|`Issue #9301 <https://github.com/pgadmin-org/pgadmin4/issues/9301>`_ - Added a "Back to login" link to the Forgot Password and Reset Password pages.
|`Issue #9626 <https://github.com/pgadmin-org/pgadmin4/issues/9626>`_ - Add support for the TOAST tuple target storage parameter in the Materialized View dialog.
|`Issue #9646 <https://github.com/pgadmin-org/pgadmin4/issues/9646>`_ - Make the init container security context in the Helm chart configurable via containerSecurityContext, consistent with the main container.
|`Issue #9699 <https://github.com/pgadmin-org/pgadmin4/issues/9699>`_ - Add support for closing a tab with a middle-click on its title.
Housekeeping
************
|`Issue #9817 <https://github.com/pgadmin-org/pgadmin4/issues/9817>`_ - Correct the macOS Replace keyboard shortcut documented in the Query Tool toolbar reference.
|`Issue #9866 <https://github.com/pgadmin-org/pgadmin4/issues/9866>`_ - Fix a typo in the PGPASS_FILE environment-variable documentation and clarify its behaviour.
|`Issue #9917 <https://github.com/pgadmin-org/pgadmin4/issues/9917>`_ - Update the Italian translation.
|`Issue #9959 <https://github.com/pgadmin-org/pgadmin4/issues/9959>`_ - Bump Electron in the desktop runtime from 41.5.0 to 42.1.0 and pin the packaged Electron version.
|`Issue #9981 <https://github.com/pgadmin-org/pgadmin4/issues/9981>`_ - Clarify the SSH tunnel "Prompt for identity file password?" switch label and help text to indicate it applies only to identity-file authentication.
|`Issue #10014 <https://github.com/pgadmin-org/pgadmin4/issues/10014>`_ - Document the Kubernetes init-container tag-mismatch and RollingUpdate hazards on shared data volumes.
|`Issue #10050 <https://github.com/pgadmin-org/pgadmin4/issues/10050>`_ - Rebase the version-specific SQL templates so the default targets PostgreSQL 14, the oldest supported server version, dropping the obsolete sub-14 template buckets.
|`Issue #10063 <https://github.com/pgadmin-org/pgadmin4/issues/10063>`_ - Strip the foreign-architecture slice from the macOS bundle so single-arch builds no longer ship the universal2 Python framework's unused arm64/x86_64 code.
|`Issue #10094 <https://github.com/pgadmin-org/pgadmin4/issues/10094>`_ - Update the Italian translation for v9.16.
Bug fixes
*********
|`Issue #6308 <https://github.com/pgadmin-org/pgadmin4/issues/6308>`_ - Fix the infinite loading spinner after an idle database connection is silently dropped, by detecting stale connections and offering a reconnect dialog.
|`Issue #7346 <https://github.com/pgadmin-org/pgadmin4/issues/7346>`_ - Fixed an issue where preferences set via the CLI (setup.py set-prefs) were not validated, so invalid values were stored silently; CLI preference values are now validated against the preference type and rejected (and reported) if invalid.
|`Issue #7596 <https://github.com/pgadmin-org/pgadmin4/issues/7596>`_ - Fix the Query Tool turning into a blank white screen when the runtime has a malformed default locale, by guarding the Query History date/time formatting against the resulting RangeError.
|`Issue #8318 <https://github.com/pgadmin-org/pgadmin4/issues/8318>`_ - Fixed an error ("i.default.find(...) is undefined") that prevented deleting a table or relationship link in the ERD tool when a foreign key referenced a column that had been renamed.
|`Issue #9060 <https://github.com/pgadmin-org/pgadmin4/issues/9060>`_ - Fixed an issue in the Create Table dialog where renaming a column did not update the column references in foreign key and unique constraint definitions for the new table.
|`Issue #9091 <https://github.com/pgadmin-org/pgadmin4/issues/9091>`_ - Fix the Query Tool re-prompting for an unsaved password in a loop and rejecting the re-entered password, by caching the entered password on the server manager when the primary connection is already established.
|`Issue #9128 <https://github.com/pgadmin-org/pgadmin4/issues/9128>`_ - Fixed an issue where the object breadcrumbs popup blocked clicks on the object explorer items beneath it.
|`Issue #9595 <https://github.com/pgadmin-org/pgadmin4/issues/9595>`_ - Fix missing ALTER ... SET DEFAULT statements for inherited columns in the generated table SQL/EDIT script.
|`Issue #9677 <https://github.com/pgadmin-org/pgadmin4/issues/9677>`_ - Fix the Unlogged table toggle in table properties not generating any ALTER TABLE ... SET LOGGED/UNLOGGED statement.
|`Issue #9701 <https://github.com/pgadmin-org/pgadmin4/issues/9701>`_ - Ensure pgAdmin uses psycopg3 rather than psycopg2 when connecting to the configuration database via PGADMIN_CONFIG_CONFIG_DATABASE_URI.
|`Issue #9744 <https://github.com/pgadmin-org/pgadmin4/issues/9744>`_ - Fix a View/Edit Data crash when the session contains a transaction object that is not filter-capable (e.g. left by the Query Tool or persisted by an older version), which could prevent the desktop application from loading after an upgrade.
|`Issue #9762 <https://github.com/pgadmin-org/pgadmin4/issues/9762>`_ - Fix the "Cannot read properties of undefined (reading 'map')" crash in the desktop runtime when a menu refresh is triggered before the application menus have been received.
|`Issue #9766 <https://github.com/pgadmin-org/pgadmin4/issues/9766>`_ - Fixed an issue where a server's custom foreground colour was not applied to the object counts (children count) and column type labels shown in the object explorer.
|`Issue #9782 <https://github.com/pgadmin-org/pgadmin4/issues/9782>`_ - Optimise pgAdmin startup time by replacing the fixed server-ping interval with adaptive pinging that backs off when no servers are configured.
|`Issue #9806 <https://github.com/pgadmin-org/pgadmin4/issues/9806>`_ - Fixed an issue where generated font and image filenames contained a double dot (e.g. ``Roboto-Bold..ttf``) due to the webpack asset filename template.
|`Issue #9828 <https://github.com/pgadmin-org/pgadmin4/issues/9828>`_ - Fix tool calls failing against OpenAI-compatible providers that emit empty/null name, arguments, or id fields in streaming continuation deltas.
|`Issue #9829 <https://github.com/pgadmin-org/pgadmin4/issues/9829>`_ - Fixed installation on Python 3.9 (e.g. RHEL/Rocky/AlmaLinux 8 and 9) failing with "No module named 'pkg_resources'" by pinning setuptools below the version that dropped pkg_resources for Python 3.9.
|`Issue #9854 <https://github.com/pgadmin-org/pgadmin4/issues/9854>`_ - Fix the JSON editor stripping trailing fractional zeros (e.g. 10.00) and rewriting large integers in jsonb values, which corrupted unmodified numbers when saving.
|`Issue #9864 <https://github.com/pgadmin-org/pgadmin4/issues/9864>`_ - Fixed a regression where the rectangular (block/column) text selection in the code editor stopped working; restored the default Alt+drag selection without re-introducing the crosshair cursor on Alt+F5 (#9570).
|`Issue #9868 <https://github.com/pgadmin-org/pgadmin4/issues/9868>`_ - Warn before opening a very large JSON/JSONB value in the data grid cell editor, which could freeze pgAdmin, and let the user choose whether to proceed.
|`Issue #9875 <https://github.com/pgadmin-org/pgadmin4/issues/9875>`_ - Fixed an issue where EXPLAIN and EXPLAIN ANALYZE failed to execute when blank lines separated clauses in the SQL query.
|`Issue #9810 <https://github.com/pgadmin-org/pgadmin4/issues/9810>`_ - Use the ServerManager's passfile for the credential gate in connect() so the check matches the passfile actually used for the connection, and warn on conflicting passfile/passexec settings.
|`Issue #9892 <https://github.com/pgadmin-org/pgadmin4/issues/9892>`_ - Fix blank difference counts on the top-level group rows in Schema Diff.
|`Issue #9896 <https://github.com/pgadmin-org/pgadmin4/issues/9896>`_ - Fix invalid DDL reconstruction for SERIAL columns in Schema Diff and the generated SQL/CREATE Script so the output round-trips on a clean target.
|`Issue #9933 <https://github.com/pgadmin-org/pgadmin4/issues/9933>`_ - Remove the administrator-role bypass from the server-access helpers so the access-control checks added in 9.15 (CVE-2026-7813) are enforced uniformly. The Administrator role manages pgAdmin itself, not other users' database connections.
|`Issue #9935 <https://github.com/pgadmin-org/pgadmin4/issues/9935>`_ - Fix "Illegal instruction" crash on startup of the Linux DEB and RPM packages on older x86_64 CPUs by pinning the psycopg C extension build to the x86-64 baseline.
|`Issue #9936 <https://github.com/pgadmin-org/pgadmin4/issues/9936>`_ - Fix the AI panel silently falling back to the default provider when a custom LLM API URL or key file was set, and allow self-hosted LLM endpoints on any loopback port.
|`Issue #9939 <https://github.com/pgadmin-org/pgadmin4/issues/9939>`_ - Fix saving a newly-added row in the Query Tool failing when the result set includes expression or alias columns that are not real columns of the underlying table.
|`Issue #9952 <https://github.com/pgadmin-org/pgadmin4/issues/9952>`_ - Ship libpq-oauth-18.so and libcurl in the Docker image so PostgreSQL 18 OAuth connections work out of the box.
|`Issue #9976 <https://github.com/pgadmin-org/pgadmin4/issues/9976>`_ - Fix a startup migration crash (NoSuchTableError) when an old configuration database contains a stale foreign-key reference.
|`Issue #9984 <https://github.com/pgadmin-org/pgadmin4/issues/9984>`_ - Fix the Docker entrypoint mishandling a quoted PGADMIN_CONFIG_CONFIG_DATABASE_URI, which caused a SQLAlchemy parse error and silently skipped PGADMIN_DEFAULT_EMAIL/PASSWORD setup.
|`Issue #9985 <https://github.com/pgadmin-org/pgadmin4/issues/9985>`_ - Make CAP_NET_BIND_SERVICE optional in the Docker image so the container works on restricted runtimes that disable that capability.
|`Issue #9987 <https://github.com/pgadmin-org/pgadmin4/issues/9987>`_ - Fix "AttributeError: 'PgAdmin' object has no attribute 'login_manager'" crash when running setup.py user-management commands (add-user, update-user) from the CLI.
|`Issue #9988 <https://github.com/pgadmin-org/pgadmin4/issues/9988>`_ - Provide an actionable error when 'openid' is in OAUTH2_SCOPE but OAUTH2_SERVER_METADATA_URL is not set, instead of a cryptic Authlib failure.
|`Issue #10013 <https://github.com/pgadmin-org/pgadmin4/issues/10013>`_ - Fix pg_attribute filtering by attname returning the wrong attnum when the same column name exists in multiple tables.
|`Issue #10022 <https://github.com/pgadmin-org/pgadmin4/issues/10022>`_ - Fix AI Assistant read-only transaction bypass that allowed prompt-injected multi-statement payloads to commit out of the READ ONLY wrapper and execute arbitrary SQL, chaining to RCE via COPY ... TO PROGRAM on a superuser connection (CVE-2026-12045). Reported by Isaac Chen.
|`Issue #10026 <https://github.com/pgadmin-org/pgadmin4/issues/10026>`_ - Fix SQL injection in the named restore point endpoint where the user-supplied restore point name was interpolated into SQL via str.format() instead of being passed as a bound parameter (CVE-2026-12050). Reported by Geo.
|`Issue #10027 <https://github.com/pgadmin-org/pgadmin4/issues/10027>`_ - Fix the spurious "Crypt key is missing" error and logged traceback in the Query Tool new-connection endpoints after a backend restart, by surfacing it as the standard CRYPTKEY_MISSING response so the client recovers transparently.
|`Issue #10028 <https://github.com/pgadmin-org/pgadmin4/issues/10028>`_ - Fix open redirect in the multi-factor authentication flow via an unvalidated 'next' parameter that allowed a crafted link to redirect an authenticated victim to an attacker-controlled host (CVE-2026-12049). Reported by Mai Phạm Hiền.
|`Issue #10029 <https://github.com/pgadmin-org/pgadmin4/issues/10029>`_ - Fixed a regression where the rectangular (block/column) text selection in the code editor stopped working; restored the default Alt+drag selection without re-introducing the crosshair cursor on Alt+F5 (#9570).
|`Issue #10030 <https://github.com/pgadmin-org/pgadmin4/issues/10030>`_ - Accept prepare and binary keyword arguments in DictCursor.execute() so callers using the modern psycopg API no longer raise TypeError.
|`Issue #10059 <https://github.com/pgadmin-org/pgadmin4/issues/10059>`_ - Fix the generated SQL for editing a SQL-language function/procedure whose body contains the word "return" (e.g. a RETURNING clause), which was wrongly treated as a SQL-standard body and produced a statement without the AS $BODY$ wrapper.
|`Issue #10068 <https://github.com/pgadmin-org/pgadmin4/issues/10068>`_ - Fix critical stored cross-site scripting where PostgreSQL server error text and Explain plan-node content passed through html-react-parser across notifier toasts, form errors, modal alerts, and the Explain visualiser. Because pgAdmin's default Content-Security-Policy allows inline script and an iframe ``srcdoc`` inherits the embedding origin, the injected JavaScript ran same-origin to the victim's authenticated pgAdmin session and could read every saved server connection credential and issue arbitrary SQL against every server the victim was connected to (CVE-2026-12048). Reported by Fernando Bortotti.
|`Issue #10069 <https://github.com/pgadmin-org/pgadmin4/issues/10069>`_ - Fix HTML injection in the cloud deployment module (RDS, Azure, Google) where SDK exception text was forwarded to the browser unsanitised and rendered through html-react-parser in the Cloud Wizard (CVE-2026-12047). Reported by Fernando Bortotti.
|`Issue #10072 <https://github.com/pgadmin-org/pgadmin4/issues/10072>`_ - Fix two SQL Editor endpoints (close and update_connection) missing the ``@pga_login_required`` decorator, making them reachable without authentication in server mode and exposing a pickle deserialization sink (CVE-2026-12046). Reported by Fernando Bortotti.
|`Issue #10078 <https://github.com/pgadmin-org/pgadmin4/issues/10078>`_ - Fix SQL injection across sixteen dialog templates that rendered ``COMMENT ON ... IS '<description>'`` and the related ``pgstattuple``/``pgstatindex`` stats sinks, where a low-privilege user could plant a table or index name containing an apostrophe and a superuser viewing statistics on the object would trigger SQL execution under the superuser role. The fix switches the affected templates to the ``qtLiteral`` escape filter and rewrites the ``pgstattuple``/``pgstatindex`` calls to address the relation via OID with a ``::oid::regclass`` cast, eliminating the embedded string-literal call form entirely. Also hardens ``qtLiteral`` to raise instead of silently returning raw values when ``conn`` is falsy (CVE-2026-12044). Reported by Jasser Chebbi.
Additional changes (no associated issue)
****************************************
Dependencies
------------
Non-breaking ``dependabot`` updates aggregated for v9.16.
|`Issue #9990 <https://github.com/pgadmin-org/pgadmin4/issues/9990>`_ - Include the authenticated user's identity in the HTTP access log.
|`Issue #9942 <https://github.com/pgadmin-org/pgadmin4/issues/9942>`_ - Add an opt-in Gateway API HTTPRoute template to the Helm chart as an alternative to the existing Ingress.
|`Issue #10104 <https://github.com/pgadmin-org/pgadmin4/issues/10104>`_ - Add a preference to cap the row count fetched by the plain "View Data" action, so it is usable on large tables without always doing a full ``SELECT *``.
|`Issue #10142 <https://github.com/pgadmin-org/pgadmin4/issues/10142>`_ - Add support for a custom XYZ tile provider (URL, name, CRS, attribution, max zoom) in the Geometry Viewer, alongside the existing built-in base layers.
Housekeeping
************
|`Issue #10077 <https://github.com/pgadmin-org/pgadmin4/issues/10077>`_ - Centralize shared-server-group visibility and access-control logic, and adjust the ServerGroup-to-Server/SharedServer model relationships.
|`Issue #10093 <https://github.com/pgadmin-org/pgadmin4/issues/10093>`_ - Add a basic starter contributing guide.
|`Issue #10135 <https://github.com/pgadmin-org/pgadmin4/issues/10135>`_ - Fail the macOS appbundle build if any bundled library links outside the bundle, and scan all Mach-O binaries for bundle linkage rather than only files named ``.so``/``.dylib``.
|`Issue #10154 <https://github.com/pgadmin-org/pgadmin4/issues/10154>`_ - Pin the sonarqube-scan-action GitHub workflow to a full commit SHA instead of a mutable branch ref, to harden against supply-chain tampering.
|`Issue #10138 <https://github.com/pgadmin-org/pgadmin4/issues/10138>`_ - Update the Simplified Chinese (zh_Hans_CN) translation to match the wording used in the interface.
|`Issue #10156 <https://github.com/pgadmin-org/pgadmin4/issues/10156>`_ - Pin the Yarn version used by the build scripts to the ``packageManager`` field, rather than whatever Yarn happens to be on the builder's PATH.
|`Issue #10176 <https://github.com/pgadmin-org/pgadmin4/issues/10176>`_ - Bump JavaScript and Python third-party dependencies, including axios, webpack, react, electron, and certifi.
Bug fixes
*********
|`Issue #10053 <https://github.com/pgadmin-org/pgadmin4/issues/10053>`_ - Warn when OAuth2 provider settings are misplaced at the top level of the config instead of under ``OAUTH2_CONFIG``.
|`Issue #10100 <https://github.com/pgadmin-org/pgadmin4/issues/10100>`_ - Fix Schema Diff's "Generate Script" and the browser tree's CREATE Script view emitting wrong SQL for SERIAL/identity columns, by detecting column-owned sequences via pg_depend instead of guessing the sequence name.
|`Issue #10102 <https://github.com/pgadmin-org/pgadmin4/issues/10102>`_ - Fix a Schema Diff result-status filter chip showing "No difference found" after being toggled off and back on, even when real differences exist.
|`Issue #10106 <https://github.com/pgadmin-org/pgadmin4/issues/10106>`_ - Fix the Object Explorer briefly showing the literal HTML markup instead of a greyed-out "[Disconnecting...]" label when disconnecting a server or database.
|`Issue #10107 <https://github.com/pgadmin-org/pgadmin4/issues/10107>`_ - Detect a selected-but-unusable OS keyring and fall back gracefully instead of failing.
|`Issue #10109 <https://github.com/pgadmin-org/pgadmin4/issues/10109>`_ - Fix ALT+F5 ("Execute query at cursor") doing nothing when the cursor is on or near a statement that is not highlighted, in a Query Tool tab with multiple statements separated by blank lines.
|`Issue #10110 <https://github.com/pgadmin-org/pgadmin4/issues/10110>`_ - Stop the googleapiclient cloud-deployment dependency from pulling in the system oauth2client package.
|`Issue #10117 <https://github.com/pgadmin-org/pgadmin4/issues/10117>`_ - Fix a "'Response' object is not iterable" crash when expanding a Trigger node under a Table to view its trigger function, and report the correct error message on a node.sql failure in that flow.
|`Issue #10158 <https://github.com/pgadmin-org/pgadmin4/issues/10158>`_ - Honor the selected EOL sequence when copying query text to the clipboard.
|`Issue #10187 <https://github.com/pgadmin-org/pgadmin4/issues/10187>`_ - Fix the object browser's extension UI breaking under PostgreSQL 19's extension catalog changes.
|`Issue #10190 <https://github.com/pgadmin-org/pgadmin4/issues/10190>`_ - Fix a tool-permission bypass where a user denied the Query Tool, Grant Wizard, or Schema Diff permission could still drive that tool's backend routes and Socket.IO handlers directly, since the permission check was applied only to a single "front door" route per tool. Also fixes a non-owner triggering an adhoc connection against another user's shared server persisting a new server record still owned by that other user (CVE-2026-17350). Reported by LXY.
|`Issue #10191 <https://github.com/pgadmin-org/pgadmin4/issues/10191>`_ - Fix OS command injection in the MASTER_PASSWORD_HOOK feature, where an externally-sourced username (e.g. via OAuth2/OIDC, Kerberos, or webserver authentication) containing shell metacharacters could execute arbitrary commands as the pgAdmin service account when the configured hook string uses ``%u`` (CVE-2026-17347). Reported by Thiago Pereira.
|`Issue #10192 <https://github.com/pgadmin-org/pgadmin4/issues/10192>`_ - Fix a lexer-differential bypass of the AI Assistant's read-only transaction guard, where sqlparse's string-literal lexing disagreed with PostgreSQL's own parser under ``standard_conforming_strings = on``, letting a crafted multi-statement payload smuggle a COMMIT past the intended read-only wrapper, an incomplete fix for CVE-2026-12045 (CVE-2026-17351). Reported by Kai Aizen.
|`Issue #10193 <https://github.com/pgadmin-org/pgadmin4/issues/10193>`_ - Fix SQL injection in the Index Statistics all-indexes listing and the Publications/Subscriptions Dependencies views, where an apostrophe in a table, index, publication, or subscription name broke out of an unescaped template interpolation, an incomplete fix for CVE-2026-12044 (CVE-2026-17346). Reported by Hung Tran Quoc (@rampage0010).
|`Issue #10194 <https://github.com/pgadmin-org/pgadmin4/issues/10194>`_ - Fix several Constraints, Preferences, Debugger, and Schema Diff routes missing the ``@pga_login_required`` decorator, making them reachable without authentication in server mode, an incomplete fix for CVE-2026-12046 (CVE-2026-17348). Reported by Hung Tran Quoc (@rampage0010).
|`Issue #10200 <https://github.com/pgadmin-org/pgadmin4/issues/10200>`_ - Fix an adhoc server connection cloning another user's stored database credentials (password, save password flag, tunnel password) alongside ownership, letting a non-owner who cloned another user's shared server connect using that user's saved database password (CVE-2026-17349).
|`Issue #10213 <https://github.com/pgadmin-org/pgadmin4/issues/10213>`_ - Fix OS command injection in the Import/Export Data tool, where a query-based export could pass a crafted query string past the ``\copy (...)`` parenthesis-balance guard by exploiting a backslash-escape mismatch with psql's default ``standard_conforming_strings = on`` behaviour, exposing a live ``TO PROGRAM`` clause for arbitrary command execution (CVE-2026-17566). Reported by Arpit Jain.
Additional changes (no associated issue)
****************************************
Dependencies
------------
Non-breaking ``dependabot`` and audit-driven bumps aggregated for v9.17.
Python:
|``certifi`` 2026.5.20 -> 2026.6.17
JavaScript (``web/``):
|``@babel/core`` 7.29.0 -> 7.29.6
|``@date-io/date-fns`` 3.x -> 3.2.1
|``@szhsin/react-menu`` 4.5.1 -> 4.5.2
|``@tanstack/react-query`` 5.100.9 -> 5.101.4
|``@types/react`` 19.2.14 -> 19.2.17
|``ajv`` 8.18.0 -> 8.20.0
|``anti-trojan-source`` 1.8.1 -> 1.12.0
|``autoprefixer`` 10.5.0 -> 10.5.4
|``axios`` 1.16.0 -> 1.18.1
|``core-js`` added as an explicit direct dependency (3.49.0), previously relied on transitively via pickr
|`Issue #9631 <https://github.com/pgadmin-org/pgadmin4/issues/9631>`_ - Collapse and restore the Object Explorer by re-clicking the current workspace icon, in the manner of the VS Code side bar, remembering the choice across refreshes. A keyboard shortcut, Ctrl+Alt+B by default, does the same thing and can be changed through the new ``toggle_object_explorer`` preference.
Housekeeping
************
|`Issue #10221 <https://github.com/pgadmin-org/pgadmin4/issues/10221>`_ - Skip importing and initialising the kerberos, ldap, mfa, oauth2 and webserver authentication providers unless ``SERVER_MODE`` is set, leaving desktop mode with internal authentication alone.
|`Issue #10247 <https://github.com/pgadmin-org/pgadmin4/issues/10247>`_ - Relax the ``azure-mgmt-resource`` pin to allow 24.0.0, and aggregate the third-party JavaScript and Python dependency bumps for this release.
|`Issue #10293 <https://github.com/pgadmin-org/pgadmin4/issues/10293>`_ - Make the Schema Diff regression test assert its own generated script, which it previously swallowed, so invalid generated SQL can no longer pass silently.
Bug fixes
*********
|`Issue #9226 <https://github.com/pgadmin-org/pgadmin4/issues/9226>`_ - Accept ``SharedUsername`` when importing a shared server from a servers.json definition, instead of insisting on ``Username`` for every server.
|`Issue #10155 <https://github.com/pgadmin-org/pgadmin4/issues/10155>`_ - Share concurrent identical GET requests behind ``getNodeAjaxOptions()`` so a wide table's Columns tab no longer fires one duplicate ``get_types`` request per column row.
|`Issue #10179 <https://github.com/pgadmin-org/pgadmin4/issues/10179>`_ - Fix inherited columns in the Table dialog being editable and deletable: a column already inherited from a parent carries ``inheritedfromtable`` while one fetched interactively through 'Inherited from table(s)' carries ``inheritedfrom`` and has no ``attnum`` yet, and only the latter was checked, after an ``isNew()`` short-circuit that treated the attnum-less rows as new.
|`Issue #10180 <https://github.com/pgadmin-org/pgadmin4/issues/10180>`_ - Fix the Data type dropdown on the expanded Definition tab of the Table dialog offering every type, ignoring the allowed-type restriction already applied to the inline editor.
|`Issue #10214 <https://github.com/pgadmin-org/pgadmin4/issues/10214>`_ - Fix the ``existingSecret`` path in the Helm deployment template.
|`Issue #10235 <https://github.com/pgadmin-org/pgadmin4/issues/10235>`_ - Remove a trailing quote from the Windows installer's ``ProductVersion``, which was stamped as e.g. ``9.17"``.
|`Issue #10236 <https://github.com/pgadmin-org/pgadmin4/issues/10236>`_ - Fix Schema Diff reporting false differences for SERIAL/BIGSERIAL columns by ignoring the owned sequence's oid, and fix the invalid ``ALTER COLUMN ... TYPE bigserial`` SQL generated when such a column genuinely differs.
|`Issue #10237 <https://github.com/pgadmin-org/pgadmin4/issues/10237>`_ - Fix a ``replace() argument 2 must be str, not None`` crash when a per-server Password Exec Command is used with a service-only (pg_service.conf) connection, which leaves host, port and username unset.
|`Issue #10239 <https://github.com/pgadmin-org/pgadmin4/issues/10239>`_ - Remove a redundant ``passlib`` pin that conflicts with the indirectly required ``libpass``.
|`Issue #10252 <https://github.com/pgadmin-org/pgadmin4/issues/10252>`_ - Fix the argument grid on the Definition tab of a user-defined function or procedure refusing to delete, or add, a row in edit mode.
|`Issue #10295 <https://github.com/pgadmin-org/pgadmin4/issues/10295>`_ - Reinstate dependency ordering of the script Schema Diff generates, which had been lost since the React port left ``dependLevel`` unset.
|`Issue #10297 <https://github.com/pgadmin-org/pgadmin4/issues/10297>`_ - Fix Schema Diff duplicating any column that also differs when it recreates a foreign table.
|`Issue #10298 <https://github.com/pgadmin-org/pgadmin4/issues/10298>`_ - Fix Schema Diff generating SQL that PostgreSQL rejects when a sequence's MINVALUE is raised above its current value.
|`Issue #10300 <https://github.com/pgadmin-org/pgadmin4/issues/10300>`_ - Fix a foreign table column added by Schema Diff losing its collation.
|`Issue #10302 <https://github.com/pgadmin-org/pgadmin4/issues/10302>`_ - Fix Schema Diff injecting whitespace into an applied function or procedure body, which left a whitespace-only difference behind.
|`Issue #10303 <https://github.com/pgadmin-org/pgadmin4/issues/10303>`_ - Report a Schema Diff comparison that fails part way through as a failure, rather than as a success.
|`Issue #10309 <https://github.com/pgadmin-org/pgadmin4/issues/10309>`_ - Reject an empty or null ``Username`` when importing a non-shared server, which previously imported cleanly and left a server libpq would silently authenticate as the OS account running pgAdmin.
|`Issue #10311 <https://github.com/pgadmin-org/pgadmin4/issues/10311>`_ - Fix login being impossible against Flask-Security-Too 5.8.2, which corrected a long-standing inversion in ``UserMixin.is_locked()`` that ``User.is_locked()`` had been written against.
|`Issue #10341 <https://github.com/pgadmin-org/pgadmin4/issues/10341>`_ - Omit the redundant ``TABLESPACE pg_default`` clause from generated index SQL, which was invalid on a partitioned table.
|`Issue #10383 <https://github.com/pgadmin-org/pgadmin4/issues/10383>`_ - Fix an authentication bypass in Webserver authentication mode, where ``get_user()`` fell back to reading the configured ``WEBSERVER_REMOTE_USER`` name from the request headers when it was absent from the WSGI environment, letting any client that could reach pgAdmin assert any identity, including an administrator's (CVE-2026-86863). Header-asserted identity is now opt-in, restricted to a configured list of trusted proxies with an optional shared secret, and refused for accounts whose authentication source is not ``webserver``. Reported by Sanghyeon Lee (@h9e0n).
|`Issue #10384 <https://github.com/pgadmin-org/pgadmin4/issues/10384>`_ - Fix argument and connection-string injection in the Backup tool, where the client-supplied database name was appended to the pg_dump argument vector as a bare positional argument: because getopt_long permutes arguments, a value beginning with a dash supplied further options such as ``--file``, overriding the storage-confined output path, and because libpq expands a database name containing an equals sign into a full connection string, one could also redirect the connection to an attacker-controlled host and exfiltrate the stored password (CVE-2026-86864). The database name is now passed through the ``PGDATABASE`` environment variable instead. Reported by Sanghyeon Lee (@h9e0n) and Hitesh Jambhale.
|`Issue #10385 <https://github.com/pgadmin-org/pgadmin4/issues/10385>`_ - Fix a time-of-check to time-of-use flaw in the File Manager's ``save_file`` endpoint, which backs saving from the Query Tool and ERD: the requested path was validated with ``check_access_permission()`` and then opened with a plain ``open()``, so a symbolic link planted in between was followed, writing outside the storage directory. This is the sink CVE-2026-7819's hardening of the separate upload path did not cover (CVE-2026-86861). Reported by sec-rex.
|`Issue #10388 <https://github.com/pgadmin-org/pgadmin4/issues/10388>`_ - Fix connection-string injection in the Restore and Maintenance tools, where the client-supplied database name was passed straight to ``--dbname``: libpq expands a database name containing an equals sign into a full connection string, whose embedded keywords override the ``--host`` and ``--port`` pgAdmin supplies, so the connection - and the stored password exported in ``PGPASSWORD`` - could be redirected to a server of the caller's choosing (CVE-2026-86862). The database name is now passed through the ``PGDATABASE`` environment variable, which libpq never expands. Found by Hitesh Jambhale.
|`Issue #10393 <https://github.com/pgadmin-org/pgadmin4/issues/10393>`_ - Render the Validate binary path dialog as HTML, instead of showing the raw markup.
|`Issue #10420 <https://github.com/pgadmin-org/pgadmin4/issues/10420>`_ - Refuse HTTP redirects on LLM API requests, rather than following a ``Location`` header on to a destination ``ALLOWED_LLM_API_URLS`` was never applied to. This is hardening rather than a fix for an exploitable flaw, since returning the redirect at all requires control of a host already on the allowlist. Reported by Ziya Abdullayev.
Dependencies
************
Non-breaking ``dependabot`` and audit-driven bumps aggregated for v9.18.
|`Issue #2018 <https://github.com/pgadmin-org/pgadmin4/issues/2018>`_ - Add a Statistics node for creating and managing extended statistics.
|`Issue #2363 <https://github.com/pgadmin-org/pgadmin4/issues/2363>`_ - Allow data to be edited through simple auto-updatable views in View/Edit Data.
|`Issue #3205 <https://github.com/pgadmin-org/pgadmin4/issues/3205>`_ - Allow Query Tool results to be saved as JSON or XML, as well as CSV.
|`Issue #3834 <https://github.com/pgadmin-org/pgadmin4/issues/3834>`_ - Add a keyboard shortcut to duplicate the current line or selection in the SQL editor.
|`Issue #4128 <https://github.com/pgadmin-org/pgadmin4/issues/4128>`_ - Allow the encoding of Query Tool result files to be chosen.
|`Issue #4129 <https://github.com/pgadmin-org/pgadmin4/issues/4129>`_ - Add a 'copy with headers' option for Query Tool results.
|`Issue #5691 <https://github.com/pgadmin-org/pgadmin4/issues/5691>`_ - Close dialogs with the Escape key.
|`Issue #6695 <https://github.com/pgadmin-org/pgadmin4/issues/6695>`_ - Allow a byte order mark to be added to Query Tool result files.
|`Issue #7167 <https://github.com/pgadmin-org/pgadmin4/issues/7167>`_ - Allow dialogs such as Sort/Filter to be saved and closed with the Enter key.
|`Issue #8647 <https://github.com/pgadmin-org/pgadmin4/issues/8647>`_ - Allow shift + click to extend the cell range selection in the Query Tool results grid.
|`Issue #9057 <https://github.com/pgadmin-org/pgadmin4/issues/9057>`_ - Support UNLOGGED partitioned tables correctly, which PostgreSQL 18 no longer allows to be created or altered.
|`Issue #9064 <https://github.com/pgadmin-org/pgadmin4/issues/9064>`_ - Add the PostgreSQL 18 pg_dump options to the Backup and Backup Server dialogs.
|`Issue #9066 <https://github.com/pgadmin-org/pgadmin4/issues/9066>`_ - Add the PostgreSQL 18 pg_restore options to the Restore dialog.
|`Issue #9212 <https://github.com/pgadmin-org/pgadmin4/issues/9212>`_ - Show fill factor, TOAST tuple target and parallel workers in the table Properties panel.
|`Issue #9264 <https://github.com/pgadmin-org/pgadmin4/issues/9264>`_ - Move the 'Returns a set?' option to the Definition tab in the Function dialog.
|`Issue #9704 <https://github.com/pgadmin-org/pgadmin4/issues/9704>`_ - Allow an OAuth2 claim to restrict which shared servers a user can see, configured through ``OAUTH2_SERVER_GROUP_CLAIM`` and ``OAUTH2_SERVER_GROUP_CLAIM_MAPPING``.
|`Issue #9975 <https://github.com/pgadmin-org/pgadmin4/issues/9975>`_ - Allow the login page to be skipped when OAuth2 is the only authentication source, using ``OAUTH2_AUTO_LOGIN`` and ``OAUTH2_LOGOUT_URL``.
|`Issue #10148 <https://github.com/pgadmin-org/pgadmin4/issues/10148>`_ - Add support for managing pg_timetable chains and tasks.
|`Issue #10249 <https://github.com/pgadmin-org/pgadmin4/issues/10249>`_ - Replace free-text Password Exec Commands in server mode with an administrator-defined allowlist, ``SERVER_PASSEXEC_COMMANDS``. Commands run without a shell, with a reduced environment, and receive the pgAdmin user's identity in ``PGADMIN_PASSEXEC_*`` variables; users of a shared server inherit the owner's command by default.
|`Issue #10473 <https://github.com/pgadmin-org/pgadmin4/issues/10473>`_ - Add a macOS 26 layered app icon with dark, clear and tinted variants.
|`Issue #10499 <https://github.com/pgadmin-org/pgadmin4/issues/10499>`_ - Add pgEdge Starfleet to the Deploy Cloud Instance wizard, for creating Starfleet Managed databases.
|`Issue #10503 <https://github.com/pgadmin-org/pgadmin4/issues/10503>`_ - Add numpad zoom shortcuts to the Desktop View menu.
Housekeeping
************
|`Issue #5369 <https://github.com/pgadmin-org/pgadmin4/issues/5369>`_ - Clarify the configuration files documentation.
|`Issue #9230 <https://github.com/pgadmin-org/pgadmin4/issues/9230>`_ - Rename the server 'Change Password' option to 'Change Server Password', and the user menu option to 'Change pgAdmin Password', so the two can no longer be confused.
|`Issue #9331 <https://github.com/pgadmin-org/pgadmin4/issues/9331>`_ - Remove unused Google API documents, Babel locale data and packaging tools from the packages.
|`Issue #9524 <https://github.com/pgadmin-org/pgadmin4/issues/9524>`_ - Document running the container with rootless Podman, and the ``PUID`` and ``PGID`` environment variables.
|`Issue #10363 <https://github.com/pgadmin-org/pgadmin4/issues/10363>`_ - Enforce ``--immutable`` on every yarn install, in CI and the release builds, to harden the supply chain.
|`Issue #10546 <https://github.com/pgadmin-org/pgadmin4/issues/10546>`_ - Fix the flaky ``test_windows_pty_failures`` test counting psql output from other tests.
|`Issue #10552 <https://github.com/pgadmin-org/pgadmin4/issues/10552>`_ - Add a test for Reset discarding pending SQL editor values in dialogs.
|`Issue #10553 <https://github.com/pgadmin-org/pgadmin4/issues/10553>`_ - Make the server tests restore the shared test fixture after themselves.
|`Issue #10601 <https://github.com/pgadmin-org/pgadmin4/issues/10601>`_ - Remove the dead tree ``loadfail`` handler from ``browser.js``.
|`Issue #10602 <https://github.com/pgadmin-org/pgadmin4/issues/10602>`_ - Fix the Jest ``addNewDatagridRow`` helper so schema UI specs actually add a row.
Bug fixes
*********
|`Issue #5940 <https://github.com/pgadmin-org/pgadmin4/issues/5940>`_ - Fix View/Edit Data failing on SQL_ASCII databases when a name contains a backslash followed by 'u' or 'U'.
|`Issue #6090 <https://github.com/pgadmin-org/pgadmin4/issues/6090>`_ - Refresh a stale server connection manager when a server id is reused, so one user's session can no longer pick up settings from another server.
|`Issue #6231 <https://github.com/pgadmin-org/pgadmin4/issues/6231>`_ - Generate a single GRANT statement for roles with identical privileges.
|`Issue #6481 <https://github.com/pgadmin-org/pgadmin4/issues/6481>`_ - Fix index and constraint columns not being shown when their names require quoting.
|`Issue #6652 <https://github.com/pgadmin-org/pgadmin4/issues/6652>`_ - Fix Schema Diff treating functions that differ only in OUT arguments or return type as unrelated objects.
|`Issue #6671 <https://github.com/pgadmin-org/pgadmin4/issues/6671>`_ - Allow the emailed MFA code to be re-entered after an invalid code is entered.
|`Issue #7232 <https://github.com/pgadmin-org/pgadmin4/issues/7232>`_ - Fix the tab switching shortcuts not working whilst focus is inside the PSQL, Query, ERD or Schema Diff tools.
|`Issue #7612 <https://github.com/pgadmin-org/pgadmin4/issues/7612>`_ - Fix the server context menu showing database options after the tree state is restored.
|`Issue #7619 <https://github.com/pgadmin-org/pgadmin4/issues/7619>`_ - Fix Query Tool autocomplete running as the wrong user, or failing, after changing the connection to a different user.
|`Issue #8015 <https://github.com/pgadmin-org/pgadmin4/issues/8015>`_ - Re-sort renamed server groups and other nodes in the Object Explorer instead of waiting for a reload.
|`Issue #8144 <https://github.com/pgadmin-org/pgadmin4/issues/8144>`_ - Connect the server when changing the Query Tool connection after it was disconnected.
|`Issue #8529 <https://github.com/pgadmin-org/pgadmin4/issues/8529>`_ - Rewrite the container entrypoint using bash features, and pass values to the embedded Python safely, so an email address containing an apostrophe works and a validation error stops the container rather than passing silently.
|`Issue #8664 <https://github.com/pgadmin-org/pgadmin4/issues/8664>`_ - Fix debugger messages from successive steps running together on one line.
|`Issue #8991 <https://github.com/pgadmin-org/pgadmin4/issues/8991>`_ - Fix BEGIN, COMMIT and ROLLBACK silently failing in the Query Tool under server cursor mode.
|`Issue #9130 <https://github.com/pgadmin-org/pgadmin4/issues/9130>`_ - Select the adjacent tab when the active tab is closed, rather than the left-most.
|`Issue #9450 <https://github.com/pgadmin-org/pgadmin4/issues/9450>`_ - Allow roles holding ADMIN OPTION to manage Group Role membership.
|`Issue #9603 <https://github.com/pgadmin-org/pgadmin4/issues/9603>`_ - Restore the per-tab toolbar options when Query Tool tabs are restored on relaunch.
|`Issue #9624 <https://github.com/pgadmin-org/pgadmin4/issues/9624>`_ - Fix a database's label staying at '[Disconnecting...]' when disconnecting it fails.
|`Issue #9630 <https://github.com/pgadmin-org/pgadmin4/issues/9630>`_ - Fix the parameter value dropdown keeping stale options when the parameter name is changed.
|`Issue #9672 <https://github.com/pgadmin-org/pgadmin4/issues/9672>`_ - Fix View/Edit Data not handling generated columns properly.
|`Issue #9784 <https://github.com/pgadmin-org/pgadmin4/issues/9784>`_ - Fix modal dialogs losing keyboard focus to a tool iframe loading in the background.
|`Issue #9798 <https://github.com/pgadmin-org/pgadmin4/issues/9798>`_ - Fix the collation and ctype query error for databases with a non-default LC_COLLATE.
|`Issue #9814 <https://github.com/pgadmin-org/pgadmin4/issues/9814>`_ - Disable the SSH agent when an SSH tunnel identity file or password is provided.
|`Issue #9818 <https://github.com/pgadmin-org/pgadmin4/issues/9818>`_ - Keep the AI Assistant's intermediate responses instead of replacing them with placeholder messages.
|`Issue #9836 <https://github.com/pgadmin-org/pgadmin4/issues/9836>`_ - Fix the CREATE TABLE script ignoring the UNLOGGED attribute of a partition.
|`Issue #9838 <https://github.com/pgadmin-org/pgadmin4/issues/9838>`_ - Fix formatting a view's SQL being treated as a column change that drops and re-creates the view.
|`Issue #9865 <https://github.com/pgadmin-org/pgadmin4/issues/9865>`_ - Fix modal dialogs being hidden from assistive technology by ``aria-hidden`` on the application root.
|`Issue #9890 <https://github.com/pgadmin-org/pgadmin4/issues/9890>`_ - Fix ERD tables becoming immovable after a Shift-drag selection.
|`Issue #9921 <https://github.com/pgadmin-org/pgadmin4/issues/9921>`_ - Fix Azure and Google authentication in the cloud deployment wizard.
|`Issue #9984 <https://github.com/pgadmin-org/pgadmin4/issues/9984>`_ - Report an unreachable external configuration database, or a malformed ``CONFIG_DATABASE_URI``, as a failed check rather than an unhandled exception.
|`Issue #10031 <https://github.com/pgadmin-org/pgadmin4/issues/10031>`_ - Fix Query Tool tabs running their query automatically after a session restore.
|`Issue #10103 <https://github.com/pgadmin-org/pgadmin4/issues/10103>`_ - Exclude non-editable alias and expression columns when saving edited rows in the Query Tool.
|`Issue #10136 <https://github.com/pgadmin-org/pgadmin4/issues/10136>`_ - Restore tags for standard users on shared servers, and never inherit the owner's password file.
|`Issue #10143 <https://github.com/pgadmin-org/pgadmin4/issues/10143>`_ - Fix switching back to a dialog tab containing a data grid being slow, by skipping virtualisation for small grids.
|`Issue #10226 <https://github.com/pgadmin-org/pgadmin4/issues/10226>`_ - Restore remembering the last visited directory in the desktop file dialogs.
|`Issue #10251 <https://github.com/pgadmin-org/pgadmin4/issues/10251>`_ - Fix invalid SQL from the Maintenance dialog for REINDEX ... CONCURRENTLY.
|`Issue #10254 <https://github.com/pgadmin-org/pgadmin4/issues/10254>`_ - Fix the Object Explorer shortcut not moving keyboard focus into the tree.
|`Issue #10273 <https://github.com/pgadmin-org/pgadmin4/issues/10273>`_ - Fix the dashboard transactions per second graph being wrong for refresh rates other than one second.
|`Issue #10292 <https://github.com/pgadmin-org/pgadmin4/issues/10292>`_ - Fix Schema Diff scripts that convert a column to or from SERIAL.
|`Issue #10301 <https://github.com/pgadmin-org/pgadmin4/issues/10301>`_ - Drop the scaffolding default partition after Schema Diff rebuilds a partitioned table.
|`Issue #10310 <https://github.com/pgadmin-org/pgadmin4/issues/10310>`_ - Fix the Statistics node failing to list its children in Object Explorer.
|`Issue #10334 <https://github.com/pgadmin-org/pgadmin4/issues/10334>`_ - Add Caddy reverse proxy configuration examples to the documentation.
|`Issue #10347 <https://github.com/pgadmin-org/pgadmin4/issues/10347>`_ - Fix the selected UI language not persisting beyond the request that set it in server mode.
|`Issue #10348 <https://github.com/pgadmin-org/pgadmin4/issues/10348>`_ - Fix OpenAI streaming tool calls being split into two entries in the AI Assistant.
|`Issue #10349 <https://github.com/pgadmin-org/pgadmin4/issues/10349>`_ - Fix the OAuth2 userinfo request crashing when ``OAUTH2_USERINFO_ENDPOINT`` is set to None.
|`Issue #10350 <https://github.com/pgadmin-org/pgadmin4/issues/10350>`_ - Fix the MAINTAIN privilege being silently dropped for views and materialised views on PostgreSQL 17 and later.
|`Issue #10357 <https://github.com/pgadmin-org/pgadmin4/issues/10357>`_ - Fix the cursor jumping to the start of SQL fields in dialogs when keys are pressed together.
|`Issue #10364 <https://github.com/pgadmin-org/pgadmin4/issues/10364>`_ - Explain Docker Compose quoting for ``PGADMIN_CONFIG_*`` values in the container documentation.
|`Issue #10386 <https://github.com/pgadmin-org/pgadmin4/issues/10386>`_ - Stop the Query Tool spinner, and its idle CPU use, whilst a password prompt is open.
|`Issue #10401 <https://github.com/pgadmin-org/pgadmin4/issues/10401>`_ - Show default privileges in the Database properties panel.
|`Issue #10409 <https://github.com/pgadmin-org/pgadmin4/issues/10409>`_ - Include the authenticated user in the access log on logout.
|`Issue #10414 <https://github.com/pgadmin-org/pgadmin4/issues/10414>`_ - Fix the Linux packages shipping a stray copy of Python as ``venv/bin/python``, which stopped the DEB package installing on Ubuntu 26.04.
|`Issue #10415 <https://github.com/pgadmin-org/pgadmin4/issues/10415>`_ - Prefer the user's email address as the Gravatar identifier.
|`Issue #10418 <https://github.com/pgadmin-org/pgadmin4/issues/10418>`_ - Remove the dead ``usr`` parameter from Query Tool connection URLs.
|`Issue #10419 <https://github.com/pgadmin-org/pgadmin4/issues/10419>`_ - Fix Kerberos login failures returning a JSON 500 response, and first-time automatically created Kerberos logins being rejected.
|`Issue #10424 <https://github.com/pgadmin-org/pgadmin4/issues/10424>`_ - Fix Reset Layout dropping Query Tool tabs from the saved application state.
|`Issue #10428 <https://github.com/pgadmin-org/pgadmin4/issues/10428>`_ - Fix connections crashing on Windows with an access violation, and the utilities failing to find ``gssapi64.dll``, by staging the Kerberos runtime next to libpq.
|`Issue #10446 <https://github.com/pgadmin-org/pgadmin4/issues/10446>`_ - Fix Schema Diff reporting identical tables as different when a foreign key's referenced table has a dropped column.
|`Issue #10449 <https://github.com/pgadmin-org/pgadmin4/issues/10449>`_ - Honour the password file in the Change Server Password dialog, rather than requiring the current password.
|`Issue #10462 <https://github.com/pgadmin-org/pgadmin4/issues/10462>`_ - Fix duplicated content when opening large non-UTF-8 files in the Query Tool.
|`Issue #10463 <https://github.com/pgadmin-org/pgadmin4/issues/10463>`_ - Pass the target database, and every supported connection parameter, to the utilities, so a database set in a service file no longer overrides the one selected.
|`Issue #10470 <https://github.com/pgadmin-org/pgadmin4/issues/10470>`_ - Remove inherited columns from the Columns tab when their parent table is removed after reopening a table.
|`Issue #10482 <https://github.com/pgadmin-org/pgadmin4/issues/10482>`_ - Allow saving files through symbolic links in desktop mode, and stop forcing 0600 permissions on them there.
|`Issue #10485 <https://github.com/pgadmin-org/pgadmin4/issues/10485>`_ - Fix 'Cannot have two HTML5 backends at the same time' errors in dialogs containing a data grid.
|`Issue #10511 <https://github.com/pgadmin-org/pgadmin4/issues/10511>`_ - Fix the Dashboard Logs tab failing on multi-byte characters split across read chunks.
|`Issue #10527 <https://github.com/pgadmin-org/pgadmin4/issues/10527>`_ - Write the cloud deployment wizard's AWS, Azure and Google credentials through to the session store.
|`Issue #10531 <https://github.com/pgadmin-org/pgadmin4/issues/10531>`_ - Fix the Query Tool saving files that were loaded as latin-1 back as UTF-8.
|`Issue #10534 <https://github.com/pgadmin-org/pgadmin4/issues/10534>`_ - Fix the hardcoded table OID in the column properties query, which broke inherited column details.
|`Issue #10547 <https://github.com/pgadmin-org/pgadmin4/issues/10547>`_ - Fix valid escapes below U+0100 being rewritten in SQL_ASCII data and names.
|`Issue #10548 <https://github.com/pgadmin-org/pgadmin4/issues/10548>`_ - Fix negative identity column options being dropped from the generated SQL.
|`Issue #10549 <https://github.com/pgadmin-org/pgadmin4/issues/10549>`_ - Fix Schema Diff not removing a column comment that exists only in the target.
|`Issue #10550 <https://github.com/pgadmin-org/pgadmin4/issues/10550>`_ - Fix changing an identity column's START value also restarting its sequence.
|`Issue #10551 <https://github.com/pgadmin-org/pgadmin4/issues/10551>`_ - Fix the cursor jumping to the start of SQL fields in dialogs after a quick edit and undo.
|`Issue #10554 <https://github.com/pgadmin-org/pgadmin4/issues/10554>`_ - Fix column properties reporting an arbitrary parent for columns merged from several parents.
|`Issue #10573 <https://github.com/pgadmin-org/pgadmin4/issues/10573>`_ - Fix Query Tool state save/restore failing on installations with a legacy ``SECRET_KEY``.
|`Issue #10597 <https://github.com/pgadmin-org/pgadmin4/issues/10597>`_ - Fix Schema Diff treating a function whose input argument was renamed as two unrelated functions.
|`Issue #10598 <https://github.com/pgadmin-org/pgadmin4/issues/10598>`_ - Keep the Query Tool execute options when its transaction is re-created.
|`Issue #10600 <https://github.com/pgadmin-org/pgadmin4/issues/10600>`_ - Revert the Query Tool execute option checkboxes when changing an option fails.
|`Issue #10603 <https://github.com/pgadmin-org/pgadmin4/issues/10603>`_ - Fix the Query Tool not re-running the query after reconnecting at a password prompt.
|`Issue #10604 <https://github.com/pgadmin-org/pgadmin4/issues/10604>`_ - Report a failed autocomplete reconnect in the Query Tool instead of ignoring it.
|`Issue #10607 <https://github.com/pgadmin-org/pgadmin4/issues/10607>`_ - Fix Query Tool autocomplete objects being shared between sessions that use the same transaction id.
|`Issue #10612 <https://github.com/pgadmin-org/pgadmin4/issues/10612>`_ - Free Query Tool autocomplete objects when an idle session's connections are released.
|`Issue #10613 <https://github.com/pgadmin-org/pgadmin4/issues/10613>`_ - Reconnect a lost Query Tool connection as the user and role it was changed to.
|`Issue #10614 <https://github.com/pgadmin-org/pgadmin4/issues/10614>`_ - Keep external pages out of the desktop runtime's privileged windows and confine non-prompted downloads to the downloads directory.
|`Issue #10618 <https://github.com/pgadmin-org/pgadmin4/issues/10618>`_ - Keep Query Tool execution disabled until a restored tab's execute options reach the server.
|`Issue #10645 <https://github.com/pgadmin-org/pgadmin4/issues/10645>`_ - Fix connection redirection in the materialized view refresh, where the target database's name was passed straight to psql's ``--dbname``: because libpq expands a value containing an equals sign into a full connection string, a database named like ``host=... port=...``, which any role with only CREATEDB can create, redirected psql to a server of the creator's choosing and leaked the connecting user's saved password (CVE-2026-116252). The name is now passed through the same connection-information helper as the Backup, Restore, Maintenance and Import/Export tools, so libpq treats it strictly as a database name. Reported by Bae Seung-won (@baeseungwon1010).
|`Issue #10646 <https://github.com/pgadmin-org/pgadmin4/issues/10646>`_ - Fix a user's other sessions surviving a password change, which left a session an attacker already held valid after the password was changed or reset (CVE-2026-116251). A self-service change now rotates the user's ``fs_uniquifier``, logging out every other session while the changing session stays signed in with its database connections, and saved passwords, including those of shared servers, are re-encrypted under the new key, with any that cannot be decrypted cleared, and all of them cleared if re-encryption fails. An administrator reset (including ``setup.py update-user``) also rotates ``fs_uniquifier``, ending all of that user's sessions, but does not re-encrypt their saved passwords. Reported by Zohaib.
|`Issue #10647 <https://github.com/pgadmin-org/pgadmin4/issues/10647>`_ - Fix time-of-check to time-of-use symlink races in Import/Export Servers and the File Manager's upload, save and file-open paths, where a path validated against the user's storage directory was then opened by name, so a symbolic link swapped in at the file or any parent directory in between let an export overwrite, or an import read, a file outside the storage directory (CVE-2026-116250). In server mode on POSIX the file is now opened by walking down from the storage directory one component at a time with ``O_NOFOLLOW``; new files are created with mode 0600 and only regular files are read, opened non-blocking so a FIFO cannot hang the request. Symbolic links inside a storage directory are now refused, even when they point elsewhere inside it. Windows and desktop mode are unchanged, so the race remains there. Reported by MUTED CYBERSECURITY S.L.
|`Issue #10648 <https://github.com/pgadmin-org/pgadmin4/issues/10648>`_ - Fix the same symlink races in the files handed to Backup, Restore and Import/Export Data, where the validated path was passed to pg_dump, pg_dumpall, pg_restore or psql, which opened it by name later, extending the fix for the vulnerability tracked in Issue #10647. In server mode on POSIX pgAdmin now opens the file itself with the descriptor-relative ``O_NOFOLLOW`` walk, accepts only a regular file and hands the utility ``/dev/fd/N``, and new backup and export files are created with mode 0600. This applies on Linux only, and not to directory-format Backup and Restore, where the utility opens the files inside the directory by name; on macOS and the BSDs the file is checked safely but the utility still reopens it by name.
|`Issue #10649 <https://github.com/pgadmin-org/pgadmin4/issues/10649>`_ - Refuse hard links and FIFOs in the symlink-race fixes of Issue #10647 and Issue #10648, since ``O_NOFOLLOW`` stops only symbolic links: in server mode on POSIX, a file with more than one link is refused, for reading as well as before it is truncated for writing, and a FIFO can no longer hang an upload, save or export; Windows is not covered. The Import/Export Servers, save, Backup, Restore and Import/Export Data handlers also return the reason for a failure instead of text containing the absolute storage path.
|`Issue #10650 <https://github.com/pgadmin-org/pgadmin4/issues/10650>`_ - Fix connection file paths escaping the user's storage directory, where a password file, SSL certificate, key or CRL, or SSH tunnel identity file such as ``../<another user>/.pgpass`` was joined to the storage directory after stripping only leading slashes, so a user could connect with another user's credentials or key, and where ``sslkeylogfile`` was copied into the connection string unchanged, letting libpq create or append a file as the pgAdmin service account (CVE-2026-116249). In server mode the resolved path, with symbolic links followed, must now stay inside the user's storage directory or the named shared storage directory, and ``sslkeylogfile`` is confined the same way. Connection parameter keys are also validated against the libpq keyword list, since a crafted key could smuggle its own ``keyword=value`` pairs past these checks. Servers whose file paths reach outside the storage directory, through ``..`` or a symbolic link, stop connecting until the files are moved into it or into a ``SHARED_STORAGE`` directory.
|`Issue #10651 <https://github.com/pgadmin-org/pgadmin4/issues/10651>`_ - Fix any user able to register a server being able to share it with every other user in server mode, because the create and update endpoints stored whatever ``shared`` value they were sent, and the servers.json import removed a non-administrator's shared entries only after validation, so an early error left them in place (CVE-2026-116248). Such requests are now refused with a 403, and the import prunes and skips a non-administrator's shared entries before validating. The restriction is not retroactive: servers shared by non-administrators before the upgrade stay shared until the owner un-shares them or an administrator clears the ``shared`` column in the configuration database, and pgAdmin only logs a warning about each of them at startup, so administrators should review those servers and un-share any that should not be shared. ``Shared`` in a servers.json import must now be the JSON value ``true``, and a file that fails validation is refused as a whole.
|`Issue #10652 <https://github.com/pgadmin-org/pgadmin4/issues/10652>`_ - Stop libpq using the pgAdmin service account's own client files in server mode: when a server had no password file, client certificate or client key configured, libpq fell back to the service account's ``~/.pgpass``, ``~/.postgresql/postgresql.crt`` and ``~/.postgresql/postgresql.key`` (or ``PGPASSFILE``, ``PGSSLCERT`` and ``PGSSLKEY``), letting any user connect with the service account's saved passwords or client certificate (CVE-2026-116247). In server mode each of these parameters is now always given a value, the user's own file or a path that can never be opened, in the connection string and for the utilities. Server mode deployments relying on the service account's files, or on ``passfile``, ``sslcert`` or ``sslkey`` in a connection service entry, must configure them on each server instead, and a server with a client certificate must also have a client key.
|`Issue #10653 <https://github.com/pgadmin-org/pgadmin4/issues/10653>`_ - Fix server passwords being left in pgAdmin's own process environment, where the password handed to psql and to the utilities was written into ``os.environ`` and never removed, so another user's later psql session or utility inherited it and libpq used it for any connection made without a password of its own (CVE-2026-116246). The password now goes only into the environment of the process that needs it.
|`Issue #10654 <https://github.com/pgadmin-org/pgadmin4/issues/10654>`_ - Restrict connection services in server mode, where any user could set a server's service, or a ``service`` connection parameter, to an entry in the service account's connection service file and connect with that entry's credentials, and where a ``PGPASSWORD`` or ``PGSERVICE`` variable set for that account was used for every connection (CVE-2026-116245). A service may now only be used if the administrator lists it in the new ``SERVER_MODE_ALLOWED_SERVICES`` setting, which is empty by default; the ``servicefile`` parameter is refused, and ``PGPASSWORD`` and ``PGSERVICE`` are removed from pgAdmin's environment at startup. ``setup.py load-servers`` also refuses an unlisted service. In server mode, servers that use a service stop connecting until the administrator lists it in ``SERVER_MODE_ALLOWED_SERVICES`` in ``config_local.py``, ideally before upgrading. Listing a service shares its credentials with every pgAdmin user, so list only services intended for everyone.
|`Issue #10655 <https://github.com/pgadmin-org/pgadmin4/issues/10655>`_ - Stop libpq using the pgAdmin service account's Kerberos credentials in server mode, where removing ``KRB5CCNAME`` let the Kerberos library fall back to the service account's default credential cache or client keytab (or its logon, through SSPI on Windows), so any user could answer a server's GSSAPI request as the service account's principal (CVE-2026-116244). In server mode ``KRB5CCNAME`` and ``KRB5_CLIENT_KTNAME`` now point at a file that cannot exist, except that a user who logged in with Kerberos gets their own delegated ``KRB5CCNAME`` when connecting to a server with Kerberos authentication enabled. Every other connection also gets ``gssencmode=disable`` and, with libpq 16 or later, ``require_auth=!gss,!sspi``; without ``require_auth``, SSPI and gssproxy are not stopped, so upgrade the libpq used by pgAdmin and its utilities to 16 or later. A server whose ``require_auth`` allows only GSSAPI or SSPI is refused unless the user logged in with Kerberos. A service listed in ``SERVER_MODE_ALLOWED_SERVICES`` takes precedence for psql and the utilities, so it must not set ``require_auth``, ``gssencmode`` or ``gsslib``.
|`Issue #10656 <https://github.com/pgadmin-org/pgadmin4/issues/10656>`_ - Fix the utility binary-path guard accepting a path under ``SHARED_STORAGE``, which ordinary users can write to, so a user could point a server type's binary path at a file placed there and have pgAdmin execute it (CVE-2026-116243). Every ``SHARED_STORAGE`` entry is now rejected alongside ``STORAGE_DIR``, after expanding ``$DIR`` and ``..`` and resolving symbolic links, so an existing binary path under a shared storage directory stops working. An empty ``STORAGE_DIR`` is not covered. Reported by Harsh Singh.
|`Issue #10662 <https://github.com/pgadmin-org/pgadmin4/issues/10662>`_ - Stop SSH tunnels using the pgAdmin service account's own SSH identity, where sshtunnel always loaded the unencrypted private keys in the service account's ``~/.ssh``, read its ``~/.ssh/config`` and could use its ssh-agent, so any user could configure a tunnel that authenticated with the service account's key (CVE-2026-116242). In server mode the tunnel no longer reads those keys or that configuration, or uses the agent, and an identity file that cannot be found in the user's storage is reported. Tunnels relying on the service account's key, config or agent must have an identity file in the user's storage or a password configured on the server.
|`Issue #10663 <https://github.com/pgadmin-org/pgadmin4/issues/10663>`_ - Fix OS command injection in the Import/Export Data tool, where a query-based export could pass a crafted query past the ``\copy (...)`` parenthesis-balance guard through a backslash inside a double-quoted identifier, which psql treats as an escape when ``standard_conforming_strings`` is off, exposing a live ``TO PROGRAM`` clause that runs an arbitrary command on the pgAdmin host, an incomplete fix for CVE-2026-17566 (CVE-2026-116241). A backslash inside a double-quoted identifier is now rejected, and so is a backslash or null byte in the schema, table or column names of an export or import, which reached the same ``\copy`` command through ``qtIdent()``. Reported by Yonghwa Lee (Xint by Theori).
|`Issue #10674 <https://github.com/pgadmin-org/pgadmin4/issues/10674>`_ - Fix the File Manager and the storage-path tools acting on a different path from the one that was checked (CVE-2026-116240): opening, saving and listing files, download, delete, rename, create-folder and upload could name files outside the user's storage directory, or in another user's directory whose name begins with the same characters. Every File Manager and storage-path caller now acts on the absolute path that was checked, and in server mode on POSIX files are opened relative to the storage directory without following symbolic links, so symbolic links to directories and hard-linked files inside it can no longer be opened, downloaded or renamed through. The storage directory itself can no longer be deleted.
|`Issue #10675 <https://github.com/pgadmin-org/pgadmin4/issues/10675>`_ - Fix LDAP and Kerberos authentication logging a user in to a different account that shared their username: Kerberos and LDAP looked the account up by username alone, or accepted the account Flask-Security's internal validation had matched by the email typed, so a Kerberos or LDAP user could be logged in as an Internal account, including the initial administrator, or as another user (CVE-2026-116239). Accounts are now looked up by the authenticated username and the source that authenticated them, and only internal authentication logs in an account from its own form. Webserver users whose username matches an Internal account, and LDAP users with capitals in their username, now log in correctly, and ``setup.py`` can update a user without a role and add one without ``--role``. Users who relied on being logged in to an Internal account of the same name now need an account of their own source. Reported by Yves Soete of Blacksight LLC (@yssoe).
|`Issue #10676 <https://github.com/pgadmin-org/pgadmin4/issues/10676>`_ - Fix bypasses of the failed-login limit and of pgAdmin's own login, password change and password reset checks, where a username in another case or a wrong internal password with LDAP or Webserver authentication also configured was never counted, and where Flask-Security's own ``/login``, ``/verify``, ``/change``, ``/reset`` and ``/reset/<token>`` views accepted submissions that skipped the failed-login count and lock, the change password permission, the re-encryption of saved passwords, and the internal-account check on resets (CVE-2026-116238). Every failed internal login is now counted (when ``MAX_LOGIN_ATTEMPTS`` is set) and only internal authentication is locked, so a locked Internal account no longer blocks other sources. A login form that posts a password is no longer passed to Kerberos, OAuth2 or Webserver authentication, submissions to Flask-Security's views are refused with 405, and password resets act only on the internal account.
To delete the user, invoke ``setup.py`` with ``delete-user`` command line option, followed by
username and auth_source. For Internal users, email adress will be used instead of username. the option ``--yes`` is available to skip the confirmation prompt before deleting the user.
username and auth_source. For Internal users, email address will be used instead of username. the option ``--yes`` is available to skip the confirmation prompt before deleting the user.
stores `as_dict()` in session, keeps live `ServerManager` instances in module-global state) is
the template.
**Per-site refactor (verified by grep):**
| Site (file:line) | Current | Replacement |
|---|---|---|
| `web/pgadmin/authenticate/__init__.py:113` | `session['auth_obj'] = AuthSourceManager(...)` (live object) | Store source name; reconstruct `AuthSourceManager` in OAuth2 callback (`oauth2.py:55`) |
| `web/pgadmin/misc/cloud/azure/__init__.py:89` | `session['azure']['azure_obj'] = Azure(...)` (live object) | Store `authentication_record_json` + tenant/auth_type/cache_name; rebuild `Azure` per request via process-local cache keyed by sid. **See §5.4 for an unverified-claims caveat.** |
| `web/pgadmin/tools/sqleditor/__init__.py` (lines 338, 632, 699, 740 write `session['gridData']`; the per-trans `command_obj` field holds `pickle.dumps(transaction, -1)` produced in `tools/sqleditor/utils/start_running_query.py:204-208`, where the local `session` parameter is later persisted via `update_session_grid_transaction`) | serialized `Command` bytes inside a session-resident dict | Store command kind + identifying inputs; rebuild `Command` via `from_state()`; cache PG metadata in process-local TTL cache. **High risk — see §5.4.** |
**Process-local cache pattern (illustrative):**
```python
# Module-level
_azure_cache:dict[str,tuple[float,Azure]]={}
_AZURE_CACHE_TTL=600# seconds
defget_azure_for_session(sid:str)->Azure:
now=time.monotonic()
cached=_azure_cache.get(sid)
ifcachedand(now-cached[0])<_AZURE_CACHE_TTL:
returncached[1]
state=session['azure']# JSON-safe dict only
obj=Azure.from_state(state)# new constructor
_azure_cache[sid]=(now,obj)
returnobj
```
Eviction: lazy on read (TTL check) and on logout (`_azure_cache.pop(sid, None)`).
**Worker-restart UX trade-off (Azure).** Today's behavior pickles the live `Azure` (with its
populated SDK client cache) into the session file, so a worker restart preserves Azure auth
state. After Phase 2, the in-memory client cache is gone on restart. Whether the user is
re-prompted for device-code on the next request depends on whether
`authentication_record_json` is sufficient to silently rebuild the credential. **This is
unverified** (see §5.4) and must be confirmed during the Azure refactor PR before merge. If
re-prompting is unavoidable, that is a UX regression — Phase 1 ships independently and is not
| LOC changed | Source: ~80–110 (session HMAC logic ~50–80, file_manager realpath/O_NOFOLLOW/coverage ~30). Tests: ~250–400 (10 regression tests in §4.3.1, ~25–40 LOC each plus shared fixtures). **Total ~350–500.** |
| Behavioural change | Sessions written by old code become unreadable → users re-login once on upgrade. One transient warning logged per rejected sid. |
| Risk | **Low.** Self-contained, well-bounded, easy to review. |
### 5.2 Phase 2 — verified site counts
Per-feature breakdown (all numbers verified by grep):
| SQL editor — `command_obj` producer | `tools/sqleditor/utils/start_running_query.py:204-208` | 1 (`pickle.dumps` at line 207, into a per-trans dict later persisted via `update_session_grid_transaction`) | 0 | **High (verification gated)** | See §5.4 item 4. |
| SQL editor — Flask-session reads/writes | `tools/sqleditor/__init__.py` | gridData writes at lines 338, 632, 699, 740 | 1 (`pickle.loads` at line 1935) | **High (verification gated)** | The per-trans dict containing `command_obj` lives inside `session['gridData']`. Refactor: store command kind + identifying inputs; rebuild `Command` via `from_state()`; cache PG metadata in process-local TTL cache. |
| Final swap (`session.py` to JSON) | `utils/session.py` | n/a | n/a | Low | Trivial once Phase 2 lands. |
**Total source files touched (Phase 1 + Phase 2):** 8 (files), 2 of which are big files
echo"'${PGADMIN_DEFAULT_EMAIL}' does not appear to be a valid email address. Please reset the PGADMIN_DEFAULT_EMAIL environment variable and try again."
echo"Validation output: ${is_valid_email}"
exit1
fi
# Switch back to root directory for further process
Loaded 100 of 2340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.