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