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
7 changes: 5 additions & 2 deletions config/weir.reference.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ stores:
# Direct mode isolates one member and provides no failover.
# No userinfo, database path, fragments, SRV, mongos or load balancing.
# Accepted URI options: replicaSet, directConnection, authSource,
# authMechanism=SCRAM-SHA-256, tls=true. Any other option fails.
# authMechanism=SCRAM-SHA-256, tls. Any other option fails.
# Authentication requires the credentials below plus explicit authSource,
# SCRAM and TLS. Member certificates verify each discovered hostname/IP.
# SCRAM and tls. Prefer tls=true. tls=false sends the SCRAM handshake and
# every command in plaintext, so anyone on the path can read or change
# them; use it only on a trusted network path to every member. With
# tls=true, member certificates verify each discovered hostname/IP.
# Example:
# mongodb://mongo-a:27017,mongo-b:27017/?replicaSet=orders&authSource=admin&authMechanism=SCRAM-SHA-256&tls=true
# CA files belong in tls.ca_file; URI tlsCAFile is rejected.
Expand Down
18 changes: 14 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,18 @@ A seed without a port uses 27017. IPv6 seeds use brackets, such as
URI userinfo, database paths, fragments, SRV, mongos/sharding, load balancing,
secondary routing and other options are unsupported. A query requires the `/`
separator before `?`. Accepted options are `replicaSet`, `directConnection`,
`authSource`, `authMechanism=SCRAM-SHA-256` and `tls=true`. Authentication requires the
`authentication` block, explicit SCRAM-SHA-256, `authSource` and TLS; TLS options
require credentials. Custom CAs belong in `tls.ca_file`, not in the URI.
`authSource`, `authMechanism=SCRAM-SHA-256` and `tls`. Authentication requires the
`authentication` block, explicit SCRAM-SHA-256, `authSource` and an explicit
`tls`; `tls` requires credentials. Use `tls=true` unless the backend has no TLS.
With `tls=false` Weir still authenticates with SCRAM-SHA-256, but the handshake
and every command travel in plaintext with nothing binding them together.
Anyone who can see the traffic can read and change commands, guess the password
offline from a captured handshake, or relay the handshake and then use the
authenticated connection with Weir's privileges. Use it only when the network
path to every replica-set member, including members Weir discovers, is trusted,
and give the user a long random password. A NetworkPolicy limits which Pods can
connect, but does not hide traffic from anyone with node or host-network access.
Custom CAs belong in `tls.ca_file`, not in the URI, and require `tls=true`.
Verification uses each actual member's hostname/IP, not the seed's name. OCSP
follows the MongoDB driver: a stapled or responder answer of revoked fails the
connection, while an unreachable responder does not. All
Expand Down Expand Up @@ -116,7 +125,8 @@ confirmation of the same commit separately.

Each username and password comes from exactly one explicit value or file; URI
userinfo is rejected. Files contain UTF-8 text and allow one final LF/CRLF.
Authentication requires TLS to the backend. A custom `tls.ca_file` replaces the
Search authentication requires HTTPS, and MongoDB authentication an explicit
`tls` option (see [MongoDB](#mongodb)). A custom `tls.ca_file` replaces the
system roots while retaining hostname verification.

Backend identity and configuration stay local to each process. The application
Expand Down
4 changes: 2 additions & 2 deletions internal/app/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,9 @@ func TestMongoCredentialMappingAndProfile(t *testing.T) {
if err := cfg.Validate(); err == nil || strings.Contains(err.Error(), "sentinel") {
t.Fatal("URI userinfo must be rejected without credential leakage", err)
}
m.URI = "mongodb://unresolved.invalid:27017/?authSource=admin&authMechanism=SCRAM-SHA-256&tls=false"
m.URI = "mongodb://unresolved.invalid:27017/?authSource=admin&authMechanism=SCRAM-SHA-256"
if err := cfg.Validate(); err == nil || strings.Contains(err.Error(), "sentinel") {
t.Fatal("separate credentials must still enforce the audited SCRAM/TLS profile", err)
t.Fatal("separate credentials must still enforce the audited SCRAM profile with explicit tls", err)
}
}

Expand Down
116 changes: 116 additions & 0 deletions internal/backend/mongodb/secure_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/batchstream/weir/internal/execution"
"github.com/batchstream/weir/internal/testutil/testmongo"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)

func TestMongoSCRAMTLSProductionOpen(t *testing.T) {
Expand Down Expand Up @@ -82,6 +83,121 @@ func TestMongoSCRAMTLSProductionOpen(t *testing.T) {
}
}

// plaintextConfig turns a fixture configuration into SCRAM over tls=false.
func plaintextConfig(t *testing.T, cfg Config) Config {
t.Helper()
parsed, err := url.Parse(cfg.URI)
if err != nil {
t.Fatal("invalid owned MongoDB fixture URI")
}
query := parsed.Query()
query.Set("tls", "false")
parsed.RawQuery = query.Encode()
cfg.URI = parsed.String()
cfg.CAFile = ""
return cfg
}

// With an explicit tls=false, SCRAM-SHA-256 authenticates over plaintext:
// writes and reads work without a TLS handshake, and a wrong password still
// fails authentication.
func TestMongoSCRAMWithoutTLSOpen(t *testing.T) {
fixture := testmongo.OpenSecureAllowingPlaintext(t)
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
defer cancel()
cfg := Config{URI: fixture.URI, Store: "mongo"}
cfg = plaintextConfig(t, mongoFixtureConfig(t, cfg))
before := settledTransportCounts(t, fixture.Admin)
adapter, err := Open(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer adapter.Close(context.Background())
doc := bson.D{{Key: "_id", Value: "plaintext"}, {Key: "n", Value: int64(7)}}
raw, _ := bson.Marshal(doc)
document := &pb.Document{ContentType: "application/bson", Data: raw}
put := &pb.MutateRequest_Put{Put: document}
request := &pb.MutateRequest{Resource: fixture.DB + "/records/s:plaintext", Action: put}
operation := &pb.Command_Mutate{Mutate: request}
command := &pb.Command{Operation: operation}
op := &pb.ExecuteRequest{Index: 1, Command: command}
plan, failure := prepareTestRecord(adapter, op)
if failure != nil {
t.Fatal(failure)
}
result := adapter.ExecuteRecords(ctx, []*execution.Plan{plan})
if result[0].GetMutationResult().GetOutcome() != pb.MutationOutcome_APPLIED {
t.Fatal(result)
}
read := prepareCounter(t, adapter, fixture.DB+"/records/s:plaintext")
result = adapter.ExecuteRecords(ctx, []*execution.Plan{read})
if result[0].GetReadResult().GetDocument() == nil {
t.Fatal(result)
}
after := transportCounts(t, fixture.Admin)
if after.connections <= before.connections || after.handshakes != before.handshakes {
t.Fatalf("tls=false must connect without TLS: connections %d->%d, TLS handshakes %d->%d",
before.connections, after.connections, before.handshakes, after.handshakes)
}
badConfig := Config{URI: fixture.BadPassURI, Store: "mongo"}
badConfig = plaintextConfig(t, mongoFixtureConfig(t, badConfig))
badContext, badCancel := context.WithTimeout(t.Context(), 5*time.Second)
defer badCancel()
bad, err := Open(badContext, badConfig)
if err == nil {
bad.Close(context.Background())
t.Fatal("wrong password authenticated over plaintext")
}
if !strings.Contains(err.Error(), "MongoDB code 18") {
t.Fatal("wrong password over plaintext did not fail authentication", err)
}
}

type transportCount struct {
connections int64
handshakes int64
}

// settledTransportCounts waits until the fixture's own clients stop opening
// monitoring connections, so later changes belong to the code under test.
func settledTransportCounts(t *testing.T, client *mongo.Client) transportCount {
t.Helper()
previous := transportCounts(t, client)
for range 20 {
time.Sleep(250 * time.Millisecond)
current := transportCounts(t, client)
if current == previous {
return current
}
previous = current
}
t.Fatal("fixture connections did not settle")
return previous
}

// transportCounts reads how many connections the server has accepted and how
// many of them negotiated TLS.
func transportCounts(t *testing.T, client *mongo.Client) transportCount {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
command := bson.D{{Key: "serverStatus", Value: 1}}
var status struct {
Connections struct {
TotalCreated int64 `bson:"totalCreated"`
} `bson:"connections"`
TransportSecurity map[string]int64 `bson:"transportSecurity"`
}
if err := client.Database("admin").RunCommand(ctx, command).Decode(&status); err != nil {
t.Fatal(err)
}
count := transportCount{connections: status.Connections.TotalCreated}
for _, handshakes := range status.TransportSecurity {
count.handshakes += handshakes
}
return count
}

func TestMongoSCRAMTLSRepeatedFailureAndClose(t *testing.T) {
fixture := testmongo.OpenSecure(t)
baseline := connectionCount(t, fixture.Admin)
Expand Down
14 changes: 9 additions & 5 deletions internal/backend/mongodb/uri.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ func parseConfig(cfg Config) (*connstring.ConnString, error) {
if err != nil {
return nil, err
}
// parseURI allows TLS only with SCRAM, so this covers both.
if (cfg.Username != "") != uri.SSL {
return nil, errors.New("MongoDB credentials and SCRAM with TLS require each other")
// parseURI allows tls only with SCRAM, so this covers both.
if (cfg.Username != "") != (uri.AuthMechanism != "") {
return nil, errors.New("MongoDB credentials and SCRAM require each other")
}
if cfg.CAFile != "" && !uri.SSL {
return nil, errors.New("MongoDB CA file requires TLS")
Expand Down Expand Up @@ -62,9 +62,13 @@ func parseURI(raw string) (*connstring.ConnString, error) {
} else if len(uri.Hosts) != 1 || uri.DirectConnectionSet && !uri.DirectConnection {
return nil, errors.New("MongoDB discovery requires an explicit replica-set name")
}
// Authentication must state tls. With tls=false the SCRAM handshake and all
// commands are plaintext: anyone on the path can read and change them, guess
// the password offline from the handshake, or take over the authenticated
// connection. It suits only a trusted network path to every member.
if uri.AuthMechanism != "" || uri.AuthSourceSet || uri.SSLSet {
if uri.AuthMechanism != "SCRAM-SHA-256" || !uri.AuthSourceSet || !validDatabaseName(uri.AuthSource) || !uri.SSL {
return nil, errors.New("MongoDB URI authentication requires explicit SCRAM-SHA-256, authSource, and TLS")
if uri.AuthMechanism != "SCRAM-SHA-256" || !uri.AuthSourceSet || !validDatabaseName(uri.AuthSource) || !uri.SSLSet {
return nil, errors.New("MongoDB URI authentication requires explicit SCRAM-SHA-256, authSource, and tls")
}
}
return uri, nil
Expand Down
50 changes: 49 additions & 1 deletion internal/backend/mongodb/uri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ func TestValidateMongoConfigProfiles(t *testing.T) {
t.Fatal("supported credential values rejected or static validation accessed CA/DNS", err)
}
}

// SCRAM over an isolated plaintext network must say so with tls=false.
// Option names are case-insensitive, as elsewhere in the URI.
for _, uri := range []string{
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=false",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&TLS=false",
} {
plaintext := Config{URI: uri, Username: "user", Password: "password"}
if err := ValidateConfig(plaintext); err != nil {
t.Fatal("explicit tls=false SCRAM rejected", uri, err)
}
}
}

func TestValidateMongoConfigRejectsOutsideProfile(t *testing.T) {
Expand Down Expand Up @@ -78,8 +90,13 @@ func TestValidateMongoConfigRejectsOutsideProfile(t *testing.T) {
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&tls=true",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=invalid.name&tls=true",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=false",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=true&tlsInsecure=true",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=FALSE",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=0",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=true&tls=false",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=false&tls=true",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&ssl=false",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=false&tlsInsecure=true",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=true&tlsCAFile=",
"mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=true&tlsCAFile=private-sentinel%0Apath",
secure.URI + "&tlsCAFile=" + strings.Repeat("a", 2049),
Expand All @@ -88,6 +105,14 @@ func TestValidateMongoConfigRejectsOutsideProfile(t *testing.T) {
invalid.URI = uri
cases = append(cases, invalid)
}
// A CA file cannot apply without TLS, and tls=false needs credentials.
plaintext := secure
plaintext.URI = "mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=false"
plaintext.CAFile = "/missing/ca-private-sentinel.pem"
cases = append(cases, plaintext)
anonymous := base
anonymous.URI = "mongodb://127.0.0.1:27028/?tls=false"
cases = append(cases, anonymous)
for _, pair := range []struct{ username, password string }{
{"", "password-private-sentinel"},
{"user-private-sentinel", ""},
Expand Down Expand Up @@ -143,6 +168,29 @@ func TestMongoDriverAuthenticationOptions(t *testing.T) {
}
}

// tls=false keeps SCRAM authentication and configures no TLS at all.
func TestMongoDriverPlaintextAuthenticationOptions(t *testing.T) {
cfg := Config{
URI: "mongodb://unresolved.invalid:27028/?authMechanism=SCRAM-SHA-256&authSource=admin&tls=false&directConnection=true",
Username: "user",
Password: "password",
}
uri, err := parseConfig(cfg)
if err != nil {
t.Fatal(err)
}
opts, err := connectionOptions(cfg, uri)
if err != nil {
t.Fatal(err)
}
if opts.Auth == nil || opts.Auth.AuthMechanism != "SCRAM-SHA-256" || opts.Auth.AuthSource != "admin" || !opts.Auth.PasswordSet {
t.Fatal("tls=false must keep SCRAM authentication")
}
if opts.TLSConfig != nil {
t.Fatal("tls=false must not configure TLS")
}
}

func TestMongoDriverUnauthenticatedOptions(t *testing.T) {
cfg := Config{URI: "mongodb://127.0.0.1:27028/"}
if err := ValidateConfig(cfg); err != nil {
Expand Down
18 changes: 16 additions & 2 deletions internal/testutil/testmongo/secure.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type SecureFixture struct {
Client *mongo.Client

startup context.Context
tlsMode string
owned bool
root string
process *exec.Cmd
Expand All @@ -55,11 +56,24 @@ type SecureFixture struct {

var secureFixtureSequence atomic.Uint64

// OpenSecure starts a mongod that requires TLS and SCRAM.
func OpenSecure(t *testing.T) *SecureFixture {
t.Helper()
return openSecure(t, "requireTLS")
}

// OpenSecureAllowingPlaintext starts a mongod that requires SCRAM and accepts
// both TLS and plaintext connections.
func OpenSecureAllowingPlaintext(t *testing.T) *SecureFixture {
t.Helper()
return openSecure(t, "preferTLS")
}

func openSecure(t *testing.T, tlsMode string) *SecureFixture {
t.Helper()
startup, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
fixture := &SecureFixture{startup: startup}
fixture := &SecureFixture{startup: startup, tlsMode: tlsMode}
t.Cleanup(func() { fixture.cleanup(t) })
fixture.prepareRoot(t)
fixture.startMongo(t)
Expand Down Expand Up @@ -263,7 +277,7 @@ func (fixture *SecureFixture) startMongo(t *testing.T) {
"--replSet", "weir_mongo_secure",
"--auth",
"--keyFile", filepath.Join(fixture.root, "keyfile"),
"--tlsMode", "requireTLS",
"--tlsMode", fixture.tlsMode,
"--tlsCertificateKeyFile", filepath.Join(fixture.root, "server.pem"),
"--tlsCAFile", fixture.caFile,
"--tlsAllowConnectionsWithoutCertificates",
Expand Down