fix(config): Datenbank-Passwort mit Sonderzeichen bricht den Start

Die Verbindungszeichenkette wurde mit fmt.Sprintf zusammengeklebt. Ein
Passwort mit % und @ ergab eine kaputte URL:

  parse "postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/aether":
  invalid URL escape "%Co"

Der Server kam nicht über die Migrationen hinaus. Die key=value-Form hätte
dasselbe Problem mit einem Leerzeichen gehabt, nur stiller.

config.DSN() liefert jetzt eine postgres://-URL, gebaut mit net/url und
net.JoinHostPort (klammert IPv6). pgxpool und golang-migrate nehmen sie
beide unverändert – dsnToURL, splitDSN, indexByte und orDefault in pkg/db
sind ersatzlos entfallen. Test deckt %, @, :, /, Leerzeichen und IPv6 ab.

Dazu: DB_HOST und DB_PORT fehlten in der schlanken .env.example, weshalb
localhost statt postgres gegriffen hat. APP_ENV ergänzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 11:20:01 +02:00
1 parent 16ab6f385b
commit 35ebf609b7
5 files changed
+153 -71

No files matched your search

+31 -4
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
@@ -218,11 +219,37 @@ func Load() (*Config, error) {
return cfg, nil
}
// DSN liefert eine `postgres://`-URL – nicht die `key=value`-Form.
//
// Vorher wurde hier mit fmt.Sprintf zusammengeklebt, und beide Formate haben
// Sonderzeichen, die dabei kaputtgehen:
//
// - In der URL-Form beenden `@` und `:` den Anmeldeteil, und `%` leitet eine
// Maskierung ein. Ein Passwort wie `8UoNj%CoWJ7ze@qy` ergab
// `postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/...` und damit
// „invalid URL escape %Co". Der Server kam nicht über die Migrationen
// hinaus – gemessen bei der ersten Einrichtung.
// - In der key=value-Form trennt das Leerzeichen die Paare. Ein Passwort mit
// Leerzeichen wäre dort genauso kaputtgegangen, nur stiller: Der Rest
// hinter dem Leerzeichen wäre als eigener Schlüssel gelesen worden.
//
// Beides erledigt jetzt die Standardbibliothek. `url.UserPassword` maskiert,
// was maskiert werden muss, und `net.JoinHostPort` klammert eine IPv6-Adresse.
// pgxpool und golang-migrate nehmen beide diese URL unverändert an – die
// Umrechnung von Hand in pkg/db ist damit ersatzlos weggefallen.
//
// Ein Passwort darf danach jedes Zeichen enthalten. Das ist keine
// Bequemlichkeit: Wer erst herausfinden muss, welche Zeichen erlaubt sind,
// wählt am Ende ein schwächeres.
func (c *Config) DSN() string {
return fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword,
)
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(c.DBUser, c.DBPassword),
Host: net.JoinHostPort(c.DBHost, c.DBPort),
Path: "/" + c.DBName,
RawQuery: "sslmode=disable",
}
return u.String()
}
func (c *Config) IsDev() bool {
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"net/url"
"testing"
)
func parseDSN(s string) (*url.URL, error) { return url.Parse(s) }
// Ein Passwort darf jedes Zeichen enthalten. Die Fälle hier sind nicht
// ausgedacht: `8UoNj%CoWJ7ze@qy` stammt aus einer echten Einrichtung und
// legte den Server lahm – „invalid URL escape %Co", noch vor den Migrationen.
func TestDSNEscapesPassword(t *testing.T) {
cases := []struct {
name string
pw string
}{
{"prozent und at", "8UoNj%CoWJ7ze@qy"},
{"doppelpunkt", "gehei:mnis"},
{"schraegstrich", "a/b?c#d"},
{"leerzeichen", "mit leer zeichen"},
{"nur sonderzeichen", "%%@@::////"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &Config{
DBHost: "postgres", DBPort: "5432", DBName: "aether",
DBUser: "aether", DBPassword: tc.pw,
}
got := c.DSN()
// Die Probe ist nicht der erzeugte Text, sondern ob er sich
// wieder auseinandernehmen lässt – genau das tun pgx und
// golang-migrate mit ihm.
u, err := parseDSN(got)
if err != nil {
t.Fatalf("DSN nicht parsebar: %v (%s)", err, got)
}
if pw, _ := u.User.Password(); pw != tc.pw {
t.Errorf("Passwort kam als %q zurück, erwartet %q", pw, tc.pw)
}
if u.User.Username() != "aether" {
t.Errorf("Nutzer %q", u.User.Username())
}
if u.Host != "postgres:5432" {
t.Errorf("Host %q", u.Host)
}
if u.Path != "/aether" {
t.Errorf("Datenbank %q", u.Path)
}
})
}
}
// IPv6 muss geklammert werden, sonst frisst der Doppelpunkt den Port.
func TestDSNIPv6(t *testing.T) {
c := &Config{DBHost: "::1", DBPort: "5432", DBName: "aether", DBUser: "u"}
u, err := parseDSN(c.DSN())
if err != nil {
t.Fatalf("nicht parsebar: %v", err)
}
if u.Hostname() != "::1" || u.Port() != "5432" {
t.Errorf("Host %q Port %q", u.Hostname(), u.Port())
}
}