mirror of
https://github.com/pgsty/minio.git
synced 2026-10-06 17:55:59 +03:00
fix(tls): honor Go key exchange defaults across transports
Remove the eight explicit curve overrides so Go 1.27 honors tlsmlkem=0 across Server listeners, node links and outbound transports. Remove the unused shared curve option and add wire-level regression coverage. Document CA trust and TLS upgrade behavior, retain the investigation artifacts, and exclude their synthetic routes from the rebrand guard. The product compatibility baseline remains unchanged. Validation: focused race tests, HTTP tests, lint, compatibility guard positive/negative controls, and a fresh Linux build with three isolated OIDC integration scenarios all pass. Adversarial review: Claude Code Fable 5.1, max effort. Final verdict: APPROVE FOR COMMIT. Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
//go:build ignore
|
||||
|
||||
// Loopback-only lab client for the Admin API used by Console's OIDC form.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
endpoint := os.Getenv("LAB_SERVER")
|
||||
host, _, err := net.SplitHostPort(endpoint)
|
||||
if err != nil || host != "127.0.0.1" {
|
||||
panic("LAB_SERVER must use IPv4 loopback")
|
||||
}
|
||||
a, err := madmin.New(endpoint, os.Getenv("LAB_USER"), os.Getenv("LAB_PASSWORD"), false)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if len(os.Args) > 1 && os.Args[1] == "add" {
|
||||
restart, err := a.AddOrUpdateIDPConfig(ctx, "openid", "local154", "enable=on client_id=local154 client_secret=local154-placeholder config_url="+os.Getenv("LAB_OIDC_URL"), false)
|
||||
fmt.Printf("add restart=%v err=%v\n", restart, err)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
users, err := a.ListUsers(ctx)
|
||||
fmt.Printf("list_users count=%d err=%v\n", len(users), err)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build ignore
|
||||
|
||||
// Run only with the public, synthetic CA made by fixture.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/pgsty/silo-pkg/v3/certs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
panic("usage: cert-roots synthetic-ca.pem explicit-ca-path-or-empty")
|
||||
}
|
||||
data, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
panic("expected public certificate")
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
roots, err := certs.GetRootCAs(os.Args[2])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = certificate.Verify(x509.VerifyOptions{Roots: roots})
|
||||
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
|
||||
"go": runtime.Version(), "os": runtime.GOOS,
|
||||
"explicit_ca": os.Args[2] != "", "trusted": err == nil,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
//go:build ignore
|
||||
|
||||
// Loopback-only synthetic OIDC/TLS fixture. Only disposable lab identities are used.
|
||||
// Rejection modes model hypotheses; they are not evidence about the user's IdP.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type observedConn struct {
|
||||
net.Conn
|
||||
readBytes int
|
||||
}
|
||||
|
||||
func (c *observedConn) Read(p []byte) (int, error) {
|
||||
n, e := c.Conn.Read(p)
|
||||
c.readBytes += n
|
||||
return n, e
|
||||
}
|
||||
func (c *observedConn) reset() { _ = c.Conn.(*net.TCPConn).SetLinger(0); _ = c.Conn.Close() }
|
||||
|
||||
type observedListener struct{ net.Listener }
|
||||
|
||||
func (l observedListener) Accept() (net.Conn, error) {
|
||||
c, e := l.Listener.Accept()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &observedConn{Conn: c}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := flag.String("dir", "", "isolated output directory for public CA, URL, and mode file")
|
||||
tls13 := flag.Bool("tls13", false, "allow TLS 1.3 in addition to the TLS 1.2 baseline")
|
||||
flag.Parse()
|
||||
if *dir == "" {
|
||||
panic("-dir required")
|
||||
}
|
||||
must(os.MkdirAll(*dir, 0700))
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
must(err)
|
||||
root := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "issue-154 local CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, root, root, &key.PublicKey, key)
|
||||
must(err)
|
||||
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, NotBefore: root.NotBefore, NotAfter: root.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, root, &key.PublicKey, key)
|
||||
must(err)
|
||||
must(os.WriteFile(filepath.Join(*dir, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}), 0600))
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
must(err)
|
||||
base := "https://" + ln.Addr().String()
|
||||
must(os.WriteFile(filepath.Join(*dir, "url"), []byte(base), 0600))
|
||||
mode := func() string { b, _ := os.ReadFile(filepath.Join(*dir, "mode")); return strings.TrimSpace(string(b)) }
|
||||
var mu sync.Mutex
|
||||
log := func(v any) { mu.Lock(); defer mu.Unlock(); _ = json.NewEncoder(os.Stdout).Encode(v) }
|
||||
tc := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, rootDER}, PrivateKey: key}}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CurvePreferences: []tls.CurveID{tls.CurveP256}, CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384}}
|
||||
if *tls13 {
|
||||
tc.MaxVersion = tls.VersionTLS13
|
||||
}
|
||||
peerConfig := tc.Clone()
|
||||
peerConfig.NextProtos = []string{"h2", "http/1.1"}
|
||||
// net/http validates HTTP/2 support before GetConfigForClient; the fixture
|
||||
// then deliberately selects only the reported AES-256 suite.
|
||||
tc.CipherSuites = append(tc.CipherSuites, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)
|
||||
tc.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
m := mode()
|
||||
c := chi.Conn.(*observedConn)
|
||||
log(map[string]any{"event": "hello", "mode": m, "bytes_read": c.readBytes, "curves": chi.SupportedCurves, "signatures": chi.SignatureSchemes, "alpn": chi.SupportedProtos, "versions": chi.SupportedVersions})
|
||||
reject := m == "reset" || m == "reject-mlkem" && slices.Contains(chi.SupportedCurves, tls.CurveID(4588)) || m == "reject-mldsa" && slices.Contains(chi.SignatureSchemes, tls.SignatureScheme(0x0904)) || m == "require-h2" && !slices.Contains(chi.SupportedProtos, "h2")
|
||||
if reject {
|
||||
c.reset()
|
||||
return nil, errors.New("synthetic ClientHello rejection")
|
||||
}
|
||||
return peerConfig, nil
|
||||
}
|
||||
server := &http.Server{TLSConfig: tc, ReadHeaderTimeout: 5 * time.Second}
|
||||
var codes sync.Map
|
||||
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log(map[string]any{"event": "request", "path": r.URL.Path, "protocol": r.Proto, "tls": r.TLS.Version, "cipher": r.TLS.CipherSuite, "resumed": r.TLS.DidResume, "ua": r.UserAgent()})
|
||||
if mode() == "reject-silo-ua" && strings.HasPrefix(r.UserAgent(), "Silo") {
|
||||
c, _, e := w.(http.Hijacker).Hijack()
|
||||
if e == nil {
|
||||
c.(*tls.Conn).NetConn().(*observedConn).reset()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/authorize":
|
||||
q := r.URL.Query()
|
||||
redirect, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil || redirect.Scheme != "http" || redirect.Hostname() != "127.0.0.1" || redirect.Path != "/oauth_callback" || q.Get("client_id") != "local154" {
|
||||
http.Error(w, "loopback lab authorization only", 400)
|
||||
return
|
||||
}
|
||||
codeBytes := make([]byte, 18)
|
||||
_, err = rand.Read(codeBytes)
|
||||
must(err)
|
||||
code := base64.RawURLEncoding.EncodeToString(codeBytes)
|
||||
codes.Store(code, q.Get("nonce"))
|
||||
values := redirect.Query()
|
||||
values.Set("code", code)
|
||||
values.Set("state", q.Get("state"))
|
||||
redirect.RawQuery = values.Encode()
|
||||
http.Redirect(w, r, redirect.String(), http.StatusFound)
|
||||
case "/token":
|
||||
if r.Method != http.MethodPost || r.ParseForm() != nil {
|
||||
http.Error(w, "bad token request", 400)
|
||||
return
|
||||
}
|
||||
id, secret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
id, secret = r.Form.Get("client_id"), r.Form.Get("client_secret")
|
||||
}
|
||||
nonce, found := codes.LoadAndDelete(r.Form.Get("code"))
|
||||
if id != "local154" || secret != "local154-placeholder" || !found || r.Form.Get("grant_type") != "authorization_code" {
|
||||
w.WriteHeader(400)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
|
||||
return
|
||||
}
|
||||
audience := id
|
||||
if mode() == "bad-audience" {
|
||||
audience = "different-lab-client"
|
||||
}
|
||||
claims, _ := json.Marshal(map[string]any{"iss": base, "sub": "local154-user", "aud": audience, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), "policy": "readwrite", "nonce": nonce})
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"local-154","typ":"JWT"}`))
|
||||
payload := header + "." + base64.RawURLEncoding.EncodeToString(claims)
|
||||
hash := sha256.Sum256([]byte(payload))
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
|
||||
must(err)
|
||||
if mode() == "bad-signature" {
|
||||
signature[0] ^= 1
|
||||
}
|
||||
token := payload + "." + base64.RawURLEncoding.EncodeToString(signature)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": token, "id_token": token, "token_type": "Bearer", "expires_in": 3600})
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": base, "jwks_uri": base + "/jwks", "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid"}})
|
||||
case "/jwks":
|
||||
if mode() == "bad-jwks" {
|
||||
http.Error(w, "synthetic JWKS outage", 503)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{"kty": "RSA", "kid": "local-154", "use": "sig", "alg": "RS256", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": "AQAB"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
fmt.Fprintln(os.Stderr, base)
|
||||
must(server.ServeTLS(observedListener{ln}, "", ""))
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
# Historical OIDC-only candidate; superseded by ../go127-stack.md. Do not apply on top of the stack fix.
|
||||
--- a/cmd/utils.go
|
||||
+++ b/cmd/utils.go
|
||||
@@ -654,6 +654,14 @@
|
||||
return NewHTTPTransportWithTimeout(1 * time.Minute)
|
||||
}
|
||||
|
||||
+// NewOpenIDHTTPTransport uses Go defaults for external identity-provider key exchange.
|
||||
+// This lets tlsmlkem/tlssecpmlkem configure their documented default sets.
|
||||
+func NewOpenIDHTTPTransport() *http.Transport {
|
||||
+ tr := NewHTTPTransport()
|
||||
+ tr.TLSClientConfig.CurvePreferences = nil
|
||||
+ return tr
|
||||
+}
|
||||
+
|
||||
// Default values for dial timeout
|
||||
const defaultDialTimeout = 5 * time.Second
|
||||
|
||||
--- a/cmd/iam.go
|
||||
+++ b/cmd/iam.go
|
||||
@@ -277,7 +277,7 @@
|
||||
for {
|
||||
if !openidInit {
|
||||
openidConfig, err := openid.LookupConfig(s,
|
||||
- xhttp.WithUserAgent(NewHTTPTransport(), func() string {
|
||||
+ xhttp.WithUserAgent(NewOpenIDHTTPTransport(), func() string {
|
||||
return getUserAgent(getMinioMode())
|
||||
}), xhttp.DrainBody, globalSite.Region())
|
||||
if err != nil {
|
||||
--- a/cmd/config-current.go
|
||||
+++ b/cmd/config-current.go
|
||||
@@ -352,7 +352,7 @@
|
||||
}
|
||||
case config.IdentityOpenIDSubSys:
|
||||
if _, err := openid.LookupConfig(s,
|
||||
- xhttp.WithUserAgent(NewHTTPTransport(), func() string {
|
||||
+ xhttp.WithUserAgent(NewOpenIDHTTPTransport(), func() string {
|
||||
return getUserAgent(getMinioMode())
|
||||
}), xhttp.DrainBody, globalSite.Region()); err != nil {
|
||||
return err
|
||||
@@ -0,0 +1,165 @@
|
||||
//go:build ignore
|
||||
|
||||
// Diagnostic GET using the Server's actual transport constructor.
|
||||
// Build explicitly from the SILO module root; see ../issue-154.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/cmd"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/certs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
endpoint := flag.String("url", os.Getenv("OIDC_URL"), "discovery URL; no credentials or query string")
|
||||
ca := flag.String("ca", "", "same CA file or certs/CAs directory as Server")
|
||||
h2 := flag.Bool("h2", false, "diagnostic: opt in to HTTP/2")
|
||||
classical := flag.Bool("classical", false, "diagnostic: omit hybrid key exchange only")
|
||||
defaultCurves := flag.Bool("default-curves", false, "diagnostic: let Go choose curves and honor its GODEBUG defaults")
|
||||
tls12 := flag.Bool("tls12", false, "diagnostic: TLS 1.2 only; keeps certificate verification")
|
||||
direct := flag.Bool("direct", false, "diagnostic: bypass environment proxy")
|
||||
ip := flag.String("ip", "", "diagnostic: pin destination IP, preserving Host/SNI; requires -direct")
|
||||
fresh := flag.Bool("fresh", false, "diagnostic: close idle connections between requests")
|
||||
ua := flag.String("ua", "issue-154-probe", "HTTP User-Agent; supply actual Server UA to investigate a WAF")
|
||||
n := flag.Int("n", 1, "number of GETs (1 to 3)")
|
||||
flag.Parse()
|
||||
u, err := url.Parse(*endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || *n < 1 || *n > 3 {
|
||||
fmt.Fprintln(os.Stderr, "require an HTTPS URL without credentials/query/fragment and -n between 1 and 3")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *ip != "" && (!*direct || net.ParseIP(*ip) == nil) {
|
||||
fmt.Fprintln(os.Stderr, "-ip requires a literal IP and -direct")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *classical && *defaultCurves {
|
||||
fmt.Fprintln(os.Stderr, "choose at most one of -classical and -default-curves")
|
||||
os.Exit(2)
|
||||
}
|
||||
tr := cmd.NewHTTPTransport()
|
||||
tr.TLSClientConfig.RootCAs, err = certs.GetRootCAs(*ca)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "CA loading failed; check the local CA path")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *h2 {
|
||||
tr.ForceAttemptHTTP2 = true
|
||||
}
|
||||
if *classical {
|
||||
tr.TLSClientConfig.CurvePreferences = []tls.CurveID{tls.CurveP256, tls.X25519, tls.CurveP384, tls.CurveP521}
|
||||
}
|
||||
if *defaultCurves {
|
||||
tr.TLSClientConfig.CurvePreferences = nil
|
||||
}
|
||||
if *tls12 {
|
||||
tr.TLSClientConfig.MinVersion = tls.VersionTLS12
|
||||
tr.TLSClientConfig.MaxVersion = tls.VersionTLS12
|
||||
}
|
||||
if *direct {
|
||||
tr.Proxy = nil
|
||||
}
|
||||
if *ip != "" {
|
||||
base := tr.DialContext
|
||||
tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, e := net.SplitHostPort(address)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if host == u.Hostname() {
|
||||
address = net.JoinHostPort(*ip, port)
|
||||
}
|
||||
return base(ctx, network, address)
|
||||
}
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
var mu sync.Mutex
|
||||
log := func(format string, args ...any) { mu.Lock(); defer mu.Unlock(); fmt.Printf(format+"\n", args...) }
|
||||
log("go=%s os=%s arch=%s h2=%v classical=%v default_curves=%v tls12=%v", runtime.Version(), runtime.GOOS, runtime.GOARCH, *h2, *classical, *defaultCurves, *tls12)
|
||||
// Deliberately print no URL, headers, body, client ID, secret, or token.
|
||||
req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
proxy := "direct"
|
||||
if tr.Proxy != nil {
|
||||
p, e := tr.Proxy(req)
|
||||
if e != nil {
|
||||
log("proxy_selection_error=%T", e)
|
||||
os.Exit(2)
|
||||
}
|
||||
if p != nil {
|
||||
proxy = p.Scheme + " proxy (address omitted)"
|
||||
}
|
||||
}
|
||||
log("route=%s curves=%v", proxy, tr.TLSClientConfig.CurvePreferences)
|
||||
client := &http.Client{Transport: xhttp.WithUserAgent(tr, func() string { return *ua }), Timeout: 20 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
failed := false
|
||||
for i := 0; i < *n; i++ {
|
||||
if *fresh {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
log("request=%d", i+1)
|
||||
trace := &httptrace.ClientTrace{
|
||||
DNSDone: func(d httptrace.DNSDoneInfo) { log("dns_addresses=%v err=%s", d.Addrs, errorClass(d.Err)) },
|
||||
ConnectStart: func(network, addr string) { log("connect=%s %s", network, addr) },
|
||||
ConnectDone: func(_, addr string, e error) { log("connected=%s err=%s", addr, errorClass(e)) },
|
||||
TLSHandshakeStart: func() { log("tls_start") },
|
||||
TLSHandshakeDone: func(s tls.ConnectionState, e error) {
|
||||
log("tls_done=0x%x cipher=%s alpn=%q resumed=%v verified_chains=%d err=%s", s.Version, tls.CipherSuiteName(s.CipherSuite), s.NegotiatedProtocol, s.DidResume, len(s.VerifiedChains), errorClass(e))
|
||||
},
|
||||
GotConn: func(c httptrace.GotConnInfo) { log("got_conn=%s reused=%v", c.Conn.RemoteAddr(), c.Reused) },
|
||||
WroteRequest: func(w httptrace.WroteRequestInfo) { log("wrote_request err=%s", errorClass(w.Err)) },
|
||||
GotFirstResponseByte: func() { log("first_response_byte") },
|
||||
}
|
||||
r := req.Clone(httptrace.WithClientTrace(context.Background(), trace))
|
||||
resp, e := client.Do(r)
|
||||
if e != nil {
|
||||
log("get_error=%s", errorClass(e))
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
log("status=%d protocol=%s", resp.StatusCode, resp.Proto)
|
||||
_, e = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if e != nil || resp.StatusCode != http.StatusOK {
|
||||
failed = true
|
||||
log("body_error=%s", errorClass(e))
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func errorClass(err error) string {
|
||||
if err == nil {
|
||||
return "none"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "deadline"
|
||||
}
|
||||
// Error text may contain a private URL. Emit only category and concrete type.
|
||||
category := "other"
|
||||
s := err.Error()
|
||||
for _, k := range []string{"connection reset by peer", "x509:", "TLS handshake timeout", "connection refused", "EOF"} {
|
||||
if strings.Contains(s, k) {
|
||||
category = k
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s (%T)", category, err)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded, loopback-only full-Server comparison. See the investigation report.
|
||||
|
||||
Run in an isolated generic Linux container with locally built binaries in
|
||||
/lab/bin and a new disposable /lab/out. No customer identities or endpoints.
|
||||
"""
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path("/lab")
|
||||
OUT = ROOT / "out"
|
||||
BASE = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("MINIO_", "SILO_", "CONSOLE_"))
|
||||
and k.lower() not in {"http_proxy", "https_proxy", "all_proxy", "no_proxy", "godebug"}}
|
||||
|
||||
|
||||
def port():
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def request(url, opener=None, payload=None):
|
||||
headers = {"Origin": f"http://{urllib.parse.urlparse(url).netloc}"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=None if payload is None else json.dumps(payload).encode(), headers=headers)
|
||||
try:
|
||||
response = (opener.open if opener else urllib.request.urlopen)(req, timeout=2)
|
||||
except urllib.error.HTTPError as err:
|
||||
response = err
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 0, {}, b""
|
||||
with response:
|
||||
return response.code, dict(response.headers), response.read(1 << 20)
|
||||
|
||||
|
||||
def stop(proc):
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def login(console, ca):
|
||||
jar = http.cookiejar.CookieJar()
|
||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
status, _, raw = request(console + "/api/v1/login", client)
|
||||
details = json.loads(raw)
|
||||
rules = details.get("redirectRules", [])
|
||||
assert status == 200 and len(rules) == 1, (status, details)
|
||||
auth_url = rules[0]["redirect"]
|
||||
provider = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context(cafile=str(ca))))
|
||||
status, headers, _ = request(auth_url, provider)
|
||||
callback = headers.get("Location", headers.get("location", ""))
|
||||
assert status == 302 and callback.startswith(console + "/oauth_callback?"), (status, callback)
|
||||
values = urllib.parse.parse_qs(urllib.parse.urlparse(callback).query)
|
||||
callback_status, _, _ = request(callback, client)
|
||||
status, _, _ = request(console + "/api/v1/login/oauth2/auth", client,
|
||||
{"code": values["code"][0], "state": values["state"][0]})
|
||||
buckets_status, _, _ = request(console + "/api/v1/buckets", client)
|
||||
# Do not record cookies, codes, JWTs, or the state value.
|
||||
return {"callback_status": callback_status, "login_status": status,
|
||||
"buckets_status": buckets_status, "session_cookie": any(c.name == "token" for c in jar)}
|
||||
|
||||
|
||||
def run(name, binary, mode="normal", debug=None, tls13=False,
|
||||
expected=True, trusted=True, oidc=True, oauth=False, add=False):
|
||||
d = OUT / name
|
||||
(d / "certs/CAs").mkdir(parents=True, exist_ok=False)
|
||||
idp = d / "idp"
|
||||
idp.mkdir()
|
||||
(idp / "mode").write_text(mode)
|
||||
with (d / "fixture.jsonl").open("w") as events, (d / "fixture.stderr").open("w") as errors, (d / "server.log").open("w") as logs:
|
||||
fixture = subprocess.Popen([str(ROOT / "bin/fixture"), "-dir", str(idp), *(["-tls13"] if tls13 else [])], env=BASE, stdout=events, stderr=errors)
|
||||
server = None
|
||||
try:
|
||||
until = time.monotonic() + 5
|
||||
while not (idp / "url").is_file() and time.monotonic() < until:
|
||||
time.sleep(.05)
|
||||
assert (idp / "url").is_file(), "fixture did not initialize"
|
||||
url = (idp / "url").read_text() + "/.well-known/openid-configuration"
|
||||
if trusted:
|
||||
shutil.copyfile(idp / "ca.pem", d / "certs/CAs/lab.pem")
|
||||
sport, cport = port(), port()
|
||||
address = f"127.0.0.1:{sport}"
|
||||
api, console = "http://" + address, f"http://127.0.0.1:{cport}"
|
||||
password = secrets.token_urlsafe(24)
|
||||
env = dict(BASE, MINIO_ROOT_USER="local154", MINIO_ROOT_PASSWORD=password, MINIO_BROWSER="on")
|
||||
if debug:
|
||||
env["GODEBUG"] = debug
|
||||
if oidc:
|
||||
env.update(MINIO_IDENTITY_OPENID_CONFIG_URL=url,
|
||||
MINIO_IDENTITY_OPENID_CLIENT_ID="local154",
|
||||
MINIO_IDENTITY_OPENID_CLIENT_SECRET="local154-placeholder",
|
||||
MINIO_IDENTITY_OPENID_REDIRECT_URI=console + "/oauth_callback")
|
||||
server = subprocess.Popen([str(ROOT / "bin" / binary), "--config-dir", str(d / "config"), "--certs-dir", str(d / "certs"), "server", "--address", address, "--console-address", f"127.0.0.1:{cport}", str(d / "data")], env=env, stdout=logs, stderr=subprocess.STDOUT)
|
||||
until = time.monotonic() + 15
|
||||
while time.monotonic() < until:
|
||||
assert server.poll() is None, "Server exited; inspect its local log"
|
||||
status, _, _ = request(api + "/minio/health/cluster")
|
||||
if status == 200 and request(console)[0] == 200:
|
||||
break
|
||||
if not expected and (d / "server.log").read_text().count("Waiting for OpenID") >= 2:
|
||||
break
|
||||
time.sleep(.1)
|
||||
result = {"case": name, "binary": binary, "mode": mode, "godebug": debug, "tls13": tls13,
|
||||
"cluster": request(api + "/minio/health/cluster")[0],
|
||||
"ready": request(api + "/minio/health/ready")[0], "console": request(console)[0]}
|
||||
assert result["cluster"] == (200 if expected else 503), result
|
||||
aenv = dict(BASE, LAB_SERVER=address, LAB_USER="local154", LAB_PASSWORD=password, LAB_OIDC_URL=url)
|
||||
if expected:
|
||||
admin = subprocess.run([str(ROOT / "bin/admin-check")], env=aenv, capture_output=True, text=True, timeout=7)
|
||||
result["admin_list_ok"] = admin.returncode == 0
|
||||
assert result["admin_list_ok"], admin.stdout
|
||||
curl = subprocess.run(["curl", "--cacert", str(idp / "ca.pem"), "--http2", "--max-time", "3", "-sS", "-o", "/dev/null", "-w", "%{http_code} %{http_version}", url], env=BASE, capture_output=True, text=True, timeout=5)
|
||||
result["curl"] = {"exit": curl.returncode, "status_protocol": curl.stdout}
|
||||
if add:
|
||||
attempt = subprocess.run([str(ROOT / "bin/admin-check"), "add"], env=aenv, capture_output=True, text=True, timeout=7)
|
||||
result["add_ok"] = attempt.returncode == 0
|
||||
result["add_reset"] = "connection reset by peer" in attempt.stdout
|
||||
assert result["add_ok"] == binary.startswith("candidate"), result
|
||||
if oauth:
|
||||
result["oauth"] = login(console, idp / "ca.pem")
|
||||
assert result["oauth"]["login_status"] == 204 and result["oauth"]["buckets_status"] == 200, result
|
||||
for bad in ("bad-signature", "bad-audience"):
|
||||
(idp / "mode").write_text(bad)
|
||||
result[bad] = login(console, idp / "ca.pem")
|
||||
assert result[bad]["login_status"] >= 400 and result[bad]["buckets_status"] >= 400, result
|
||||
# Public handshake metadata only; no authorization parameters.
|
||||
result["events"] = [json.loads(line) for line in (d / "fixture.jsonl").read_text().splitlines()]
|
||||
(d / "result.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "events"}), flush=True)
|
||||
finally:
|
||||
if server is not None:
|
||||
stop(server)
|
||||
stop(fixture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run("old126-normal", "old-go126")
|
||||
run("old127-normal", "old-go127")
|
||||
run("old126-compat", "old-go126", "reject-mlkem", "tlsmlkem=0")
|
||||
run("old127-compat", "old-go127", "reject-mlkem", "tlsmlkem=0", expected=False)
|
||||
run("head127-compat", "head-go127", "reject-mlkem", "tlsmlkem=0", expected=False)
|
||||
run("candidate127-compat-login", "candidate-go127", "reject-mlkem", "tlsmlkem=0", oauth=True)
|
||||
run("candidate127-no-optout", "candidate-go127", "reject-mlkem", expected=False)
|
||||
run("candidate127-tls13-login", "candidate-go127", tls13=True, oauth=True)
|
||||
run("candidate127-untrusted", "candidate-go127", trusted=False, expected=False)
|
||||
run("candidate127-mldsa", "candidate-go127", "reject-mldsa", "tlsmlkem=0", expected=False)
|
||||
run("head127-add", "head-go127", "reject-mlkem", "tlsmlkem=0", oidc=False, add=True)
|
||||
run("candidate127-add", "candidate-go127", "reject-mlkem", "tlsmlkem=0", oidc=False, add=True)
|
||||
Reference in New Issue
Block a user