Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,10 @@ jobs:
DB_PASSWORD: test_password # CI-only throwaway password — not used in any real environment
run: |
if [ -d "internal/database/postgres/migrations" ]; then
# Percent-encode the password: it may contain @ : / ? # % or spaces.
ENCODED_PASSWORD=$(printf '%s' "$DB_PASSWORD" | jq -sRr @uri)
migrate -path internal/database/postgres/migrations \
-database "pgx5://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" \
-database "pgx5://${DB_USER}:${ENCODED_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" \
up
fi

Expand Down
13 changes: 10 additions & 3 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,13 +666,18 @@ If `DB_AUTO_MIGRATE=true` (the default), migration 000011 runs automatically on
> driver by scheme, and `migrate` is built here with `-tags pgx5` so it does not
> link `lib/pq` (issue #1849). A `postgres://` URL fails with
> `unknown driver postgres`.
>
> The password is percent-encoded with `jq -sRr @uri` because it may contain
> `@`, `:`, `/`, `%`, `#`, `?` or spaces, which break the URL when left raw.

```bash
DB_PASSWORD=$(aws secretsmanager get-secret-value --secret-id cudly-dev-db-password-* --query SecretString --output text | jq -r .password)
RDS_ENDPOINT=$(cd terraform/environments/aws && terraform output -raw database_proxy_endpoint)

ENCODED_PASSWORD=$(printf '%s' "$DB_PASSWORD" | jq -sRr @uri)

migrate -path internal/database/postgres/migrations \
-database "pgx5://cudly:${DB_PASSWORD}@${RDS_ENDPOINT}:5432/cudly?sslmode=require" up
-database "pgx5://cudly:${ENCODED_PASSWORD}@${RDS_ENDPOINT}:5432/cudly?sslmode=require" up
```

---
Expand All @@ -682,11 +687,13 @@ migrate -path internal/database/postgres/migrations \
Check: `DB_AUTO_MIGRATE=true`, `DB_MIGRATIONS_PATH=/app/internal/database/postgres/migrations`, correct credentials. Run manually:

```bash
DB_PASSWORD=$(aws secretsmanager get-secret-value --secret-id cudly-dev-db-password-* --query SecretString --output text)
DB_PASSWORD=$(aws secretsmanager get-secret-value --secret-id cudly-dev-db-password-* --query SecretString --output text | jq -r .password)
RDS_ENDPOINT=$(cd terraform/environments/aws && terraform output -raw database_proxy_endpoint)

ENCODED_PASSWORD=$(printf '%s' "$DB_PASSWORD" | jq -sRr @uri)

migrate -path internal/database/postgres/migrations \
-database "pgx5://cudly:${DB_PASSWORD}@${RDS_ENDPOINT}:5432/cudly?sslmode=require" up
-database "pgx5://cudly:${ENCODED_PASSWORD}@${RDS_ENDPOINT}:5432/cudly?sslmode=require" up
```

### Terraform State Lock
Expand Down
14 changes: 13 additions & 1 deletion internal/database/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -145,13 +146,24 @@ func (c *Config) dsn(password string) string {
c.Host,
c.Port,
c.User,
password,
quoteDSNValue(password),
c.Database,
c.SSLMode,
int(c.ConnectTimeout.Seconds()),
)
}

// quoteDSNValue quotes a keyword/value DSN value per libpq rules when it is
// empty or contains whitespace, a single quote or a backslash; otherwise it
// returns the value unchanged.
func quoteDSNValue(v string) string {
if v != "" && !strings.ContainsAny(v, " \t\n\r\f\v'\\") {
return v
}
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`)
return "'" + r.Replace(v) + "'"
}

// DSN generates a PostgreSQL connection string
// If passwordOverride is provided, it's used instead of config.Password.
func (c *Config) DSN(passwordOverride string) string {
Expand Down
31 changes: 31 additions & 0 deletions internal/database/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"
"time"

"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -822,3 +823,33 @@ func TestValidatePoolSettings(t *testing.T) {
})
}
}

// trickyPassword holds every character that breaks an unquoted keyword DSN or
// an unescaped URL userinfo.
const trickyPassword = `p@ss:w/rd%#? "q" 'it\s'`

func TestConfigDSN_PasswordRoundTrip(t *testing.T) {
cfg := &Config{
Host: "localhost", Port: 5432, User: "u", Database: "d",
SSLMode: "disable", ConnectTimeout: 10 * time.Second,
}
for _, pw := range []string{trickyPassword, "has space", "it's", `back\slash`, "", "plain"} {
parsed, err := pgconn.ParseConfig(cfg.DSN(pw))
require.NoError(t, err, "password %q", pw)
assert.Equal(t, pw, parsed.Password)
assert.Equal(t, "u", parsed.User)
assert.Equal(t, "d", parsed.Database)
assert.Equal(t, "localhost", parsed.Host)
}
}

func TestConfigDSN_PasswordQuotingText(t *testing.T) {
cfg := &Config{
Host: "localhost", Port: 5432, User: "u", Database: "d",
SSLMode: "disable", ConnectTimeout: 10 * time.Second,
}
// libpq unescapes a backslash in quoted values only, and pgx does not
// reject an unquoted one, so the round trip alone cannot catch its omission.
assert.Contains(t, cfg.DSN(`back\slash`), ` password='back\\slash' `)
assert.Contains(t, cfg.DSN("it's"), ` password='it\'s' `)
}
12 changes: 10 additions & 2 deletions internal/testutil/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package testutil
import (
"context"
"fmt"
"net"
"net/url"
"testing"
"time"

Expand Down Expand Up @@ -76,8 +78,14 @@ func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContain

// ConnectionString returns a PostgreSQL connection string.
func (pc *PostgresContainer) ConnectionString() string {
return fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable",
pc.Username, pc.Password, pc.Host, pc.Port, pc.Database)
u := url.URL{
Scheme: "postgresql",
User: url.UserPassword(pc.Username, pc.Password),
Host: net.JoinHostPort(pc.Host, pc.Port),
Path: "/" + pc.Database,
RawQuery: "sslmode=disable",
}
return u.String()
}

// Config returns a database configuration for the test container.
Expand Down
34 changes: 34 additions & 0 deletions internal/testutil/postgres_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//go:build integration
// +build integration

package testutil

import (
"net/url"
"testing"

"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConnectionString_PasswordRoundTrip(t *testing.T) {
pw := `p@ss:w/rd%#? "q" 'it\s'`
pc := &PostgresContainer{
Host: "localhost", Port: "5432", Database: "cudly_test",
Username: "cudly_test", Password: pw,
}

u, err := url.Parse(pc.ConnectionString())
require.NoError(t, err)
got, _ := u.User.Password()
assert.Equal(t, pw, got)

cfg, err := pgconn.ParseConfig(pc.ConnectionString())
require.NoError(t, err)
assert.Equal(t, pw, cfg.Password)
assert.Equal(t, "cudly_test", cfg.User)
assert.Equal(t, "localhost", cfg.Host)
assert.Equal(t, uint16(5432), cfg.Port)
assert.Equal(t, "cudly_test", cfg.Database)
}
Loading