Skip to content

fix(selfhost): hide Postgres backup credentials - #2459

Merged
JSONbored merged 6 commits into
mainfrom
codex/propose-fix-for-postgres-backup-vulnerability
Jul 2, 2026
Merged

fix(selfhost): hide Postgres backup credentials#2459
JSONbored merged 6 commits into
mainfrom
codex/propose-fix-for-postgres-backup-vulnerability

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Motivation

  • A Postgres backup path passed the full database URL (which can include the password) to pg_dump, exposing credentials via process argv; this change prevents that leak while keeping backup functionality.
  • The goal was to preserve the existing Postgres and SQLite backup behaviors but avoid placing secret data in command-line arguments.

Description

  • Parse the configured Postgres URL in scripts/backup.sh and export libpq connection pieces (PGHOST, PGPORT, PGDATABASE, PGUSER) instead of passing the full URL on pg_dump argv.
  • When a password is present, create a temporary, 600-permission PGPASSFILE, populate it with an escaped entry, export PGPASSFILE, and register a trap to securely remove the file on exit.
  • Replace the vulnerable pg_dump ... "$PG_DB" invocation with prepare_pg_env + pg_dump -Fc -f ... so pg_dump runs with env-based credentials only.
  • Add a regression unit test test/unit/selfhost-backup-script.test.ts that fakes pg_dump, captures its argv/env, and asserts that neither the Postgres URL nor the password appear in process arguments while existing Postgres and SQLite backup behavior remains unchanged.
  • Modified files: scripts/backup.sh and test/unit/selfhost-backup-script.test.ts.

Testing

  • sh -n scripts/backup.sh returned successfully with no syntax errors.
  • Targeted unit tests were run with npx vitest run test/unit/selfhost-backup-script.test.ts and passed (3 tests), including the new regression that verifies credentials are not passed on pg_dump argv.
  • git diff --check passed locally.
  • Full local gate npm run test:ci could not complete in this environment due to network/actionlint setup issues (WASM fallback reported the repo's custom self-hosted runner label), and npm audit --audit-level=moderate returned 403 Forbidden from the npm audit endpoint.

Codex Task

@loopover-orb

loopover-orb Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Warning

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏸️ Gittensory review result - manual review recommended

Review updated: 2026-07-02 06:39:48 UTC

2 files · 1 AI reviewer · no blockers · readiness 68/100 · CI pending · blocked

⏸️ Suggested Action - Manual Review

  • Touches a guarded path — held for manual review

Review summary
The change correctly moves Postgres passwords out of the `pg_dump` argv for the covered userinfo and lowercase query-parameter forms, while preserving SQLite backup behavior and adding meaningful regression coverage around URL preservation. The most notable implementation choice is to keep a sanitized libpq URI for non-secret connection details and supply the extracted password through a temporary 0600 passfile, which is a reasonable fit for libpq. I do not see a reachable break in the provided post-change files, but the hand-rolled URI manipulation is subtle enough that a couple of edge cases deserve tightening.

Nits — 7 non-blocking
  • nit: `scripts/backup.sh:130` only removes the first `password=` query parameter, so duplicate password parameters would leave later copies in `PG_SANITIZED_URL`; either strip all occurrences or explicitly reject duplicates.
  • nit: `scripts/backup.sh:130` matches only lowercase `password=`, so confirm libpq will not accept differently-cased password keywords in URI query parameters or normalize/removal should handle them.
  • nit: `scripts/backup.sh:53` says `url_decode` only decodes userinfo, but the function is now also used for query-string passwords; update the comment or split the wording so future readers do not copy userinfo-specific assumptions into query handling.
  • nit: `test/unit/selfhost-backup-script.test.ts:119` reads the captured argv through `cat`; direct `readFileSync` would keep the test portable inside Node without depending on an external command.
  • Add a regression in `test/unit/selfhost-backup-script.test.ts` for duplicate password query parameters so `scripts/backup.sh:130` cannot accidentally leave a later secret on argv.
  • Readiness score is below the configured threshold — Use the readiness panel as advisory maintainer context; the score does not block this PR.
  • Touches a guarded path — held for manual review — A maintainer must review and merge this change.
Signal Result Evidence
Code review ✅ No blockers 1 reviewer
Linked issue ⚠️ Missing No linked issue or no-issue rationale found.
Related work ⚠️ 2 scoped overlaps Top overlaps are listed below; lower-confidence bulk is hidden.
Change scope ❌ 8/20 High review scope from cached public metadata (size label size:L; no linked issue context).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 65 registered-repo PR(s), 55 merged, 548 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 65 PR(s), 548 issue(s).
Gate result ⚠️ Not blocking Advisory; not blocking this PR.
Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository registration is not available in the local Gittensory cache.
  • Public profile languages: not available
  • Official Gittensor activity: 65 PR(s), 548 issue(s).
  • Related work: Titles/paths share 7 meaningful terms. (PR #2477, PR #2478)
  • Related work: Titles/paths share 6 meaningful terms. (PR #2470, PR #2473)
Contributor next steps
  • Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Explain no-issue PR.
  • Review top overlaps.
  • Add a concise scope and risk note.
  • Triage stale or unlinked PRs.
  • No action.
  • Link the issue being solved, or explicitly explain why this is a no-issue PR.
  • Check active issues and PRs before submitting.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.

  • Re-run Gittensory review

@loopover-orb loopover-orb Bot added gittensor gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. labels Jul 2, 2026
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.82%. Comparing base (b0c2c32) to head (3aac594).
⚠️ Report is 86 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2459   +/-   ##
=======================================
  Coverage   95.82%   95.82%           
=======================================
  Files         224      224           
  Lines       24975    24975           
  Branches     9076     9076           
=======================================
  Hits        23933    23933           
  Misses        428      428           
  Partials      614      614           
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

JSONbored added 2 commits July 1, 2026 22:41
…p URL

prepare_pg_env manually re-parsed the Postgres URL into PGHOST/PGPORT/
PGDATABASE, discarding the query string entirely before ever looking at it.
Any DATABASE_URL that relies on it -- e.g. `postgresql:///gittensory?host=
/var/run/postgresql` (connecting over a Unix socket at a non-default path,
the authority left empty on purpose) -- silently stopped connecting the way
the original `pg_dump "$PG_DB"` call did.

Re-implementing libpq's own URI parsing in shell (host/port/dbname, every
query parameter, precedence rules between them) is exactly the kind of thing
that's easy to get wrong piecemeal, as this already showed. Instead: strip
ONLY the password from the URI's userinfo section and hand pg_dump the rest
untouched -- host, port, dbname, and the full query string, byte for byte --
as its connection argument, so libpq's own parser resolves it exactly as it
always has. The password never touches pg_dump's argv/`ps` output; it reaches
pg_dump out-of-band via a PGPASSFILE. That file wildcards host/port/dbname/
user (`*:*:*:*:<password>`) rather than trying to match them exactly: it's a
600-permission temp file scoped to the one connection this script makes and
deleted immediately after (see the existing `cleanup` trap), so there's no
value in re-deriving the exact host/port/dbname libpq will resolve just to
match them -- the query string can override those anyway.

Also fixed url_decode: it decoded '+' as a space, which is only correct for
application/x-www-form-urlencoded query values, not a URI's userinfo
component, where '+' is an ordinary allowed character. A password containing
a literal '+' would have been silently corrupted to a space. Found via the
same review pass that caught the query-string regression.

Updated the existing "does not pass credentials" test: its `not.toContain(
"postgresql://")` assertion no longer holds (a password-free connection
string on argv isn't a credential leak -- that assertion conflated "the
scheme string appears" with "a secret appears," which is what pushed the
original fix toward manual re-parsing in the first place). Replaced it with
assertions on the actual security property (no password substring on argv)
plus a check that the sanitized URL correctly reaches pg_dump. Added two new
regressions: a query-string-only connection URL (the exact form from the
review) round-trips into pg_dump's argv unchanged, and a password containing
a literal '+' survives as '+', not a space.
pgpass is a single-line-per-entry format, and pgpass_escape only escapes the
two characters (':' and '\') that format itself treats specially. A decoded
password containing a raw newline or carriage return would still split the
entry across lines, corrupting the field layout -- refuse outright (exit 1)
rather than silently write a malformed PGPASSFILE. Found via the same review
pass that caught the query-string regression, since a newline is a valid
(if unusual) percent-encoded byte a URI userinfo password can carry.

First attempt at the check was itself broken: `case "$X" in *"$(printf
'\n')"*)` looks reasonable but command substitution strips ALL trailing
newlines, so `$(printf '\n')` evaluates to an empty string and the pattern
matches literally every string -- caught immediately by the existing
regression tests (the query-string and '+'-password tests both failed
against every normal password once this shipped, before this commit ever
reached CI). Fixed with the standard `$(printf '\nx'); ${VAR%x}` idiom:
appending a marker byte means there's nothing trailing for the command
substitution to strip, then the marker itself is stripped by parameter
expansion, leaving exactly one newline/CR character to match against.

Also added a test asserting the PGPASSFILE is actually created with 0600
permissions (the existing test only asserted the path pattern, not the mode,
despite the PR's own description depending on it) and a regression for the
newline-rejection behavior itself.
…hority

prepare_pg_env searched for the first '@' across the ENTIRE remaining URL
string, including the query string. Userinfo (user:password@) can only
appear in the authority component -- everything before the first '/', '?',
or '#' -- never later in the URI. A URL with no real credentials at all but
a literal '@'/':' inside a query VALUE, e.g.
`postgresql://db.example/gittensory?application_name=a:b@worker`, got
misread: everything before that '@' was wrongly treated as userinfo, ':b'
was stripped out as a fake password, and the corrupted URL
`postgresql://db.example/gittensory?application_name=a@worker` (missing the
":b") was what actually reached pg_dump.

Fixed by finding the authority boundary FIRST -- the substring before
whichever of '/', '?', '#' occurs earliest (computed via three %% removals,
keeping the shortest result, since the earliest delimiter produces the
shortest "before" substring) -- and restricting the '@'/':' userinfo search
to within that authority substring only. Everything from the boundary
onward (path, query, fragment) is carried through untouched and reattached
verbatim, so it can never be mistaken for credentials regardless of what
it contains.

Verified against the exact URL from the review (confirmed it round-trips
unchanged with no passfile created), every prior regression case (still
passing), and a new combined case: a REAL password alongside a query string
that separately contains its own '@'/':' -- both are now handled correctly
in the same URL. Reverting just this fix reproduces the exact corrupted
output the reviewer predicted, confirming the new tests actually catch it.
…orm too

libpq connection URIs support supplying `password` as a query-string
parameter, not just via userinfo -- `postgresql://user@host/db?password=secret`
is an equally valid, if less common, connection string. The previous fix
only ever stripped a userinfo password and passed the entire query string
through untouched, so this form still leaked the password verbatim into
pg_dump's argv/`ps` output.

Split the URI's suffix (everything from the authority boundary onward) into
its path / query / fragment components and, if the query component has a
`password` key, extract it (url-decoded, same as a userinfo password) and
remove it, leaving every other parameter -- and their order -- untouched.
Whichever form (userinfo or query-string) actually carried a password now
reaches the same PGPASSFILE mechanism; pg_dump's argv never sees either.

Verified against the exact URL form from the review, a password positioned
first/middle/last among other query parameters, a password immediately
before a URI fragment, a percent-encoded password value, and the negative
case (a parameter whose VALUE merely contains the substring "password", e.g.
`application_name=has_password_in_name`, must not be misidentified as the
key) -- alongside every prior regression case, all still passing. Reverting
just this change reproduces the exact leak the review flagged.
…word

The prior fix stripped only the first `password=` occurrence in the query
string. A malformed URL repeating the key -- e.g.
`postgresql://u@h/db?password=one&sslmode=require&password=two` -- isn't
rejected by libpq's own parser, so the second occurrence survived untouched
into $PG_SANITIZED_URL, still leaking a credential into pg_dump's argv
regardless of which one libpq would actually authenticate with.

Loop the extraction until no `password=` remains in the query string instead
of stripping once. Each iteration overwrites PGPASSWORD_VALUE, so the LAST
occurrence is what ends up in the PGPASSFILE -- which one libpq itself would
use for a duplicate key is unspecified, but every occurrence is a credential
either way, so none may reach argv.

Verified against the exact duplicate-key URL from the review, a 3-occurrence
case, and every prior regression case (still passing). Reverting just this
change reproduces the exact residual leak the review flagged.
@JSONbored
JSONbored merged commit 92fba2f into main Jul 2, 2026
6 checks passed
@JSONbored
JSONbored deleted the codex/propose-fix-for-postgres-backup-vulnerability branch July 2, 2026 06:50
@github-project-automation github-project-automation Bot moved this from Todo to Done in gittensory - v1 roadmap Jul 2, 2026
JSONbored added a commit that referenced this pull request Jul 2, 2026
…word

The AI review on #2459 (scripts/backup.sh) found that the query-string
password-stripping there only removed the FIRST `password=` occurrence, so a
malformed URL repeating the key (not rejected by libpq's own parser) left a
second occurrence sitting in argv, still a leaked credential. This script's
pg_connect_arg has the identical query-string-password logic, so it carries
the same gap -- ported the identical fix: loop the extraction until no
`password=` remains rather than stripping once. Each iteration overwrites
pg_password_value, so the LAST occurrence is what ends up in the PGPASSFILE;
which one libpq itself would use for a duplicate key is unspecified, but
every occurrence is a credential either way, so none may reach argv.

Also added a full-scratch-restore-flow test for the userinfo-password form
(user:password@host) -- the existing multi-connection flow test only proved
the query-string form end to end, per a non-blocking nit from the same
review round asking for both forms to be exercised through the complete
flow, not just the isolated single-URL cases already covered.

Verified against the duplicate-key case and every prior regression case
(still passing). Reverting just the loop fix reproduces the exact residual
leak.
JSONbored added a commit that referenced this pull request Jul 2, 2026
… argv (#2512)

* fix(selfhost): stop leaking Postgres credentials via verify-backup.sh argv

scripts/backup.sh (a prior fix) had the same class of vulnerability this
script still carried: db_identity(), the scratch pg_restore --dbname call,
and the post-restore sanity psql call all passed a full postgres(ql):// URL
-- potentially including a password, via userinfo OR the libpq
`password=...` query-string form -- directly as a process argument,
exposing it via `ps`/`/proc/PID/cmdline` to any other user on the same host.

Ported the same sanitization approach from backup.sh (see that file for the
full URI-parsing rationale): strip only the password -- from userinfo,
restricted to the authority component so a literal '@'/':' in the query
string is never mistaken for credentials, or from a `password=` query
parameter -- and hand back everything else (host, port, dbname, every other
query parameter) untouched as the connection argument, with the password
supplied out-of-band via a temporary, 600-permission, wildcarded PGPASSFILE.

Unlike backup.sh, this script may connect to TWO different URLs in the same
run (the live source and a scratch database) via db_identity(), so the
shared logic here (pg_connect_arg) takes the URL as an argument instead of
reading a single script-global $PG_DB, tracks every passfile it creates in a
list for cleanup (rather than backup.sh's single $PGPASSFILE_CREATED), and
always unsets PGPASSFILE before checking the current URL for a password --
otherwise a PREVIOUS call's password could leak into a connection for a URL
that doesn't have one of its own (e.g. a passwordless live source checked
between two scratch-database connections).

Not extracted into a shared sourced helper file: docker-compose.yml's
`backup` service bind-mounts backup.sh and verify-backup.sh as individual
files at the container root (./scripts/backup.sh:/backup.sh:ro, similarly for
verify-backup.sh), not the whole scripts/ directory, so a shared file would
need its own new mount entry kept in sync by hand -- more deployment
coupling than the ~90 duplicated lines it would save.

Updated every existing test that mocks psql by exact URL match: the real
script now calls psql/pg_restore with a SANITIZED (password-free) URL, not
the raw one, so fakePsql's identity map must be keyed on the sanitized form
-- added a sanitizedUrl() test helper mirroring the shell logic exactly (a
test that could still pass keyed on the raw URL would not actually verify
the credential never reaches argv). Added a new test exercising the full
scratch-restore flow (4 psql/pg_restore calls plus the initial structural
pg_restore --list) with a password supplied via the query-string form on one
URL and no password on the other, asserting: no password ever appears in any
captured argv, and the passwordless URL's connection never inherits a
PGPASSFILE left over from the other URL's.

* fix(selfhost): strip every occurrence of a repeated query-string password

The AI review on #2459 (scripts/backup.sh) found that the query-string
password-stripping there only removed the FIRST `password=` occurrence, so a
malformed URL repeating the key (not rejected by libpq's own parser) left a
second occurrence sitting in argv, still a leaked credential. This script's
pg_connect_arg has the identical query-string-password logic, so it carries
the same gap -- ported the identical fix: loop the extraction until no
`password=` remains rather than stripping once. Each iteration overwrites
pg_password_value, so the LAST occurrence is what ends up in the PGPASSFILE;
which one libpq itself would use for a duplicate key is unspecified, but
every occurrence is a credential either way, so none may reach argv.

Also added a full-scratch-restore-flow test for the userinfo-password form
(user:password@host) -- the existing multi-connection flow test only proved
the query-string form end to end, per a non-blocking nit from the same
review round asking for both forms to be exercised through the complete
flow, not just the isolated single-URL cases already covered.

Verified against the duplicate-key case and every prior regression case
(still passing). Reverting just the loop fix reproduces the exact residual
leak.

* fix(selfhost): strip a query-string password even with an encoded key name

The AI review on the sibling PR #2519 (scripts/backup.sh) found that the
query-string password-stripping there only matched the LITERAL string
"password=", so a percent-encoded key name like `pass%77ord=secret` (%77
decodes to 'w', and libpq percent-decodes query key names before matching
them against connection keywords) still leaked a real credential into argv.
This script's pg_connect_arg has the identical logic, so it carries the
same gap -- ported the identical fix: walk each '&'-separated query pair
individually, decode only the key half of each, compare the decoded key
against "password", and rebuild the query from every pair whose decoded key
isn't a match, in original order, with values left percent-encoded exactly
as given.

Added a matching regression test through the full scratch-restore flow.

Verified against the exact encoded-key case and every prior regression case
(still passing). Reverting just this change reproduces the exact leak.

* fix(selfhost): call pg_connect_arg in the parent shell for identity checks

db_identity() is invoked via command substitution ($(db_identity ...)),
which forks a subshell -- calling pg_connect_arg from inside its body
meant the PG_PASSFILES cleanup-list append never propagated back to the
parent, orphaning a real, credential-bearing 600-permission passfile on
disk for every identity check that needed one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

No open projects
Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant