Skip to content

fix(import/d1): keep the destination password off psql's argv - #1486

Open
e-akashsaini wants to merge 1 commit into
planetscale:mainfrom
e-akashsaini:fix/d1-psql-password-argv
Open

e-akashsaini wants to merge 1 commit into
planetscale:mainfrom
e-akashsaini:fix/d1-psql-password-argv

Conversation

@e-akashsaini

Copy link
Copy Markdown

Why?

runPsqlFile passes the full destination connection string, password included, as psql's first argument:

cmd := execabs.CommandContext(ctx, psqlPath, destURI, "-v", "ON_ERROR_STOP=1", "--single-transaction", "-f", path)

Process arguments are world-readable (ps, /proc/<pid>/cmdline on Linux, ps on macOS), so any other user on the machine can read the password while each psql runs. pscale import d1 runs it up to three times per import (tables, indexes, foreign keys). By default the credential is the role ResolveDestURI creates: it inherits postgres and is valid for 2 hours, so seeing it once is enough to use it long after the import finishes. Shared hosts (CI runners, bastions, shared dev boxes) are where this matters.

The rest of the importer already keeps the password out of sight: the pgloader control file is written 0600, and pscale shell passes the Postgres password through PGPASSWORD. This is the one call that puts it on the command line.

How?

splitConnPassword removes the password from the connection string and returns it separately. It handles both forms libpq accepts:

  • keyword/value strings, which is what BuildConnectionString produces, with libpq quoting and escaping rules
  • postgres:// / postgresql:// URIs, either in the userinfo part or as a ?password= parameter

runPsqlFile then passes the stripped string to psql and sets PGPASSWORD in the child's environment. The environment can only be read by the same user or root.

Checked with a fake psql on PATH. Before: password=... was in the arguments. After:

ARGV: host=h port=5432 user=u dbname=postgres sslmode=verify-full sslrootcert=system -v ON_ERROR_STOP=1 --single-transaction -f /dev/null
PGPASSWORD=TOPSECRETpw

The tests parse the original and stripped strings with pgconn.ParseConfig (with PGPASSWORD set) and check that both resolve to the same host, port, user, database and password, so the stripped string connects the same way.

Decisions

  • PGPASSWORD over a temporary .pgpass file. A pgpass file would avoid the environment too, but it adds a temp file to create, set to 0600 and clean up on every exit path, and that file would sit on disk for the whole run. pscale shell already uses PGPASSWORD, so this matches existing practice.
  • Strip in runPsqlFile rather than changing ResolveDestURI. The same string goes to pgx (in-process, so it's fine) and to pgloader (written to a 0600 control file). Only the psql child needed changing, so the diff stays at that boundary.

Follow-up

postgres.quoteValue (internal/postgres/postgres.go:184) escapes ' but not \. libpq treats backslash as an escape inside quoted values, so a password containing \ gets silently altered (a\secret becomes asecret). That bug is older than this change and is better as its own PR. Generated role passwords probably never contain a backslash, but quoteValue is used for every option value.

🤖 Generated with Claude Code

runPsqlFile passed the full connection string, password included, as
psql's first argument, exposing the 2-hour import role credential to
any local user via ps. Strip the password from the keyword/value or
URI connection string and pass it to psql via PGPASSWORD instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@e-akashsaini
e-akashsaini requested a review from a team as a code owner October 23, 2026 00:14

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant