f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
266 lines
6.5 KiB
Go
266 lines
6.5 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// RedactDSN returns the host portion of the DSN for diagnostics,
|
|
// stripping credentials, query parameters, and path components
|
|
// that may contain secrets.
|
|
func RedactDSN(dsn string) string {
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
// CheckSSL returns an error when the PG connection string targets
|
|
// a non-loopback host without TLS encryption. It uses the pgx
|
|
// driver's own DSN parser to resolve the effective host and TLS
|
|
// configuration, avoiding bypasses from exotic DSN formats.
|
|
//
|
|
// A connection is rejected when any path in the TLS negotiation
|
|
// chain (primary + fallbacks) permits plaintext for a non-loopback
|
|
// host. This rejects sslmode=disable, allow, and prefer.
|
|
func CheckSSL(dsn string) error {
|
|
cfg, err := pgconn.ParseConfig(dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing pg connection string: %w", err)
|
|
}
|
|
if isLoopback(cfg.Host) {
|
|
return nil
|
|
}
|
|
if hasPlaintextPath(cfg) {
|
|
return fmt.Errorf(
|
|
"pg connection to %s permits plaintext; "+
|
|
"set sslmode=require (or verify-full) "+
|
|
"for non-local hosts, "+
|
|
"or set allow_insecure = true under [pg] or [pg.NAME] "+
|
|
"in config to override",
|
|
cfg.Host,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WarnInsecureSSL logs a warning when the PG connection string
|
|
// targets a non-loopback host without TLS encryption. Uses the
|
|
// pgx driver's DSN parser for accurate host/TLS resolution.
|
|
func WarnInsecureSSL(dsn string) {
|
|
cfg, err := pgconn.ParseConfig(dsn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if isLoopback(cfg.Host) {
|
|
return
|
|
}
|
|
if hasPlaintextPath(cfg) {
|
|
log.Printf(
|
|
"warning: pg connection to %s permits "+
|
|
"plaintext; consider sslmode=require or "+
|
|
"verify-full for non-local hosts",
|
|
cfg.Host,
|
|
)
|
|
}
|
|
}
|
|
|
|
// hasPlaintextPath returns true if any path in the pgconn
|
|
// connection chain (primary config + fallbacks) has TLS disabled.
|
|
// This catches sslmode=disable (no TLS), sslmode=allow (plaintext
|
|
// first, TLS fallback), and sslmode=prefer (TLS first, plaintext
|
|
// fallback).
|
|
func hasPlaintextPath(cfg *pgconn.Config) bool {
|
|
if cfg.TLSConfig == nil {
|
|
return true
|
|
}
|
|
for _, fb := range cfg.Fallbacks {
|
|
if fb.TLSConfig == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isLoopback returns true if host is a loopback address,
|
|
// localhost, a unix socket path, or empty (defaults to local
|
|
// connection).
|
|
func isLoopback(host string) bool {
|
|
if host == "" || host == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip != nil && ip.IsLoopback() {
|
|
return true
|
|
}
|
|
// Unix socket paths start with /
|
|
if len(host) > 0 && host[0] == '/' {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validIdentifier matches simple SQL identifiers (letters,
|
|
// digits, underscores). Used to reject schema names that could
|
|
// enable SQL injection.
|
|
var validIdentifier = regexp.MustCompile(
|
|
`^[a-zA-Z_][a-zA-Z0-9_]*$`,
|
|
)
|
|
|
|
// quoteIdentifier double-quotes a SQL identifier, escaping any
|
|
// embedded double quotes. Rejects empty or non-identifier strings
|
|
// to prevent injection.
|
|
func quoteIdentifier(name string) (string, error) {
|
|
if name == "" {
|
|
return "", fmt.Errorf(
|
|
"schema name must not be empty",
|
|
)
|
|
}
|
|
if !validIdentifier.MatchString(name) {
|
|
return "", fmt.Errorf(
|
|
"invalid schema name: %q", name,
|
|
)
|
|
}
|
|
return `"` + name + `"`, nil
|
|
}
|
|
|
|
// Open opens a PG connection pool, validates SSL, and sets
|
|
// search_path to the given schema on every connection.
|
|
//
|
|
// The schema name is validated and quoted to prevent injection.
|
|
// When allowInsecure is true, non-loopback connections without
|
|
// TLS produce a warning instead of failing.
|
|
func Open(
|
|
dsn, schema string, allowInsecure bool,
|
|
) (*sql.DB, error) {
|
|
if dsn == "" {
|
|
return nil, fmt.Errorf("postgres URL is required")
|
|
}
|
|
quoted, err := quoteIdentifier(schema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid pg schema: %w", err)
|
|
}
|
|
|
|
if allowInsecure {
|
|
WarnInsecureSSL(dsn)
|
|
} else if err := CheckSSL(dsn); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Append search_path and timezone as runtime parameters in
|
|
// the DSN so every connection in the pool inherits them.
|
|
// pgx's stdlib driver passes options through to ConnConfig.
|
|
connStr, err := appendConnParams(dsn, map[string]string{
|
|
"search_path": quoted,
|
|
"TimeZone": "UTC",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"setting connection params: %w", err,
|
|
)
|
|
}
|
|
|
|
db, err := sql.Open("pgx", connStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"opening pg (host=%s): %w",
|
|
RedactDSN(dsn), err,
|
|
)
|
|
}
|
|
db.SetMaxOpenConns(5)
|
|
db.SetMaxIdleConns(5)
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
|
|
|
ctx, cancel := context.WithTimeout(
|
|
context.Background(), 10*time.Second,
|
|
)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf(
|
|
"pg ping (host=%s): %w",
|
|
RedactDSN(dsn), err,
|
|
)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func pgTargetFingerprint(dsn, schema string) (string, error) {
|
|
if dsn == "" {
|
|
return "", fmt.Errorf("postgres URL is required")
|
|
}
|
|
cfg, err := pgconn.ParseConfig(dsn)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parsing pg connection string: %w", err)
|
|
}
|
|
|
|
fields := []string{
|
|
"v1",
|
|
cfg.Database,
|
|
cfg.User,
|
|
schema,
|
|
}
|
|
appendTargetEndpoint := func(host string, port uint16) {
|
|
if host != "" && !strings.HasPrefix(host, "/") {
|
|
host = strings.ToLower(host)
|
|
}
|
|
fields = append(fields, host, fmt.Sprintf("%d", port))
|
|
}
|
|
appendTargetEndpoint(cfg.Host, cfg.Port)
|
|
for _, fallback := range cfg.Fallbacks {
|
|
appendTargetEndpoint(fallback.Host, fallback.Port)
|
|
}
|
|
h := sha256.New()
|
|
for _, field := range fields {
|
|
fmt.Fprintf(h, "%d:%s", len(field), field)
|
|
}
|
|
return "v1:" + hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
// appendConnParams injects key=value connection parameters into
|
|
// the DSN. For URI-style DSNs it adds query parameters; for
|
|
// key=value DSNs it appends key=value pairs.
|
|
func appendConnParams(
|
|
dsn string, params map[string]string,
|
|
) (string, error) {
|
|
// URI format: postgres://...
|
|
if strings.HasPrefix(dsn, "postgres://") ||
|
|
strings.HasPrefix(dsn, "postgresql://") {
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"parsing pg URI: %w", err,
|
|
)
|
|
}
|
|
q := u.Query()
|
|
for k, v := range params {
|
|
q.Set(k, v)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|
|
// Key=value format: append parameters.
|
|
result := dsn
|
|
for k, v := range params {
|
|
if result != "" {
|
|
result += " "
|
|
}
|
|
result += k + "=" + v
|
|
}
|
|
return result, nil
|
|
}
|