Repository navigation
fix(sec): pass the migrate password via PGPASSWORD and quote every keyword DSN value - #1033
Conversation
…yword DSN value Closes LeanerCloud#557. The shell migration paths used two percent-encoders for the same secret: jq (ci.yml, database-migration.yml, DEPLOYMENT.md) and awk (scripts/entrypoint.sh, whose runtime image has no jq). The awk one silently drops a newline, so it and jq disagreed on such a password. Every shell site now leaves the password out of the pgx5:// URL and passes it in PGPASSWORD, which pgx reads when the URL has none. No shell encoder remains, the password needs no encoding whatever it contains, it no longer appears in migrate's argv, and database-migration.yml no longer needs to ::add-mask:: a derived (encoded) form of the secret. internal/database/config.go: the keyword/value DSN now runs host, user, dbname and sslmode through the same libpq quoting as the password, so a space or quote in any field cannot break the DSN or inject a keyword such as sslmode=disable. Go URL builders already use url.UserPassword. Tests: hostile-value round trips through pgconn.ParseConfig for every DSN field; a guard that every shell migrate call takes the password from PGPASSWORD and no shell URL carries one (replaces the jq/mask guards); an integration test that sets the role password to hostile values on a testcontainers Postgres and connects through golang-migrate's pgx5 driver with PGPASSWORD and through Config.DSN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independent review verdict: CLEAN at 74dc9b2Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, acting as a security reviewer. The PR body was treated as a claim. Coordinator: claude-cc-reviewer. Blocking findings: none. DSN quoting (
The tests round-trip each value through Injection: the passwords The PGPASSWORD path:
Deploy impact (an ordinary code merge, no infra hold):
Note (non-blocking, already present before this PR): What was attacked and how:
Gate:
|
|
Gate record for 74dc9b2 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at 74dc9b2 with --match-head-commit. |
|
Gate record for 74dc9b2 (origin/main CLAUDE.md "Review gate"):
Merging at 74dc9b2 with --match-head-commit. |
#1317) * feat(frontend): add analytics trends API client and series shaping GET /api/analytics/trends has served the savings-snapshot history since the backend work for #1023/#1033, but the frontend had no client for it. Add a typed getAnalyticsTrends() mirroring TrendsResponse, plus pure helpers that bucket the (month, account, provider, service) rows into one point per calendar month, narrow rows to the provider chip, and keep missing months and missing coverage as null rather than a fabricated 0. Refs #30 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(frontend): add a Trends sub-tab to Inventory & Coverage Users could not see savings or coverage over time. The new /inventory/trends sub-tab renders the monthly savings run-rate as a single-series bar chart with a matching table (coverage per month), stat tiles, and the by-provider and by-service breakdowns. The account chip is forwarded as account_id so the backend's allowed_accounts scope applies, and a chip change reloads the sub-tab like the other Inventory views. Everything is built with textContent (no innerHTML) and the period and refresh controls are bound once per element. 503 and the scoped-user "account_id is required" refusal get actionable inline copy. Refs #30 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(frontend): cover the Trends view in jest and Playwright Unit tests pin the month bucketing, null gaps, provider narrowing, XSS safety, stale-response handling and listener dedup. The Playwright spec checks the sub-tab routing, that the chart canvas is actually laid out, and the 503 message; /inventory/trends joins the page-overflow sweep. Refs #30 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #557
#588 fixed most of the sites in this issue. What it left open was the "two encoders" question:
scripts/entrypoint.shpercent-encoded the password with awk because the runtime image has no jq, and everything else usedjq -sRr @uri. This PR picks a third option, so neither encoder is needed. Every shell site leaves the password out of thepgx5://URL and passes it inPGPASSWORD. golang-migrate's pgx5 driver opens the URL through pgx, and pgx readsPGPASSWORDwhen the URL has no password. Go builders keepurl.UserPassword(URL form) andquoteDSNValue(keyword/value form), and the keyword DSN now quotes every field, not just the password.Per-site changes (re-verified on main at d7eb3b0)
.github/workflows/ci.ymlmigrate stepjq -sRr @uri→pgx5://user:${ENCODED_PASSWORD}@…PGPASSWORD="$DB_PASSWORD" migrate … pgx5://user@….github/workflows/database-migration.yml(AWS run + version, GCP, Azure: 4 steps)jq -sRr @uri, an empty-encode check, and::add-mask::of the encoded form, thenpgx5://cudly:${ENCODED_PASSWORD}@…env: PGPASSWORD: ${{ secrets.DB_PASSWORD_* }}, URLpgx5://cudly@…. No derived secret is left to mask.docs/DEPLOYMENT.md(both migrate commands)jq -sRr @uri+ encoded URLPGPASSWORD="$DB_PASSWORD" migrate … pgx5://cudly@…, and the note now explains whyscripts/entrypoint.shPGPASSWORD="$DB_PASSWORD" migrate …, set only on the migrate command, not exported to the appinternal/database/config.gokeyword DSNpasswordquoted;host/user/dbname/sslmoderaw, so e.g.DB_USER="x sslmode=disable"injected a keywordquoteDSNValue(libpq rules: single-quote, escape\and'). Plain values render the same as beforeinternal/testutil/postgres.go,migrations/migrate.gourl.URL{User: url.UserPassword(…)}Side effects: the password no longer shows up in
migrate's argv (ps//proc/*/cmdline). Themigrate version 2>&1output that goes into$GITHUB_ENValso can't contain it, because the URL no longer has it.Verification
All Go tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null.Keyword DSN unit tests (
internal/database/config_dsn_quoting_test.go): hostile values@ : / %41 # ? space tab+newline ' \ trailing-\ = unicode empty, plusx sslmode=disableand a quoted-injection variant. Each goes through the password and through user/dbname, andpgconn.ParseConfigmust return the exact value, withsslmode=requirestill in place (TLSConfig non-nil) and no stray runtime params.RedactedDSNnever contains the password.config.go, 6 subtests fail (space, tab/newline, trailing backslash, keyword injection, quoted injection, mixed).User(7 failures), onDatabase(7), on the password (10), dropping the'escape, dropping the\escape.Shell guard (
TestShellMigrationURLs_PassPasswordViaPGPASSWORD, which replaces the jq/mask guards): it covers all 4 files. Nopgx5://userinfo may contain:, and noENCODED_PASSWORDor@urimay remain. Workflows are parsed with yaml.v3, and everymigrate -pathline has to run either in a step whose env setsPGPASSWORDor with thePGPASSWORD="$DB_PASSWORD"prefix. The guard also refuses to pass if it finds no invocations at all.:${DB_PASSWORD}back into a docs URL, renaming the GCP step env back toDB_PASSWORD.Integration test on testcontainers Postgres 16 (
-tags integration,TestPGPasswordAndKeywordDSN_ConnectWithHostilePassword): sets the role password (scram) to 5 hostile values. For each one it authenticates through golang-migrate's pgx5 driver (password-less URL plusPGPASSWORD) and throughpgx.Connect(cfg.DSN(pw)). A wrongPGPASSWORDis rejected, and that error doesn't echo the password.The real
scripts/entrypoint.sh, run by hand inalpine:3.24.1with a linuxmigratebuilt-tags pgx5from go.mod and a Postgres 16 container, each case on a fresh database:@ : / %41 # ? ='+"= [ ] $ &The password never appeared in container output.
go build ./...,go vet ./..., andgo vet -tags integration ./...pass.go testpasses on./internal/database/,./internal/database/postgres/migrations/and./internal/mocks/;-tags integrationpasses on./internal/database/and./internal/testutil/.golangci-lint v2.10.1 reports 0 issues on
./internal/database/..., with and without--build-tags integration.gocyclo -over 10 internal/database/config.gois clean.actionlintandzizmor 1.29.0 --offline --persona=pedantic --min-severity=mediumare clean on both workflows.shellcheck scripts/entrypoint.shandmarkdownlint-cli 0.47.0 docs/DEPLOYMENT.mdare clean.git-secrets 1.3.0 in a fresh
git initrepo with an isolated HOME and the patterns fromsetup-git-secrets.shfinds nothing in the changed files; a positive control is caught.scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.Not verified: the edited
database-migration.ymljobs against real AWS/GCP/Azure databases. Those need the real secrets and environment gates. The same pattern (password-lesspgx5://URL plusPGPASSWORD) is what CI's migrate step and the entrypoint run above exercise.Out of scope
docs/DEVELOPMENT.mdlocal docker-compose URLs, which embed the fixedcudly_local_devdev password.terraform/*/outputs.tfpsqlhints, which carry no password.known_issues/has no entry for sec(ci): other raw database-password interpolations and two DSN encoders after #555 #557.🤖 Generated with Claude Code
Summary by CodeRabbit