Files
go/conn_http_auth_test.go
zeekay 057c09c529 fix(http): send X-Datastore-* auth headers so TLS connections can authenticate
The server reads X-Datastore-User/-Key/-SSL-Certificate-Auth verbatim and
defines no alias for the upstream vendor spelling, so the TLS branch of
applyOptionsToRequest could not authenticate at all: the server ignored the
headers, fell back to the default user and answered 401.

Only the TLS branch was affected. The plaintext branch sets req.URL.User, which
net/http sends as HTTP Basic, and Basic authenticates fine — which is why every
current consumer works and this stayed latent. A DSN with scheme https or
secure=true routes into the broken branch.

The three names are now declared once in headers.go, and covered by tests that
exercise the branch over a real TLS round trip.

Also reports ClientName as datastore-go; it is a self-reported label that lands
in system.query_log, and nothing filters on its value.
2026-07-26 11:32:55 -07:00

123 lines
4.0 KiB
Go

package datastore
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
)
// The Datastore HTTP interface reads X-Datastore-* verbatim and defines no
// alias for the upstream vendor spelling, so a client that sends anything else
// cannot authenticate. This branch is reachable only when opt.TLS != nil — the
// plaintext branch authenticates with HTTP Basic via req.URL.User instead and
// therefore cannot cover it.
func TestApplyOptionsToRequestTLSAuthHeaders(t *testing.T) {
newReq := func(t *testing.T) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodPost, "https://datastore.invalid/", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
return req
}
t.Run("username and password", func(t *testing.T) {
req := newReq(t)
opt := &Options{TLS: &tls.Config{}}
opt.Auth.Username = "reader"
opt.Auth.Password = "s3cret"
if err := applyOptionsToRequest(context.Background(), req, opt); err != nil {
t.Fatalf("applyOptionsToRequest: %v", err)
}
if got := req.Header.Get("X-Datastore-User"); got != "reader" {
t.Errorf("X-Datastore-User = %q, want %q", got, "reader")
}
if got := req.Header.Get("X-Datastore-Key"); got != "s3cret" {
t.Errorf("X-Datastore-Key = %q, want %q", got, "s3cret")
}
if got := req.Header.Get("X-Datastore-SSL-Certificate-Auth"); got != "off" {
t.Errorf("X-Datastore-SSL-Certificate-Auth = %q, want %q", got, "off")
}
})
t.Run("username only selects certificate auth", func(t *testing.T) {
req := newReq(t)
opt := &Options{TLS: &tls.Config{}}
opt.Auth.Username = "reader"
if err := applyOptionsToRequest(context.Background(), req, opt); err != nil {
t.Fatalf("applyOptionsToRequest: %v", err)
}
if got := req.Header.Get("X-Datastore-SSL-Certificate-Auth"); got != "on" {
t.Errorf("X-Datastore-SSL-Certificate-Auth = %q, want %q", got, "on")
}
})
// A regression guard: no request may carry the upstream vendor spelling,
// which the server answers with 401.
t.Run("no vendor branded headers", func(t *testing.T) {
req := newReq(t)
opt := &Options{TLS: &tls.Config{}}
opt.Auth.Username = "reader"
opt.Auth.Password = "s3cret"
if err := applyOptionsToRequest(context.Background(), req, opt); err != nil {
t.Fatalf("applyOptionsToRequest: %v", err)
}
for name := range req.Header {
if http.CanonicalHeaderKey(name) == http.CanonicalHeaderKey("X-ClickHouse-User") ||
http.CanonicalHeaderKey(name) == http.CanonicalHeaderKey("X-ClickHouse-Key") ||
http.CanonicalHeaderKey(name) == http.CanonicalHeaderKey("X-ClickHouse-SSL-Certificate-Auth") {
t.Errorf("request carries un-renamed header %q", name)
}
}
})
}
// End to end over real TLS: the headers must survive an actual https round trip
// with the driver's own transport, not just a hand built http.Request.
func TestHTTPConnectSendsDatastoreAuthHeadersOverTLS(t *testing.T) {
got := make(chan http.Header, 1)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case got <- r.Header.Clone():
default:
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
req, err := http.NewRequest(http.MethodPost, srv.URL, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
opt := &Options{TLS: &tls.Config{InsecureSkipVerify: true}}
opt.Auth.Username = "reader"
opt.Auth.Password = "s3cret"
if err := applyOptionsToRequest(context.Background(), req, opt); err != nil {
t.Fatalf("applyOptionsToRequest: %v", err)
}
client := srv.Client()
res, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
h := <-got
if h.Get("X-Datastore-User") != "reader" || h.Get("X-Datastore-Key") != "s3cret" {
t.Fatalf("server saw %v, want X-Datastore-User/Key", h)
}
if h.Get("X-ClickHouse-User") != "" || h.Get("X-ClickHouse-Key") != "" {
t.Fatalf("server saw un-renamed vendor headers: %v", h)
}
}
func TestClientNameIsDatastore(t *testing.T) {
if ClientName != "datastore-go" {
t.Errorf("ClientName = %q, want %q", ClientName, "datastore-go")
}
}