Skip to content

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

Merged
JSONbored merged 1 commit into
mainfrom
fix/backup-script-encoded-password-key
Jul 2, 2026
Merged

fix(selfhost): strip a query-string password even with an encoded key name#2519
JSONbored merged 1 commit into
mainfrom
fix/backup-script-encoded-password-key

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

  • Follow-up finding on fix(selfhost): hide Postgres backup credentials #2459's own review: libpq percent-decodes query key names, not just values, before matching them against connection keywords — so pass%77ord=secret (%77 = w) is just as much a password parameter as the literal spelling, and libpq will authenticate with it. The previous fix's literal string match against &password= never saw this: the raw text is pass%77ord=, not password=, so it survived untouched into $PG_SANITIZED_URL, still a real credential in pg_dump's argv.
  • Replaced the literal-match loop with one that walks each &-separated query pair individually, decodes only the key half of each, and compares that against password — rebuilding the query from every pair whose decoded key isn't a match, in original order, with values left percent-encoded exactly as given (they're not being re-parsed, just handed to libpq, which decodes them itself). A repeated key is handled the same way as before: each match overwrites the stored password, so the last occurrence wins.

Scope

  • The PR title follows type(scope): short summary Conventional Commit format.
  • This PR is focused: one script + its test file, no product code changes.
  • This follows CONTRIBUTING.md.
  • No issue is linked — this is a direct follow-up to fix(selfhost): hide Postgres backup credentials #2459's own review findings, not a pre-filed bug.

Validation

  • git diff --check
  • npm run actionlint
  • npm run typecheck
  • npm run test:coverage — full unsharded run green on Node 22.23.1 (matching CI's pinned .nvmrc): 321 passed / 2 skipped, 6092 tests passed, 0 failures.
  • npm run test:workers
  • npm run build:mcp
  • npm run test:mcp-pack
  • npm run ui:openapi:check
  • npm run ui:lint
  • npm run ui:typecheck
  • npm run ui:test
  • npm run ui:build
  • npm audit --audit-level=moderate
  • Verified against the exact encoded-key case (pass%77ord=secret) from the review, plus every prior regression case from fix(selfhost): hide Postgres backup credentials #2459 (duplicate keys, userinfo password, query-string-only host, + in password, fake @/: in a query value, percent-encoded values, the substring-only negative case) — all still passing via a standalone shell harness before wiring into the vitest suite.
  • Reverting just this change reproduces the exact leak, confirming the new test actually exercises the behavior change.

If any required check was skipped, explain why:

  • N/A — everything ran.

Safety

  • No secrets, wallet details, hotkeys, coldkeys, user PATs, private keys, raw trust scores, private rankings, or private maintainer evidence are exposed.
  • Public GitHub text stays sanitized, low-noise, and does not imply compensation guarantees or optimization tactics.
  • N/A — no auth, cookie, CORS, GitHub App, Cloudflare, or session changes.
  • N/A — no API/OpenAPI/MCP behavior change.
  • N/A — no UI code changes.
  • N/A — no visible UI change.
  • N/A — no docs/changelog changes needed.

Notes

… name

libpq percent-decodes query KEY NAMES before matching them against
connection keywords, not just values -- so `pass%77ord=secret` (%77 = 'w')
is just as much a `password` parameter as the literal spelling, and libpq
will authenticate with it. A literal string match against "&password=" (the
previous approach) never saw it: the raw text is "pass%77ord=", not
"password=", so it sailed through untouched into $PG_SANITIZED_URL, still a
real credential in pg_dump's argv.

Replaced the literal-match loop with one that walks each '&'-separated query
pair individually, decodes ONLY the key half of each, and compares that
against "password" -- rebuilding the query from every pair whose decoded key
isn't a match, in original order, with values left percent-encoded exactly
as given (they're not being re-parsed, just handed to libpq, which decodes
them itself). A repeated key is handled the same way as before: each match
overwrites the stored password, so the last occurrence wins.

Verified against the exact encoded-key case from the review, alongside every
prior regression case (still passing). Reverting just this change reproduces
the exact leak.
@dosubot dosubot Bot added the size:M label Jul 2, 2026
JSONbored added a commit that referenced this pull request Jul 2, 2026
… 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.
@JSONbored JSONbored self-assigned this Jul 2, 2026
@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 07:19:15 UTC

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

⏸️ Suggested Action - Manual Review

  • Touches a guarded path — held for manual review

Review summary
The change correctly moves password-query stripping from a literal substring match to pair-wise query parsing with percent-decoded key comparison, which covers the encoded-key credential leak while preserving non-password query pairs and values. The new regression test exercises the reachable libpq-style encoded key path and verifies both argv sanitization and passfile delivery. I do not see a must-fix defect in the visible diff.

Nits — 3 non-blocking
  • nit: scripts/backup.sh:143 drops empty query pairs while rebuilding the query, so a URL containing intentional empty segments like `?a=1&&b=2` is normalized even though the surrounding comments promise every non-password parameter is left untouched.
  • scripts/backup.sh:143: If byte-for-byte preservation of non-password query separators matters, rebuild by tracking separators instead of skipping empty `pg_pair` entries; otherwise update the comment to say empty query segments are discarded.
  • 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 ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (size label size:M; 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).
  • PR-specific overlap: none found.
Contributor next steps
  • Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Explain no-issue PR.
  • Triage stale or unlinked PRs.
  • No action.
  • Link the issue being solved, or explicitly explain why this is a no-issue PR.
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
@JSONbored
JSONbored merged commit fda621d into main Jul 2, 2026
10 checks passed
@JSONbored
JSONbored deleted the fix/backup-script-encoded-password-key branch July 2, 2026 07:24
@github-project-automation github-project-automation Bot moved this from Todo to Done in gittensory - v1 roadmap 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.95%. Comparing base (8b64bf3) to head (0f68f91).
⚠️ Report is 31 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2519      +/-   ##
==========================================
+ Coverage   95.94%   95.95%   +0.01%     
==========================================
  Files         226      226              
  Lines       25361    25425      +64     
  Branches     9229     9244      +15     
==========================================
+ Hits        24333    24397      +64     
  Misses        417      417              
  Partials      611      611              
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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