Files
synology-csi/pkg/dsm/webapi/dsmwebapi_tls_test.go
benzheng 4c5e192954 feat: verify DSM HTTPS certificate to prevent credential exposure
The DSM WebAPI client disabled TLS certificate verification for all
HTTPS connections (InsecureSkipVerify: true), then sent the configured
DSM account and password to the endpoint. An attacker able to intercept,
redirect, or impersonate the DSM HTTPS endpoint could therefore obtain
the DSM credentials stored in the CSI client config / Kubernetes secret
(CWE-295 Improper Certificate Validation, OWASP A3:2017 Sensitive Data Exposure)

TLS certificate verification is now enabled by default. The client trusts
the system CA pool, so certificates signed by a public CA work without
extra configuration. Three optional client-info fields are added:

  - tlsCACert:          PEM CA cert to trust (for DSM self-signed certs);
                        merged with the system CA pool.
  - tlsServerName:      override the name checked during verification,
                        e.g. when connecting by IP.
  - insecureSkipVerify: explicit opt-out that restores the old behavior;
                        logs a warning on every connection.

The new fields are propagated through all DSM construction sites
(service, synocli, and the HA GetAnotherController path).

BREAKING CHANGE: deployments using `https: true` against a DSM with a
self-signed certificate (the DSM default) will fail to connect after
upgrade until they set `tlsCACert`, `tlsServerName`, or (discouraged)
`insecureSkipVerify: true`.

Adds TLS tests covering default-reject, valid-CA accept, wrong-CA reject,
insecureSkipVerify opt-in, and the tlsServerName DNS-SAN scenarios.
2026-07-29 14:21:55 +08:00

270 lines
8.7 KiB
Go

// Copyright 2024 Synology Inc.
package webapi
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// fakeDSMResponse is the minimal valid DSM login JSON response.
const fakeDSMResponse = `{"success":true,"data":{"sid":"test-sid"}}`
func fakeDSMHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fakeDSMResponse))
}
// serverHostPort parses host and port from a test server's listener address.
func serverHostPort(t *testing.T, server *httptest.Server) (string, int) {
t.Helper()
host, portStr, err := net.SplitHostPort(server.Listener.Addr().String())
if err != nil {
t.Fatalf("cannot split host/port from %q: %v", server.Listener.Addr(), err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("cannot parse port %q: %v", portStr, err)
}
return host, port
}
// generateCertWithSANs generates a unique ECDSA self-signed certificate with
// the given SANs, returning the PEM string and the tls.Certificate.
// Each call produces a distinct certificate, unlike httptest's shared default.
func generateCertWithSANs(t *testing.T, dnsNames []string, ips []net.IP) (certPEM string, tlsCert tls.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "synology-csi-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: dnsNames,
IPAddresses: ips,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
tlsCert, err = tls.X509KeyPair(certPEMBytes, keyPEM)
if err != nil {
t.Fatalf("X509KeyPair: %v", err)
}
return string(certPEMBytes), tlsCert
}
// generateSelfSignedCert generates a unique ECDSA self-signed certificate
// valid for 127.0.0.1.
func generateSelfSignedCert(t *testing.T) (certPEM string, tlsCert tls.Certificate) {
t.Helper()
return generateCertWithSANs(t, nil, []net.IP{net.IPv4(127, 0, 0, 1)})
}
// startTLSServer starts a TLS test server using the given certificate.
func startTLSServer(t *testing.T, handler http.Handler, tlsCert tls.Certificate) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(handler)
server.TLS = &tls.Config{Certificates: []tls.Certificate{tlsCert}}
server.StartTLS()
t.Cleanup(server.Close)
return server
}
// newTLSServerWithCert starts a TLS test server using a freshly generated
// self-signed certificate. Returns the server and its certificate PEM.
// Unlike httptest.NewTLSServer, every call produces a distinct certificate.
func newTLSServerWithCert(t *testing.T, handler http.Handler) (*httptest.Server, string) {
t.Helper()
certPEM, tlsCert := generateSelfSignedCert(t)
return startTLSServer(t, handler, tlsCert), certPEM
}
// TestHTTPS_DefaultRejectsUntrustedCert verifies that https: true without any
// TLS config now rejects a self-signed certificate (the vulnerability is fixed).
func TestHTTPS_DefaultRejectsUntrustedCert(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(fakeDSMHandler))
defer server.Close()
host, port := serverHostPort(t, server)
dsm := &DSM{
Ip: host,
Port: port,
Username: "admin",
Password: "secret",
Https: true,
// InsecureSkipVerify: false (default)
// TLSCACert: "" (not provided)
}
err := dsm.Login()
if err == nil {
t.Fatal("expected TLS certificate error, got nil — self-signed cert was accepted without InsecureSkipVerify or TLSCACert")
}
msg := err.Error()
if !strings.Contains(msg, "certificate") && !strings.Contains(msg, "x509") && !strings.Contains(msg, "tls") {
t.Fatalf("expected a TLS/certificate error, got: %v", err)
}
}
// TestHTTPS_InsecureSkipVerify_ConnectsAndSendsCredentials verifies that
// insecureSkipVerify: true still allows connecting to an untrusted endpoint
// (explicit opt-in) and that credentials are delivered to the server.
func TestHTTPS_InsecureSkipVerify_ConnectsAndSendsCredentials(t *testing.T) {
var gotAccount, gotPasswd string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAccount = r.URL.Query().Get("account")
gotPasswd = r.URL.Query().Get("passwd")
fakeDSMHandler(w, r)
}))
defer server.Close()
host, port := serverHostPort(t, server)
dsm := &DSM{
Ip: host,
Port: port,
Username: "test-user",
Password: "test-password",
Https: true,
InsecureSkipVerify: true,
}
if err := dsm.Login(); err != nil {
t.Fatalf("expected successful login with InsecureSkipVerify=true, got: %v", err)
}
if gotAccount != "test-user" {
t.Errorf("server received account=%q, want %q", gotAccount, "test-user")
}
if gotPasswd != "test-password" {
t.Errorf("server received passwd=%q, want %q", gotPasswd, "test-password")
}
if dsm.Sid != "test-sid" {
t.Errorf("dsm.Sid=%q, want %q", dsm.Sid, "test-sid")
}
}
// TestHTTPS_TLSCACert_ValidCert_ConnectsSuccessfully verifies that providing
// the server's own certificate as tlsCACert allows a successful login.
func TestHTTPS_TLSCACert_ValidCert_ConnectsSuccessfully(t *testing.T) {
server, certPEM := newTLSServerWithCert(t, http.HandlerFunc(fakeDSMHandler))
host, port := serverHostPort(t, server)
dsm := &DSM{
Ip: host,
Port: port,
Username: "admin",
Password: "secret",
Https: true,
TLSCACert: certPEM,
}
if err := dsm.Login(); err != nil {
t.Fatalf("expected successful login with correct TLSCACert, got: %v", err)
}
if dsm.Sid != "test-sid" {
t.Errorf("dsm.Sid=%q, want %q", dsm.Sid, "test-sid")
}
}
// TestHTTPS_TLSCACert_WrongCert_RejectsConnection verifies that providing a
// CA certificate that does not sign the server's certificate causes rejection.
func TestHTTPS_TLSCACert_WrongCert_RejectsConnection(t *testing.T) {
// Each call to newTLSServerWithCert generates a distinct certificate,
// so certPEM2 is guaranteed to be unrelated to server1's certificate.
server1, _ := newTLSServerWithCert(t, http.HandlerFunc(fakeDSMHandler))
_, certPEM2 := newTLSServerWithCert(t, http.HandlerFunc(fakeDSMHandler))
host, port := serverHostPort(t, server1)
dsm := &DSM{
Ip: host,
Port: port,
Username: "admin",
Password: "secret",
Https: true,
TLSCACert: certPEM2, // intentionally wrong CA
}
err := dsm.Login()
if err == nil {
t.Fatal("expected TLS certificate error with wrong CA cert, got nil")
}
}
// TestHTTPS_TLSServerName_MatchesDNSSAN_ConnectsSuccessfully covers the
// documented DSM self-signed scenario: the certificate only has a DNS SAN,
// the client connects by IP, and tlsServerName bridges the mismatch.
func TestHTTPS_TLSServerName_MatchesDNSSAN_ConnectsSuccessfully(t *testing.T) {
certPEM, tlsCert := generateCertWithSANs(t, []string{"synology"}, nil)
server := startTLSServer(t, http.HandlerFunc(fakeDSMHandler), tlsCert)
host, port := serverHostPort(t, server)
dsm := &DSM{
Ip: host, // an IP address; the certificate has no IP SANs
Port: port,
Username: "admin",
Password: "secret",
Https: true,
TLSCACert: certPEM,
TLSServerName: "synology",
}
if err := dsm.Login(); err != nil {
t.Fatalf("expected successful login with TLSServerName matching the DNS SAN, got: %v", err)
}
if dsm.Sid != "test-sid" {
t.Errorf("dsm.Sid=%q, want %q", dsm.Sid, "test-sid")
}
}
// TestHTTPS_TLSServerName_DNSSANOnly_RejectedWithoutOverride verifies that the
// same setup fails when tlsServerName is not set: the connecting IP is not in
// the certificate's SANs, so verification must reject even with a trusted CA.
func TestHTTPS_TLSServerName_DNSSANOnly_RejectedWithoutOverride(t *testing.T) {
certPEM, tlsCert := generateCertWithSANs(t, []string{"synology"}, nil)
server := startTLSServer(t, http.HandlerFunc(fakeDSMHandler), tlsCert)
host, port := serverHostPort(t, server)
dsm := &DSM{
Ip: host,
Port: port,
Username: "admin",
Password: "secret",
Https: true,
TLSCACert: certPEM,
// TLSServerName not set: verification uses the IP, which has no SAN
}
if err := dsm.Login(); err == nil {
t.Fatal("expected hostname verification error when connecting by IP to a DNS-SAN-only cert without TLSServerName, got nil")
}
}