From c39a6c2f6309d17343bb9ffeaf805efaab11049d Mon Sep 17 00:00:00 2001 From: cppla Date: Fri, 9 Oct 2026 09:33:43 +0800 Subject: [PATCH 1/4] feat: add fixed Chrome 155 H2 profile with browser wire regression --- README.md | 7 + cmd/autocar/common.go | 50 ++- cmd/autocar/h2_profile_test.go | 217 +++++++++++ cmd/autocar/init.go | 1 + cmd/autocar/init_test.go | 16 +- cmd/autocar/web_cli_test.go | 3 + docs/ARCHITECTURE.md | 8 +- docs/DEPENDENCY-MAINTENANCE.md | 9 + docs/WEB_COVER.md | 35 +- internal/tunnel/testdata/chrome155/LICENSE | 27 ++ internal/tunnel/testdata/chrome155/README.md | 46 +++ .../tunnel/testdata/chrome155/clienthello.hex | 1 + internal/tunnel/web_chrome155_fixture_test.go | 359 ++++++++++++++++++ internal/tunnel/web_client.go | 16 +- internal/tunnel/web_client_test.go | 41 ++ internal/tunnel/web_fingerprint.go | 50 ++- .../web_fingerprint_resumption_policy_test.go | 48 ++- internal/tunnel/web_fingerprint_test.go | 34 +- internal/tunnel/web_h2_client.go | 6 +- internal/tunnel/web_h2_dial_sharing_test.go | 6 +- internal/tunnel/web_h2_initialization_test.go | 6 +- .../tunnel/web_h2_resumption_fallback_test.go | 22 +- internal/tunnel/web_h2_resumption_hrr_test.go | 5 +- internal/tunnel/web_h2_resumption_test.go | 19 +- internal/tunnel/web_h2_stream_handoff_test.go | 2 +- .../web_h2_write_timeout_integration_test.go | 4 +- 26 files changed, 946 insertions(+), 92 deletions(-) create mode 100644 cmd/autocar/h2_profile_test.go create mode 100644 internal/tunnel/testdata/chrome155/LICENSE create mode 100644 internal/tunnel/testdata/chrome155/README.md create mode 100644 internal/tunnel/testdata/chrome155/clienthello.hex create mode 100644 internal/tunnel/web_chrome155_fixture_test.go diff --git a/README.md b/README.md index c27c20d..62e3363 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ H3。`--cover-root` 和 `--cover-upstream` 必须且只能选一个: --ca server.crt \ --token-file token \ --transport web-auto \ + --h2-fingerprint chrome-155 \ --h3-fingerprint chrome-2026-10 ``` @@ -237,6 +238,12 @@ CONNECT-UDP,`h2` 不支持 UDP。Web 模式要求 TCP/UDP 使用相同数字 `--disable-tcp-fallback`,也不能在两端配置 mTLS。详细能力、边界和安全的验证方法见 [Web-cover 模式](docs/WEB_COVER.md)。 +H2 可显式选择 `--h2-fingerprint=chrome-155`(固定的新模板)、`chrome-133` +(兼容旧模板)或 `native`(Go TLS 调试/互操作)。新生成的 web `init` 客户端配置 +明确固定为 `chrome-155`;旧配置省略该字段时仍使用 `chrome-133`,不会静默改变。 +此选项也作用于 `web-auto` 的 H2 回退路径,不改变 H3。已加入与真实 Chrome 155 +历史抓取样本的冷 ClientHello 逐字段回归,但不代表完整流量等同浏览器。 + `--h3-fingerprint=chrome-2026-10` 是默认值,固定使用上述依赖版本提供的完整客户端 QUIC/TLS 握手画像,并固定为与画像中版本参数一致的 QUIC v1。 `--h3-fingerprint=native` 是互操作与故障回滚选项:它关闭该 diff --git a/cmd/autocar/common.go b/cmd/autocar/common.go index 50d10a1..e2447f0 100644 --- a/cmd/autocar/common.go +++ b/cmd/autocar/common.go @@ -36,6 +36,7 @@ type tunnelFlags struct { openTimeout time.Duration h2WriteTimeout time.Duration fallbackTTL time.Duration + h2Fingerprint h2FingerprintFlag h3Fingerprint h3FingerprintFlag pacing string pacingProfile string @@ -44,6 +45,36 @@ type tunnelFlags struct { eventHandler tunnel.ClientEventHandler } +// h2FingerprintFlag validates both CLI and JSON input before any credentials or +// network activity. An empty legacy value keeps the Chrome 133 default. +type h2FingerprintFlag string + +func (value *h2FingerprintFlag) String() string { + if value == nil { + return "" + } + return string(*value) +} + +func (value *h2FingerprintFlag) Get() any { return value.String() } + +func (value *h2FingerprintFlag) Set(profile string) error { + if err := validateH2Fingerprint(profile); err != nil { + return err + } + *value = h2FingerprintFlag(profile) + return nil +} + +func validateH2Fingerprint(profile string) error { + switch tunnel.FingerprintProfile(profile) { + case "", tunnel.FingerprintChrome133, tunnel.FingerprintChrome155, tunnel.FingerprintNative: + return nil + default: + return errors.New("invalid --h2-fingerprint; want chrome-133, chrome-155, or native (empty uses chrome-133)") + } +} + // h3FingerprintFlag rejects the retired wire image at parse time, including // commands that would not otherwise construct an H3 client. Other values retain // their existing transport-specific validation behavior. @@ -81,6 +112,8 @@ func addTunnelFlags(fs *flag.FlagSet, flags *tunnelFlags) { fs.DurationVar(&flags.openTimeout, "open-timeout", 15*time.Second, "overall remote stream open timeout") fs.DurationVar(&flags.h2WriteTimeout, "h2-write-timeout", 30*time.Second, "shared H2 connection write timeout (h2/web-auto only; 0 uses 30s; not an idle or per-stream timeout)") fs.DurationVar(&flags.fallbackTTL, "fallback-cooldown", 30*time.Second, "base time to prefer the TCP fallback after a UDP path failure (each retry is jittered +/-20%)") + flags.h2Fingerprint = h2FingerprintFlag(tunnel.FingerprintChrome133) + fs.Var(&flags.h2Fingerprint, "h2-fingerprint", "web H2 wire profile: chrome-133, chrome-155, or native; empty uses chrome-133 (h2/web-auto only)") flags.h3Fingerprint = h3FingerprintFlag(tunnel.H3FingerprintChrome202610) fs.Var(&flags.h3Fingerprint, "h3-fingerprint", "web H3 wire profile: chrome-2026-10 or native; chrome-2026-08 is retired") fs.StringVar(&flags.pacing, "pacing", "adaptive", "QUIC application pacing: adaptive, reno, or fixed-rate") @@ -95,6 +128,9 @@ type closeDialer interface { } func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) { + if err := validateH2Fingerprint(string(flags.h2Fingerprint)); err != nil { + return nil, err + } if tunnel.H3FingerprintProfile(flags.h3Fingerprint) == tunnel.H3FingerprintChrome202608 { return nil, tunnel.ErrH3FingerprintProfileRetired } @@ -251,12 +287,13 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) { return newWebSnapshotDialer(client), nil case "h2": client, err := tunnel.NewWebH2Client(tunnel.WebH2ClientConfig{ - ServerAddress: flags.server, - Token: token, - TLSConfig: tlsConfig, - HandshakeTimeout: flags.openTimeout, - DialTimeout: flags.dialTimeout, - WriteByteTimeout: flags.h2WriteTimeout, + ServerAddress: flags.server, + Token: token, + TLSConfig: tlsConfig, + FingerprintProfile: tunnel.FingerprintProfile(flags.h2Fingerprint), + HandshakeTimeout: flags.openTimeout, + DialTimeout: flags.dialTimeout, + WriteByteTimeout: flags.h2WriteTimeout, }) if err != nil { return nil, err @@ -267,6 +304,7 @@ func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) { ServerAddress: flags.server, Token: token, TLSConfig: tlsConfig, + H2FingerprintProfile: tunnel.FingerprintProfile(flags.h2Fingerprint), H3FingerprintProfile: tunnel.H3FingerprintProfile(flags.h3Fingerprint), HandshakeTimeout: flags.openTimeout, H3DialTimeout: flags.dialTimeout, diff --git a/cmd/autocar/h2_profile_test.go b/cmd/autocar/h2_profile_test.go new file mode 100644 index 0000000..67c2c1d --- /dev/null +++ b/cmd/autocar/h2_profile_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "flag" + "io" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/cppla/autocar/internal/tunnel" +) + +func TestH2FingerprintConfigAndCLI(t *testing.T) { + for _, test := range []struct { + name, config, override, want string + hasOverride, wantError bool + }{ + {name: "legacy omitted", config: `{}`, want: "chrome-133"}, + {name: "legacy empty", config: `{"h2-fingerprint":""}`}, + {name: "explicit 133", config: `{"h2-fingerprint":"chrome-133"}`, want: "chrome-133"}, + {name: "explicit 155", config: `{"h2-fingerprint":"chrome-155"}`, want: "chrome-155"}, + {name: "native", config: `{"h2-fingerprint":"native"}`, want: "native"}, + {name: "override 155", config: `{"h2-fingerprint":"chrome-133"}`, hasOverride: true, override: "chrome-155", want: "chrome-155"}, + {name: "override 133", config: `{"h2-fingerprint":"chrome-155"}`, hasOverride: true, override: "chrome-133", want: "chrome-133"}, + {name: "override native", config: `{"h2-fingerprint":"chrome-155"}`, hasOverride: true, override: "native", want: "native"}, + {name: "override empty", config: `{"h2-fingerprint":"chrome-155"}`, hasOverride: true}, + {name: "invalid config", config: `{"h2-fingerprint":"private-invalid-value"}`, wantError: true}, + {name: "invalid overridden config", config: `{"h2-fingerprint":"private-invalid-value"}`, hasOverride: true, override: "chrome-155", wantError: true}, + {name: "invalid CLI", config: `{"h2-fingerprint":"chrome-155"}`, hasOverride: true, override: "unsupported", wantError: true}, + {name: "number", config: `{"h2-fingerprint":155}`, wantError: true}, + {name: "boolean", config: `{"h2-fingerprint":true}`, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.json") + if err := os.WriteFile(path, []byte(test.config), 0o600); err != nil { + t.Fatal(err) + } + fs := flag.NewFlagSet("client", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + args := []string{"--config", path} + if test.hasOverride { + args = append(args, "--h2-fingerprint", test.override) + } + err := parseFlagsWithConfig(fs, args) + if test.wantError { + if err == nil || !strings.Contains(err.Error(), "h2-fingerprint") { + t.Fatalf("invalid profile error = %v", err) + } + if strings.Contains(err.Error(), "private-invalid-value") { + t.Fatal("invalid configuration value was exposed") + } + return + } + if err != nil || string(tf.h2Fingerprint) != test.want { + t.Fatalf("profile = %q, want %q; error = %v", tf.h2Fingerprint, test.want, err) + } + }) + } +} + +func TestH2FingerprintInvalidInputStaysOffline(t *testing.T) { + clearPreflightEnvironment(t) + dnsCalls := denyPreflightDNS(t) + for _, mode := range []string{"h2", "web-auto", "h3", "auto", "quic", "tls", "direct"} { + t.Run(mode, func(t *testing.T) { + args := []string{"--transport", mode, "--h2-fingerprint", "unsupported"} + for _, run := range []func(context.Context, []string) error{runClient, runBenchClient} { + if err := run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "--h2-fingerprint") { + t.Fatalf("invalid profile was not rejected before credentials/network: %v", err) + } + } + for _, jsonOutput := range []bool{false, true} { + var stdout, stderr bytes.Buffer + doctorArgs := []string{"--transport", mode, "--h2-fingerprint", "private-invalid-value"} + if jsonOutput { + doctorArgs = append(doctorArgs, "--json") + } + err := runDoctorWith(context.Background(), doctorArgs, &stdout, &stderr, func(tunnelFlags) (closeDialer, error) { + t.Fatal("invalid H2 profile reached doctor dialer construction") + return nil, nil + }) + if err == nil || commandExitCode(err) != doctorExitUsageFailure { + t.Fatalf("doctor invalid profile error = %v", err) + } + if strings.Contains(stdout.String()+stderr.String()+err.Error(), "private-invalid-value") { + t.Fatal("doctor exposed an invalid profile value") + } + if jsonOutput { + var failure doctorFailure + if err := json.Unmarshal(stdout.Bytes(), &failure); err != nil || failure.Code != "invalid_arguments" { + t.Fatalf("doctor JSON code = %q, decode error = %v", failure.Code, err) + } + } + } + // Internal callers must get the same preflight protection without + // relying on flag parsing or usable credential paths. + _, err := buildTunnelDialer(tunnelFlags{mode: mode, h2Fingerprint: "private-invalid-value"}) + if err == nil || !strings.Contains(err.Error(), "--h2-fingerprint") || strings.Contains(err.Error(), "private-invalid-value") { + t.Fatalf("constructor profile validation = %v", err) + } + }) + } + if calls := dnsCalls.Load(); calls != 0 { + t.Fatalf("invalid profile attempted %d DNS connections", calls) + } +} + +func TestH2FingerprintPreflightAcceptsSupportedProfiles(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + dnsCalls := denyPreflightDNS(t) + for _, mode := range []string{"h2", "web-auto"} { + for _, profile := range []string{"", "chrome-133", "chrome-155", "native"} { + t.Run(mode+"/"+profile, func(t *testing.T) { + args := append(files.clientArgs(), "--transport", mode, "--h2-fingerprint", profile) + if err := runClient(context.Background(), args); err != nil { + t.Fatal(err) + } + }) + } + } + if calls := dnsCalls.Load(); calls != 0 { + t.Fatalf("profile preflight attempted %d DNS connections", calls) + } +} + +// Observe the actual TLS ClientHello instead of inspecting private transport +// fields: Chrome 155 offers ML-DSA-65 (0x0905), whereas Chrome 133 does not. +// The server deliberately stops after capture; this is a propagation test, +// not a substitute for certificate verification or an authenticated exchange. +func TestH2FingerprintCLIReachesWire(t *testing.T) { + files := newPreflightFiles(t) + for _, mode := range []string{"h2", "web-auto"} { + for _, profile := range []string{"", "chrome-133", "chrome-155"} { + t.Run(mode+"/"+profile, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + captured := make(chan []tls.SignatureScheme, 1) + done := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + done <- err + return + } + defer conn.Close() + server := tls.Server(conn, &tls.Config{ + MinVersion: tls.VersionTLS13, + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + captured <- slices.Clone(hello.SignatureSchemes) + return nil, errors.New("test ClientHello capture complete") + }, + }) + done <- server.HandshakeContext(ctx) + }() + t.Cleanup(func() { + cancel() + _ = listener.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("ClientHello capture server did not stop") + } + }) + fs := flag.NewFlagSet("client", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + if err := fs.Parse([]string{ + "--server", listener.Addr().String(), "--server-name", "relay.invalid", + "--ca", files.cert, "--token-file", files.token, + "--transport", mode, "--h2-fingerprint", profile, + "--quic-attempt-timeout=1ns", "--open-timeout=4s", + }); err != nil { + t.Fatal(err) + } + dialer, err := buildTunnelDialer(tf) + if err != nil { + t.Fatal(err) + } + defer dialer.Close() + conn, err := dialer.DialContext(ctx, "tcp", "target.invalid:443") + if conn != nil { + _ = conn.Close() + } + if err == nil { + t.Fatal("capture-only TLS server unexpectedly accepted a tunnel") + } + select { + case signatures := <-captured: + got155 := slices.Contains(signatures, tls.SignatureScheme(0x0905)) + if want155 := profile == string(tunnel.FingerprintChrome155); got155 != want155 { + t.Fatalf("Chrome 155 signature algorithm present = %v, want %v; signatures = %v", got155, want155, signatures) + } + case <-ctx.Done(): + t.Fatal("H2 ClientHello was not captured") + } + }) + } + } +} diff --git a/cmd/autocar/init.go b/cmd/autocar/init.go index 85d6dac..5955c03 100644 --- a/cmd/autocar/init.go +++ b/cmd/autocar/init.go @@ -87,6 +87,7 @@ func runInitWith(args []string, output io.Writer) error { "ca": "server.crt", "token-file": "relay-token", "transport": transportMode, } if protocolMode == "web" { + clientOptions["h2-fingerprint"] = string(tunnel.FingerprintChrome155) clientOptions["h3-fingerprint"] = string(tunnel.H3FingerprintChrome202610) } clientConfig, err := json.MarshalIndent(clientOptions, "", " ") diff --git a/cmd/autocar/init_test.go b/cmd/autocar/init_test.go index 1dd9904..fcaa58a 100644 --- a/cmd/autocar/init_test.go +++ b/cmd/autocar/init_test.go @@ -134,11 +134,18 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { t.Fatal(err) } if wantProtocol == "web" { + if clientOptions["h2-fingerprint"] != string(tunnel.FingerprintChrome155) || tf.h2Fingerprint != h2FingerprintFlag(tunnel.FingerprintChrome155) { + t.Fatal("web bundle must explicitly pin the Chrome 155 H2 fingerprint") + } if clientOptions["h3-fingerprint"] != string(tunnel.H3FingerprintChrome202610) || tf.h3Fingerprint != h3FingerprintFlag(tunnel.H3FingerprintChrome202610) { t.Fatal("web bundle must explicitly pin the current H3 fingerprint") } - } else if _, ok := clientOptions["h3-fingerprint"]; ok { - t.Fatal("native bundle unexpectedly contains an H3 fingerprint option") + } else { + for _, option := range []string{"h2-fingerprint", "h3-fingerprint"} { + if _, ok := clientOptions[option]; ok { + t.Fatalf("native bundle unexpectedly contains %s", option) + } + } } if err := cert.VerifyHostname(tf.serverName); err != nil { t.Fatalf("certificate does not match generated client server name: %v", err) @@ -159,6 +166,11 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { if err != nil || server["protocol"] != wantProtocol || server["allow-private"] != nil || server["disable-tcp-fallback"] != nil { t.Fatal("generated server changed safe defaults") } + for _, option := range []string{"h2-fingerprint", "h3-fingerprint"} { + if _, ok := server[option]; ok { + t.Fatalf("server bundle unexpectedly contains client-only %s", option) + } + } if wantProtocol == "web" { if server["cover-root"] != "cover" || server["cover-upstream"] != nil { t.Fatal("web bundle does not use its isolated static cover directory") diff --git a/cmd/autocar/web_cli_test.go b/cmd/autocar/web_cli_test.go index b28e32e..55a35da 100644 --- a/cmd/autocar/web_cli_test.go +++ b/cmd/autocar/web_cli_test.go @@ -159,6 +159,9 @@ func TestTunnelHelpListsWebTransports(t *testing.T) { if flags.h3Fingerprint != h3FingerprintFlag(tunnel.H3FingerprintChrome202610) || !strings.Contains(output.String(), "h3-fingerprint") || !strings.Contains(output.String(), "chrome-2026-10") { t.Errorf("tunnel help omitted versioned H3 fingerprint profile: %s", output.String()) } + if flags.h2Fingerprint != h2FingerprintFlag(tunnel.FingerprintChrome133) || !strings.Contains(output.String(), "h2-fingerprint") || !strings.Contains(output.String(), "chrome-155") { + t.Errorf("tunnel help omitted H2 profiles or changed the legacy default: %s", output.String()) + } } func TestWebTunnelModesRejectNativeFixedRatePacing(t *testing.T) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca4c28e..ea83f2b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,8 +204,12 @@ version-information transport parameter. `--h3-fingerprint=native` disables Chro fork for interoperability and rollback; it does not select the native `autocar/2` transport. ChromeParrot is client-only: the relay remains an ordinary QUIC/H3 server, and H3 SETTINGS, CONNECT requests, application traffic and timing -are outside the handshake profile. H2 independently uses a fixed Chrome 133 -ClientHello but retains the Go HTTP/2 stack. +are outside the handshake profile. H2 independently supports fixed Chrome 133 +and Chrome 155 ClientHello profiles but retains the Go HTTP/2 stack. New web +`init` bundles explicitly pin `h2-fingerprint` to `chrome-155`; omitted values +keep Chrome 133 for compatibility. The explicit setting also reaches H2 fallback +in `web-auto`. A historical real-browser cold ClientHello fixture validates the +new template's normalized fields, not complete browser traffic equivalence. This version replaces, rather than aliases, the retired `chrome-2026-08` name. Explicit old names fail validation; omitted client profiles use the new default. diff --git a/docs/DEPENDENCY-MAINTENANCE.md b/docs/DEPENDENCY-MAINTENANCE.md index 46ffa4a..2b6c98b 100644 --- a/docs/DEPENDENCY-MAINTENANCE.md +++ b/docs/DEPENDENCY-MAINTENANCE.md @@ -26,6 +26,15 @@ private-error-text-triggered cold redial. This does not change the explicitly selected `chrome-133` or `chrome-2026-10` templates, enable H3 profile resumption, or establish passive browser similarity. +The separate H2 `chrome-155` option selects upstream's explicit +`HelloChrome_155`, never `HelloChrome_Auto`. It needs no further dependency +change. The older `chrome-133` remains distinct and is still selected when an +existing config omits the H2 profile; newly generated web bundles pin 155. +AutoCAR's real-wrapper cold-wire comparison retains the upstream browser +fixture, checksum, parser attribution and license under +`internal/tunnel/testdata/chrome155/`. This checks a bounded historical wire +reference, not browser equivalence or current-version freshness. + ## Updating the patch queue 1. Preserve the upstream history, copyright headers and all license/notice diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index afc4086..126a983 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -387,6 +387,7 @@ Recommended configuration: --system-roots \ --token-file /etc/autocar/relay-token \ --transport web-auto \ + --h2-fingerprint chrome-155 \ --h3-fingerprint chrome-2026-10 \ --quic-attempt-timeout 5s \ --open-timeout 15s \ @@ -401,6 +402,14 @@ The three web transport choices are: | `h3` | Require HTTP/3 over UDP for TCP CONNECT and CONNECT-UDP; no H2 fallback | | `h2` | Require HTTPS/HTTP/2 over TCP CONNECT; no H3 attempt and no UDP support | +`--h2-fingerprint=chrome-155` selects the fixed Chrome 155 ClientHello for +explicit `h2` and the H2 fallback in `web-auto`. The other choices are the +unchanged `chrome-133` profile and `native` (Go TLS, for interoperability and +debugging). JSON uses the same `h2-fingerprint` key. New web `init` client files +pin `chrome-155`; omitted values in existing configs still select `chrome-133`. +There is no moving "current Chrome" alias or silent rewrite of a named profile. +This setting does not change H3 or the server's TLS implementation. + `--h3-fingerprint=chrome-2026-10` is the default for `web-auto` and `h3`. It enables the fixed full client QUIC/TLS handshake profile and zero-length source CID supplied by the pinned `github.com/apernet/quic-go` fork. The profile is @@ -635,11 +644,18 @@ state. It does not measure browser similarity. The real-browser calibration starts a fresh browser profile for each sample and does not include a controlled warm reconnect, so its results cannot establish a resumption advantage or deficit. -The H2 client separately uses the fixed `chrome-133` uTLS ClientHello profile. -That describes only its TLS ClientHello; H2 settings, header order, flow control, +The H2 client separately offers fixed `chrome-133` and `chrome-155` uTLS +ClientHello profiles. The Chrome 155 regression compares bytes emitted by the +actual AutoCAR TLS wrapper with an independently captured official Chrome +155.0.8059.40 cold handshake, under empty-cache, nil-cache and disabled-ticket +policies. The [fixture and comparison boundaries](../internal/tunnel/testdata/chrome155/README.md) +record its source, date, checksum and normalization rules. This is reuse of a +historical browser capture, not a new browser run or a warm-handshake comparison. +Advertised trust-anchor IDs do not replace or expand the configured `RootCAs`. +These profiles describe only the TLS ClientHello; H2 settings, header order, flow control, connection reuse, payload sizes and timing retain their implementation behavior. Source builds after v1.0.1 also support ordinary TLS 1.3 session resumption for -this profile when the caller enables a TLS session cache. An empty cache keeps +both profiles when the caller enables a TLS session cache. An empty cache keeps the fixed cold ClientHello shape; a valid cached ticket adds uTLS's native `pre_shared_key` extension and binder as the last extension, as required by [RFC 8446 section 4.2.11](https://www.rfc-editor.org/rfc/rfc8446.html#section-4.2.11). @@ -647,13 +663,12 @@ The cache is private to one configured H2 client. A nil caller cache or `SessionTicketsDisabled` keeps full handshakes. This does not enable 0-RTT: every new physical connection completes TLS and starts fresh proxy authentication, even when TLS resumes; connection-scoped proxy tickets are never inherited. -The pinned uTLS implementation cannot rebuild a populated PSK after a TLS 1.3 -HelloRetryRequest. For this narrowly recognized library limitation, the H2 client -closes the failed socket and retries once on a fresh connection without a ticket, -within the same remaining initialization timeout. Certificate/hostname checks, -TLS 1.3 and h2 are still mandatory; unrelated TLS failures are not retried. -The library may invalidate the failed cached ticket. This compatibility fallback -is a full handshake, not successful HRR resumption or a browser-equivalence claim. +The pinned uTLS implementation rebuilds a populated PSK after a TLS 1.3 +HelloRetryRequest on the same physical connection. Regression tests require +the second ClientHello's binder, both peers' resumed state, and fresh proxy +authentication; the former private-error-text-triggered cold redial has been +removed. Certificate/hostname checks, TLS 1.3 and h2 remain mandatory. Functional +HRR resumption does not establish that warm handshakes match a browser. Resumption retains the previously verified TLS session rather than repeating a full certificate exchange or calling `VerifyPeerCertificate` again. Callers diff --git a/internal/tunnel/testdata/chrome155/LICENSE b/internal/tunnel/testdata/chrome155/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/internal/tunnel/testdata/chrome155/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/internal/tunnel/testdata/chrome155/README.md b/internal/tunnel/testdata/chrome155/README.md new file mode 100644 index 0000000..1aa0b8e --- /dev/null +++ b/internal/tunnel/testdata/chrome155/README.md @@ -0,0 +1,46 @@ +# Chrome 155 cold ClientHello reference + +`clienthello.hex` is copied byte-for-byte from +[`cppla/utls` commit `7295e1b508c33c2993ab878fad7432c9e7fdca9e`](https://github.com/cppla/utls/blob/7295e1b508c33c2993ab878fad7432c9e7fdca9e/testdata/chrome155_clienthello.hex). +The [upstream capture notes](https://github.com/cppla/utls/blob/7295e1b508c33c2993ab878fad7432c9e7fdca9e/testdata/chrome155_clienthello.md) +record official Google Chrome **155.0.8059.40**, macOS ARM64, October 6, 2026, +a fresh dedicated browser profile, QUIC disabled, and an HTTPS navigation to +`www.example.com`. This is a previously captured browser record, not a fresh +browser run by AutoCAR's test and not bytes emitted by the TLS library. + +The decoded TLS record is 1,955 bytes. The 1,950-byte ClientHello handshake, +excluding the five-byte record header, has SHA-256: + +```text +235618e3922e52a71a9f6593a71ec4ca182dab6b3ab4e90a2a1733133e97439a +``` + +No keylog, credentials, certificates, or application payload are retained here. +The parser in `../../web_chrome155_fixture_test.go` is adapted from the same +upstream commit's `u_chrome155_test.go`; its copyright header and BSD license +are retained. The parser is independent of the library's ClientHello generator +and Fingerprinter, but is not a separately developed third-party validator. + +The AutoCAR test records the actual H2 TLS wrapper writing to an in-process +peer, using the fixture's SNI. It checks eight cold handshakes for each policy: +empty enabled cache, nil cache, and disabled tickets. It compares ordered +ciphers, signatures, groups, versions, ALPN, key-share group/length, and the +exact ordered trust-anchor IDs, plus every other extension body. It normalizes +random/session-ID bytes, ordinary extension permutation, GREASE codepoints, ephemeral +key material, and GREASE ECH randomness. The ECH payload must remain in the +144/176/208/240-byte family. Cold PSK and early-data extensions are forbidden. +GREASE extensions retain their captured positions and corresponding bodies; +the comparison does not normalize them into the freely shuffled middle group. +The advertised trust-anchor IDs do not change AutoCAR's actual CA validation. + +Run from the repository root: + +```sh +go test ./internal/tunnel -run '^TestChrome155(ActualClientHelloMatchesBrowserFixture|ComparisonPreservesCapabilityDifferences)$' -count=1 +``` + +Passing is evidence of this **normalized cold ClientHello shape only**. It is +not a fresh browser experiment, a warm-browser resumption comparison, an H3 +profile update, a complete H2 fingerprint match, or a passive-classifier result. +TCP behavior, H2 SETTINGS/headers/flow control, server behavior, connection reuse, +packetization, workload sizes and timing remain outside this comparison. diff --git a/internal/tunnel/testdata/chrome155/clienthello.hex b/internal/tunnel/testdata/chrome155/clienthello.hex new file mode 100644 index 0000000..1d31499 --- /dev/null +++ b/internal/tunnel/testdata/chrome155/clienthello.hex @@ -0,0 +1 @@ +160301079e0100079a030397ba84e6911f26dc308204a2e65a2264ea769abc71617e9832e8a05327e2c03f20130452c827a0b862598e1f621324a0887d19f386284186f43e136844ed39fbb200201a1a130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f003501000731fafa000000120000ca3400ba00b80582df1302010582df1302060582df13020d0582df13020e0582df13020f0582df1302120582df1302130582df13021408839a648c9b2d010708839a648c9b2d010808839a648c9b2d010908839a648c9b2d010a08839a648c9b2d010b08839a648c9b2d010c08839a648c9b2d010d08839a648c9b2d011208839a648c9b2d011304d679090104d679090404d679090504d679090604d679090704d679090804d679090a04d679090b04d679090c04d679090d04d679090ffe0d00da00000100014200200b81d7a0c9bb3d4482028d5f90d7ed4f702aa257f7621d379be6d154251e745b00b0573e4731d509ac8e562a21f30236277f71fef7f0bd518c2c9916022a74afcb7d4d6145d72ee33f2f3720f85df5d6840bf3ce4dde5bc3efdacda6d4116add75ced6fa8c993692791f9437400ea835ff5f57776025cc7ff2828a93fff832dc24a1e394cf503813d67c4f45b0580f68d16fcaf5d774ac28a4bedac83733318c7cca132bebd041bba4077e53f51a2e30018559b2ed802ecf7398d369ab3090b8c4eb257b197631fad183682d83c8b40f82be001b0003020002000a000c000a7a7a11ec001d00170018ff0100010000230000002b0007065a5a030403030010000e000c02683208687474702f312e31000b00020100003304ef04ed7a7a00010011ec04c049c251fae6095e3992e4517d82ba97a8c7a789c66f450b0d9c3579870445f17856d8e0c53dfb643fe54032044236154d1fd722e7f6c3bf3606e84a68775638e765a7fdb85672dc3768941eaf232a55b229fe58cb41e35683b0532ff873249acd08ac19f79091c1acb54c53459d9ca31ac998ca6a67f172a1ef9b839151797e78589ca4aee75056be454adb070f6622577abc664b353fbc2272bf4c8cea7724dad8bfa0617d21555e3d0b69de388505b01d00ec263334c535c99699b20d8cd741f48646aea8c519334623ab33ea990701d7c400e48415da30a468953d5bbdc684aa78f1aca5c591a1805b69a786257099f5a02a833339bb9794dccc40b9188dd189596039623d76534ac1481e695b38974c5738a8107a16f5d4c19cd51d52c70bde8566b8500c73bba0e55ba09e067c6d3c76ddb99ae7da0fb7950d344bc86b1ac36827ae483a0ded32be2f897f66b96100440f6e148be9c19a3c1c7504e01eef443d4a674043e41f4f3c70029801a756227e5c95041053138472b19a103d95807ec57c02316499e72362642fe0c418c2306cdfb44dee570bc2783596a1464d5a16e4d59cb1bcb967e4459ce0430787141b547abeda15c418acd2f095addac18866cd99b2c70b3087c1b070f95320853a33b3d4bf9edca3cdf2b4a96c74154b57aab782d3e97d1801b95c2b586b7a0ca38bafc4d987ee600585ac8c359bc9c89997ed1231d87b28c0e32f90e16778a29cc2d3c4dbea79db389df497cf2210bd4dd46d7d21a6b3b683eb040bfca87019ec921b3a3ae7f20eb90a95449b70c2740fe4ea2c16e1c01ba5367676b48f58a6d7d657a52c61737b1671214b1210252107b441cc5c968a998616a77047a3d7516f28ac92438287b6bb178024566ce4a1b6f11efe9abb73894605b313567c60693c13f156a6fb55304134007c69128249a381281099a50cd18b0873a1697045bc8224bec2f87e4b09002703766e87b2c8845f56a7b77385562f41644710a36f9388fb4ab1311482b61c056b171919b3ac3b8cbf38e1971686ceead326e4654ded168b8ed8064b3032f23a7793c51700b9ba0cfbbc39390f3c82256898bb1933c1fec6cae242772e7b4076a6cf0d996c7cb6960930bea5a384083a18c00883ced91206c6401fb45b75fb44d073726d5bcaeffabd077705816c7ec7b03b0468a1f7238c660ac5f34b86b12968338c2817f45197cb04c5db396c4a13dc6a104663b57f43bad5063591c63d9cac2a199885a048809246bbd20191f211080d7286c7b61525382dada5ab9c175913cc0c9a011c4217575d1756d1e4b6b1da94b8cc1321a269d0310eb0374fe4bb39e41673d7742f335191290aaf3ec846a0d209546a97707938b3a307fe191599b59b26e1cfc92b6c69818a8de06c6e6215ce20c1057a8f7ed6912a5a89d64447aaa5b3b5a27cd41b8715fb44a502697e3185ca005077a194cfd2ce4b44cc3d5c7538e60718e90247ac1912329bf8e7c7e03078601b93a11163d5b0c1b34b1358608dfc282ebc18bd20f5a35c610e59c09f200875f05c10de1c7f27dc050296a49d4a03469c365f16a9b9a6c5def522832718e8022440019ad0d662237bb3535b912fab358c9a89fc944c201afe4a3c4d65837b31e2eb034759da63d960226e2669b646b549d3161427956172e60d876d0353ab787ac5cad550790a65ef062efbbf97f65f1cf62d001d0020d6b7a093831d73daf8e2aa6d7106321215cce52e6d14c408a0dafe217a207654000d001a0018aaaa0904090509060403080404010503080505010806060100170000002d0002010100050005010000000000000014001200000f7777772e6578616d706c652e636f6d44cd00050003026832caca000100 diff --git a/internal/tunnel/web_chrome155_fixture_test.go b/internal/tunnel/web_chrome155_fixture_test.go new file mode 100644 index 0000000..338bd27 --- /dev/null +++ b/internal/tunnel/web_chrome155_fixture_test.go @@ -0,0 +1,359 @@ +// Copyright 2026 The uTLS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license retained at testdata/chrome155/LICENSE. +// Adapted from cppla/utls 7295e1b508c3 u_chrome155_test.go. + +package tunnel + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "reflect" + "slices" + "sort" + "strings" + "testing" + "time" +) + +// This parser is deliberately independent of Fingerprinter: comparing the +// captured browser bytes against generated bytes must not hide a parser bug. +// Only extension permutation, GREASE codepoints, ephemeral public keys, and +// GREASE ECH randomness are normalized. Ordered capability lists and trust +// anchor IDs retain their original order and bytes. +type chrome155WireHello struct { + Version uint16 + RandomLength int + SessionIDLength int + CipherSuites []uint16 + CompressionMethods []byte + Extensions map[uint16][]string + FixedExtensions []string +} + +type chrome155Reader struct { + t *testing.T + b []byte + label string +} + +func (r *chrome155Reader) take(n int) []byte { + r.t.Helper() + if n < 0 || n > len(r.b) { + r.t.Fatalf("%s: truncated field: need %d bytes, have %d", r.label, n, len(r.b)) + } + b := r.b[:n] + r.b = r.b[n:] + return b +} + +func (r *chrome155Reader) number(n int) int { + r.t.Helper() + v := 0 + for _, b := range r.take(n) { + v = v<<8 | int(b) + } + return v +} + +func (r *chrome155Reader) vector(n int) []byte { + r.t.Helper() + return r.take(r.number(n)) +} + +func (r *chrome155Reader) end() { + r.t.Helper() + if len(r.b) != 0 { + r.t.Fatalf("%s: %d trailing bytes", r.label, len(r.b)) + } +} + +func chrome155NormalizeGREASE(v uint16) uint16 { + if chrome155IsGREASE(v) { + return 0x0a0a + } + return v +} + +func chrome155Uint16s(t *testing.T, b []byte) []uint16 { + t.Helper() + if len(b)%2 != 0 { + t.Fatalf("odd uint16 vector length: %d", len(b)) + } + out := make([]uint16, len(b)/2) + for i := range out { + out[i] = chrome155NormalizeGREASE(uint16(b[2*i])<<8 | uint16(b[2*i+1])) + } + return out +} + +func chrome155NormalizeExtension(t *testing.T, id uint16, body []byte) string { + t.Helper() + r := chrome155Reader{t, body, fmt.Sprintf("extension %#04x", id)} + var value string + switch id { + case 10, 13: // supported_groups, signature_algorithms + value = fmt.Sprint(chrome155Uint16s(t, r.vector(2))) + case 43: // supported_versions + value = fmt.Sprint(chrome155Uint16s(t, r.vector(1))) + case 51: // key_share + shares := chrome155Reader{t, r.vector(2), "key_share entries"} + var entries []string + for len(shares.b) > 0 { + group := uint16(shares.number(2)) + key := shares.vector(2) + entry := fmt.Sprintf("%04x/%d", chrome155NormalizeGREASE(group), len(key)) + if chrome155IsGREASE(group) { + entry += "/" + hex.EncodeToString(key) + } + entries = append(entries, entry) + } + value = strings.Join(entries, ",") + case 0xfe0d: // GREASE encrypted_client_hello + outer, kdf, aead := r.number(1), r.number(2), r.number(2) + r.take(1) // randomized config_id + enc, payload := r.vector(2), r.vector(2) + if outer != 0 || !slices.Contains([]int{144, 176, 208, 240}, len(payload)) { + t.Fatalf("unexpected GREASE ECH shape: outer=%d, payload length=%d", outer, len(payload)) + } + value = fmt.Sprintf("%d/%d/%d/enc=%d/payload=144,176,208,240", outer, kdf, aead, len(enc)) + case 0xca34: // trust_anchors + ids := chrome155Reader{t, r.vector(2), "trust anchor IDs"} + for len(ids.b) > 0 { + if len(ids.vector(1)) == 0 { + t.Fatal("empty trust anchor ID in fixture") + } + } + value = hex.EncodeToString(body) + default: + value = hex.EncodeToString(r.take(len(r.b))) + } + r.end() + return value +} + +func chrome155ParseWire(t *testing.T, data []byte) (chrome155WireHello, []uint16, map[uint16][]byte) { + t.Helper() + r := chrome155Reader{t, data, "ClientHello"} + if len(data) > 0 && data[0] == byte(22) { + if r.number(1) != 22 || r.number(2) != 0x0301 { + t.Fatal("unexpected ClientHello TLS record header") + } + record := r.vector(2) + r.end() + r = chrome155Reader{t, record, "ClientHello handshake"} + } + if r.number(1) != 1 { + t.Fatal("fixture is not a ClientHello") + } + body := r.vector(3) + r.end() + r = chrome155Reader{t, body, "ClientHello body"} + version := uint16(r.number(2)) + random := r.take(32) + sid := r.vector(1) + ciphers := chrome155Uint16s(t, r.vector(2)) + compression := bytes.Clone(r.vector(1)) + extensions := r.vector(2) + r.end() + out := chrome155WireHello{ + Version: version, RandomLength: len(random), SessionIDLength: len(sid), + CipherSuites: ciphers, CompressionMethods: compression, Extensions: make(map[uint16][]string), + } + r = chrome155Reader{t, extensions, "ClientHello extensions"} + seen, raw := make(map[uint16]bool), make(map[uint16][]byte) + var order []uint16 + for len(r.b) > 0 { + id := uint16(r.number(2)) + body := r.vector(2) + if seen[id] { + t.Fatalf("duplicate extension %#04x", id) + } + seen[id] = true + raw[id] = bytes.Clone(body) + normalizedID := chrome155NormalizeGREASE(id) + order = append(order, id) + out.Extensions[normalizedID] = append(out.Extensions[normalizedID], chrome155NormalizeExtension(t, id, body)) + } + for _, values := range out.Extensions { + sort.Strings(values) // distinguish the two GREASE bodies independently of their values + } + out.FixedExtensions = chrome155PinnedExtensions(order, raw) + return out, order, raw +} + +// Chrome shuffles ordinary extensions, but GREASE, padding and PSK retain +// their positions. Keep the body attached to its position as well as its ID. +func chrome155PinnedExtensions(order []uint16, raw map[uint16][]byte) []string { + var fixed []string + for position, id := range order { + if chrome155IsGREASE(id) || id == 21 || id == 41 { + fixed = append(fixed, fmt.Sprintf("%d/%04x/%x", position, chrome155NormalizeGREASE(id), raw[id])) + } + } + return fixed +} + +func chrome155Fixture(t *testing.T) []byte { + t.Helper() + text, err := os.ReadFile("testdata/chrome155/clienthello.hex") + if err != nil { + t.Fatal(err) + } + data, err := hex.DecodeString(strings.Join(strings.Fields(string(text)), "")) + if err != nil { + t.Fatal(err) + } + if len(data) != 1955 || fmt.Sprintf("%x", sha256.Sum256(data[5:])) != "235618e3922e52a71a9f6593a71ec4ca182dab6b3ab4e90a2a1733133e97439a" { + t.Fatal("browser fixture size or SHA-256 changed; audit provenance before updating") + } + return data +} + +func chrome155FixtureServerName(t *testing.T, raw map[uint16][]byte) string { + t.Helper() + r := chrome155Reader{t, raw[0], "server_name"} + names := chrome155Reader{t, r.vector(2), "server_name list"} + r.end() + if names.number(1) != 0 { + t.Fatal("fixture SNI is not a host name") + } + name := string(names.vector(2)) + names.end() + return name +} + +func chrome155IsGREASE(v uint16) bool { + return byte(v>>8) == byte(v) && v&0x0f0f == 0x0a0a +} + +// Capture the application's real connection wrapper writing bytes to a peer, +// not a preset or a Fingerprinter round-trip. No public network is contacted. +func captureChrome155Wire(t *testing.T, profile FingerprintProfile, config *tls.Config) []byte { + t.Helper() + client, peer := net.Pipe() + defer client.Close() + defer peer.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + deadline, _ := ctx.Deadline() + if err := peer.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + conn, err := newWebH2TLSClientConn(client, config, profile, newWebH2UTLSSessionCache(config)) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- conn.HandshakeContext(ctx) }() + // Always join the handshake goroutine, including on a failed read. + defer func() { + peer.Close() + if err := <-done; err == nil { + t.Error("capture-only peer unexpectedly completed a TLS handshake") + } + }() + var header [5]byte + if _, err := io.ReadFull(peer, header[:]); err != nil { + t.Fatal(err) + } + body := make([]byte, int(binary.BigEndian.Uint16(header[3:]))) + if _, err := io.ReadFull(peer, body); err != nil { + t.Fatal(err) + } + return append(header[:], body...) +} + +func TestChrome155ActualClientHelloMatchesBrowserFixture(t *testing.T) { + want, _, raw := chrome155ParseWire(t, chrome155Fixture(t)) + for _, policy := range []string{"empty-cache", "nil-cache", "tickets-disabled"} { + t.Run(policy, func(t *testing.T) { + config := &tls.Config{ + ServerName: chrome155FixtureServerName(t, raw), + RootCAs: x509.NewCertPool(), + ClientSessionCache: tls.NewLRUClientSessionCache(8), + } + if policy == "nil-cache" { + config.ClientSessionCache = nil + } + config.SessionTicketsDisabled = policy == "tickets-disabled" + // Exercise independent GREASE, ECH randomness and shuffled extensions. + for sample := 0; sample < 8; sample++ { + got, _, emitted := chrome155ParseWire(t, captureChrome155Wire(t, FingerprintChrome155, config)) + if !reflect.DeepEqual(got, want) { + t.Fatalf("sample %d differs from captured Chrome 155:\n got: %+v\nwant: %+v", sample, got, want) + } + if ticket, ok := emitted[35]; !ok || len(ticket) != 0 { + t.Fatal("cold ClientHello must contain an empty session_ticket") + } + for _, forbidden := range []uint16{41, 42} { + if _, ok := emitted[forbidden]; ok { + t.Fatalf("cold ClientHello must not contain extension %d (PSK/early_data)", forbidden) + } + } + } + }) + } +} + +func TestChrome155ComparisonPreservesCapabilityDifferences(t *testing.T) { + want, order, raw := chrome155ParseWire(t, chrome155Fixture(t)) + if len(want.FixedExtensions) != 2 || !chrome155IsGREASE(order[0]) || !chrome155IsGREASE(order[len(order)-1]) || len(raw[order[0]]) != 0 || !bytes.Equal(raw[order[len(order)-1]], []byte{0}) { + t.Fatal("expected fixed leading empty GREASE and trailing one-byte GREASE") + } + shuffled := slices.Clone(order) + shuffled[1], shuffled[2] = shuffled[2], shuffled[1] + if !reflect.DeepEqual(chrome155PinnedExtensions(shuffled, raw), want.FixedExtensions) { + t.Fatal("ordinary extension permutation should remain allowed") + } + shuffled[0], shuffled[1] = shuffled[1], shuffled[0] + if reflect.DeepEqual(chrome155PinnedExtensions(shuffled, raw), want.FixedExtensions) { + t.Fatal("comparison hid a displaced GREASE extension") + } + swappedBodies := make(map[uint16][]byte, len(raw)) + for id, body := range raw { + swappedBodies[id] = body + } + swappedBodies[order[0]], swappedBodies[order[len(order)-1]] = raw[order[len(order)-1]], raw[order[0]] + if reflect.DeepEqual(chrome155PinnedExtensions(order, swappedBodies), want.FixedExtensions) { + t.Fatal("comparison hid swapped positional GREASE bodies") + } + legacy, _, legacyRaw := chrome155ParseWire(t, captureChrome155Wire(t, FingerprintChrome133, &tls.Config{ + ServerName: chrome155FixtureServerName(t, raw), + RootCAs: x509.NewCertPool(), + })) + if reflect.DeepEqual(legacy, want) { + t.Fatal("normalization hid the Chrome 133/155 capability difference") + } + if _, ok := legacyRaw[0xca34]; ok { + t.Fatal("Chrome 155 trust anchors leaked into the immutable Chrome 133 profile") + } + if reflect.DeepEqual(legacy.Extensions[13], want.Extensions[13]) { + t.Fatal("comparison hid different signature algorithm lists") + } + // No normalization is permitted for the advertised trust-anchor IDs. + changed := bytes.Clone(raw[0xca34]) + changed[len(changed)-1] ^= 1 + if chrome155NormalizeExtension(t, 0xca34, changed) == chrome155NormalizeExtension(t, 0xca34, raw[0xca34]) { + t.Fatal("comparison hid a changed trust anchor") + } + // Ordered capabilities stay ordered, even when GREASE values are ignored. + signatures := bytes.Clone(raw[13]) + copy(signatures[4:6], raw[13][6:8]) + copy(signatures[6:8], raw[13][4:6]) + if chrome155NormalizeExtension(t, 13, signatures) == chrome155NormalizeExtension(t, 13, raw[13]) { + t.Fatal("comparison hid reordered signature schemes") + } + if chrome155IsGREASE(0x1a2a) || !chrome155IsGREASE(0x1a1a) { + t.Fatal("GREASE normalization must not mask non-GREASE values") + } +} diff --git a/internal/tunnel/web_client.go b/internal/tunnel/web_client.go index baea043..d8cd21e 100644 --- a/internal/tunnel/web_client.go +++ b/internal/tunnel/web_client.go @@ -30,6 +30,9 @@ type WebClientConfig struct { // H3FingerprintProfile defaults to the fixed chrome-2026-10 QUIC profile. // Native is an explicit interoperability and rollback path. H3FingerprintProfile H3FingerprintProfile + // H2FingerprintProfile selects the TCP fallback ClientHello independently + // of H3. Empty retains chrome-133; chrome-155 is an explicit opt-in. + H2FingerprintProfile FingerprintProfile HandshakeTimeout time.Duration H3DialTimeout time.Duration @@ -134,12 +137,13 @@ func NewWebClient(config WebClientConfig) (*WebClient, error) { return nil, fmt.Errorf("tunnel: configure web-cover H3 primary: %w", err) } h2, err := NewWebH2Client(WebH2ClientConfig{ - ServerAddress: config.ServerAddress, - Token: config.Token, - TLSConfig: config.TLSConfig, - HandshakeTimeout: config.HandshakeTimeout, - DialTimeout: config.H2DialTimeout, - WriteByteTimeout: config.H2WriteByteTimeout, + ServerAddress: config.ServerAddress, + Token: config.Token, + TLSConfig: config.TLSConfig, + FingerprintProfile: config.H2FingerprintProfile, + HandshakeTimeout: config.HandshakeTimeout, + DialTimeout: config.H2DialTimeout, + WriteByteTimeout: config.H2WriteByteTimeout, }) if err != nil { _ = h3.Close() diff --git a/internal/tunnel/web_client_test.go b/internal/tunnel/web_client_test.go index 6410773..c7c8d52 100644 --- a/internal/tunnel/web_client_test.go +++ b/internal/tunnel/web_client_test.go @@ -212,6 +212,10 @@ func TestWebClientEndToEndPrefersH3AndDetachesEstablishedStream(t *testing.T) { } func TestWebClientEndToEndFallsBackToH2WithoutUDPListener(t *testing.T) { + forEachWebH2ChromeProfile(t, testWebClientEndToEndFallsBackToH2WithoutUDPListener) +} + +func testWebClientEndToEndFallsBackToH2WithoutUDPListener(t *testing.T, profile FingerprintProfile) { targetAddress, closeTarget := startHalfCloseTarget(t) defer closeTarget() serverTLS, clientTLS := testTLSConfigs(t) @@ -228,6 +232,7 @@ func TestWebClientEndToEndFallsBackToH2WithoutUDPListener(t *testing.T) { ServerAddress: server.Addr().String(), Token: testToken, TLSConfig: clientTLS, + H2FingerprintProfile: profile, H3DialTimeout: 40 * time.Millisecond, PrimaryAttemptTimeout: 100 * time.Millisecond, FallbackCooldown: time.Minute, @@ -252,6 +257,42 @@ func TestWebClientEndToEndFallsBackToH2WithoutUDPListener(t *testing.T) { if got := client.SelectedTransport(); got != webAuthTransportH2 { t.Fatalf("selection = %q, want h2", got) } + fallback := client.fallback.dialer.(*WebH2Client) + if fallback.fingerprint != profile { + t.Fatalf("H2 fallback profile = %q, want %q", fallback.fingerprint, profile) + } +} + +func TestNewWebClientH2FingerprintPolicy(t *testing.T) { + for _, profile := range []FingerprintProfile{"", FingerprintChrome133, FingerprintChrome155, FingerprintNative, "chrome-current"} { + t.Run(string(profile), func(t *testing.T) { + _, clientTLS := testTLSConfigs(t) + client, err := NewWebClient(WebClientConfig{ + ServerAddress: "127.0.0.1:443", Token: testToken, TLSConfig: clientTLS, + H2FingerprintProfile: profile, + }) + if profile == "chrome-current" { + if err == nil || client != nil { + if client != nil { + _ = client.Close() + } + t.Fatal("web-auto accepted an unsupported H2 profile") + } + return + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + want := profile + if want == "" { + want = FingerprintChrome133 + } + if got := client.fallback.dialer.(*WebH2Client).fingerprint; got != want { + t.Fatalf("H2 fallback profile = %q, want %q", got, want) + } + }) + } } func TestWebClientTransportFailureFallsBackAndObservesCooldown(t *testing.T) { diff --git a/internal/tunnel/web_fingerprint.go b/internal/tunnel/web_fingerprint.go index 32df8da..386c4b6 100644 --- a/internal/tunnel/web_fingerprint.go +++ b/internal/tunnel/web_fingerprint.go @@ -13,9 +13,8 @@ import ( ) // FingerprintProfile names the TLS ClientHello profile used by the TCP web -// transport. Profile names are deliberately versioned: chrome-133 means the -// explicit HelloChrome_133 reference originally shipped in uTLS v1.8.2, -// not "current Chrome" or the moving HelloChrome_Auto alias. +// transport. Profile names select explicit, fixed uTLS ClientHello references, +// never "current Chrome" or the moving HelloChrome_Auto alias. type FingerprintProfile string const ( @@ -23,6 +22,11 @@ const ( // uTLS v1.8.2. The explicit HelloChrome_133 remains the default for H2. FingerprintChrome133 FingerprintProfile = "chrome-133" + // FingerprintChrome155 selects the Chrome 155.0.8059.40 macOS cold + // ClientHello reference captured on 2026-10-06. It is an explicit opt-in; + // omitted profiles retain Chrome 133 for configuration compatibility. + FingerprintChrome155 FingerprintProfile = "chrome-155" + // FingerprintNative uses Go's crypto/tls ClientHello. It is retained for // tests and protocol debugging, not as an anti-fingerprinting profile. FingerprintNative FingerprintProfile = "native" @@ -32,10 +36,10 @@ func normalizeFingerprintProfile(profile FingerprintProfile) (FingerprintProfile switch profile { case "", FingerprintChrome133: return FingerprintChrome133, nil - case FingerprintNative: - return FingerprintNative, nil + case FingerprintChrome155, FingerprintNative: + return profile, nil default: - return "", fmt.Errorf("tunnel: unsupported web-cover HTTP/2 fingerprint profile %q; supported profiles are %q and %q", profile, FingerprintChrome133, FingerprintNative) + return "", fmt.Errorf("tunnel: unsupported web-cover HTTP/2 fingerprint profile %q; supported profiles are %q, %q and %q", profile, FingerprintChrome133, FingerprintChrome155, FingerprintNative) } } @@ -96,8 +100,8 @@ func newWebH2TLSClientConn(raw net.Conn, config *tls.Config, profile Fingerprint return nil, errors.New("tunnel: nil web-cover HTTP/2 TLS config") } switch profile { - case FingerprintChrome133: - utlsConfig, err := chrome133UTLSConfig(config, sessionCache) + case FingerprintChrome133, FingerprintChrome155: + utlsConfig, err := chromeUTLSConfig(config, sessionCache) if err != nil { return nil, err } @@ -105,13 +109,18 @@ func newWebH2TLSClientConn(raw net.Conn, config *tls.Config, profile Fingerprint // custom copy when resumption is enabled so uTLS can populate its own // PSK identity and binder, without changing the empty-cache wire shape. resume := utlsConfig.ClientSessionCache != nil && !utlsConfig.SessionTicketsDisabled - hello := utls.HelloChrome_133 + profileID := utls.HelloChrome_133 + if profile == FingerprintChrome155 { + profileID = utls.HelloChrome_155 + } + hello := profileID if resume { hello = utls.HelloCustom } return &webH2UTLSConn{ - UConn: utls.UClient(raw, utlsConfig, hello), - prepareChrome133: resume, + UConn: utls.UClient(raw, utlsConfig, hello), + chromeHelloID: profileID, + prepareResume: resume, }, nil case FingerprintNative: return tls.Client(raw, config.Clone()), nil @@ -120,11 +129,11 @@ func newWebH2TLSClientConn(raw net.Conn, config *tls.Config, profile Fingerprint } } -// chrome133UTLSConfig translates the security-relevant client subset from the +// chromeUTLSConfig translates the security-relevant client subset from the // already hardened crypto/tls configuration. uTLS and crypto/tls intentionally // use distinct Config and Certificate types, so this conversion stays explicit // and fail-closed for callbacks that cannot be preserved exactly. -func chrome133UTLSConfig(input *tls.Config, sessionCache utls.ClientSessionCache) (*utls.Config, error) { +func chromeUTLSConfig(input *tls.Config, sessionCache utls.ClientSessionCache) (*utls.Config, error) { if input == nil { return nil, errors.New("tunnel: nil web-cover HTTP/2 TLS config") } @@ -132,10 +141,10 @@ func chrome133UTLSConfig(input *tls.Config, sessionCache utls.ClientSessionCache return nil, errors.New("tunnel: InsecureSkipVerify is forbidden") } if input.GetClientCertificate != nil { - return nil, errors.New("tunnel: chrome-133 fingerprint profile does not support dynamic GetClientCertificate; configure static client Certificates or use the native test/debug profile") + return nil, errors.New("tunnel: Chrome fingerprint profiles do not support dynamic GetClientCertificate; configure static client Certificates or use the native test/debug profile") } if input.VerifyConnection != nil { - return nil, errors.New("tunnel: chrome-133 fingerprint profile cannot preserve crypto/tls VerifyConnection exactly; use VerifyPeerCertificate or the native test/debug profile") + return nil, errors.New("tunnel: Chrome fingerprint profiles cannot preserve crypto/tls VerifyConnection exactly; use VerifyPeerCertificate or the native test/debug profile") } certificates := make([]utls.Certificate, len(input.Certificates)) @@ -202,20 +211,21 @@ func cloneCertificateForUTLS(input tls.Certificate) utls.Certificate { type webH2UTLSConn struct { *utls.UConn - prepareChrome133 bool + chromeHelloID utls.ClientHelloID + prepareResume bool prepareOnce sync.Once prepareErr error handshakeComplete atomic.Bool } // HandshakeContext enforces the application's TLS 1.3-only policy after the -// fixed browser ClientHello is applied. The Chrome 133 wire profile advertises -// its historical TLS 1.2 compatibility; uTLS therefore cannot express the +// fixed browser ClientHello is applied. Both Chrome wire profiles advertise +// their historical TLS 1.2 compatibility; uTLS therefore cannot express the // stricter application policy in the visible supported_versions extension // without ceasing to be that profile. Failing before any HTTP bytes are sent // keeps the policy fail-closed even if a future caller forgets a second check. func (c *webH2UTLSConn) HandshakeContext(ctx context.Context) error { - if c.prepareChrome133 { + if c.prepareResume { if !c.handshakeComplete.Load() { if err := ctx.Err(); err != nil { return err @@ -225,7 +235,7 @@ func (c *webH2UTLSConn) HandshakeContext(ctx context.Context) error { // ApplyPreset generates entropy and key shares: keep it inside the // caller's handshake budget rather than in the connection factory. // Each connection owns the extension pointers mutated by uTLS. - spec, err := utls.UTLSIdToSpec(utls.HelloChrome_133) + spec, err := utls.UTLSIdToSpec(c.chromeHelloID) if err == nil { // TLS 1.3 requires this extension to be last. OmitEmptyPsk // suppresses it until a valid cached session is available. diff --git a/internal/tunnel/web_fingerprint_resumption_policy_test.go b/internal/tunnel/web_fingerprint_resumption_policy_test.go index 28d2c65..a84d77c 100644 --- a/internal/tunnel/web_fingerprint_resumption_policy_test.go +++ b/internal/tunnel/web_fingerprint_resumption_policy_test.go @@ -17,7 +17,15 @@ import ( utls "github.com/refraction-networking/utls" ) -func TestChrome133ResumptionPolicyFactoryIsLazy(t *testing.T) { +func TestChromeResumptionPolicyFactoryIsLazy(t *testing.T) { + forEachWebH2ChromeProfile(t, testChromeResumptionPolicyFactoryIsLazy) +} + +func testChromeResumptionPolicyFactoryIsLazy(t *testing.T, profile FingerprintProfile) { + profileID := utls.HelloChrome_133 + if profile == FingerprintChrome155 { + profileID = utls.HelloChrome_155 + } for _, test := range []struct { name string disabled bool @@ -25,9 +33,9 @@ func TestChrome133ResumptionPolicyFactoryIsLazy(t *testing.T) { wantID utls.ClientHelloID }{ {name: "enabled-cache", cache: true, wantID: utls.HelloCustom}, - {name: "nil-cache", wantID: utls.HelloChrome_133}, + {name: "nil-cache", wantID: profileID}, // Supplying a cache cannot override SessionTicketsDisabled. - {name: "tickets-disabled-with-cache", disabled: true, cache: true, wantID: utls.HelloChrome_133}, + {name: "tickets-disabled-with-cache", disabled: true, cache: true, wantID: profileID}, } { t.Run(test.name, func(t *testing.T) { entropy := &webResumptionPolicyRand{} @@ -42,7 +50,7 @@ func TestChrome133ResumptionPolicyFactoryIsLazy(t *testing.T) { if test.cache { cache = utls.NewLRUClientSessionCache(1) } - client, err := newWebH2TLSClientConn(raw, config, FingerprintChrome133, cache) + client, err := newWebH2TLSClientConn(raw, config, profile, cache) if err != nil { t.Fatal(err) } @@ -63,7 +71,11 @@ func TestChrome133ResumptionPolicyFactoryIsLazy(t *testing.T) { } } -func TestChrome133ResumptionPolicyPreCancelledHandshakeRemainsReusable(t *testing.T) { +func TestChromeResumptionPolicyPreCancelledHandshakeRemainsReusable(t *testing.T) { + forEachWebH2ChromeProfile(t, testChromeResumptionPolicyPreCancelledHandshakeRemainsReusable) +} + +func testChromeResumptionPolicyPreCancelledHandshakeRemainsReusable(t *testing.T, profile FingerprintProfile) { serverTLS, clientTLS := testTLSConfigs(t) serverTLS.MinVersion = tls.VersionTLS13 serverTLS.MaxVersion = tls.VersionTLS13 @@ -77,7 +89,7 @@ func TestChrome133ResumptionPolicyPreCancelledHandshakeRemainsReusable(t *testin defer clientPipe.Close() defer serverPipe.Close() raw := &webResumptionPolicyConn{Conn: clientPipe} - client, err := newWebH2TLSClientConn(raw, clientTLS, FingerprintChrome133, newWebH2UTLSSessionCache(clientTLS)) + client, err := newWebH2TLSClientConn(raw, clientTLS, profile, newWebH2UTLSSessionCache(clientTLS)) if err != nil { t.Fatal(err) } @@ -131,7 +143,11 @@ func TestChrome133ResumptionPolicyPreCancelledHandshakeRemainsReusable(t *testin } } -func TestChrome133ResumptionPolicyConcurrentPreCancellationDoesNotWaitForPeer(t *testing.T) { +func TestChromeResumptionPolicyConcurrentPreCancellationDoesNotWaitForPeer(t *testing.T) { + forEachWebH2ChromeProfile(t, testChromeResumptionPolicyConcurrentPreCancellationDoesNotWaitForPeer) +} + +func testChromeResumptionPolicyConcurrentPreCancellationDoesNotWaitForPeer(t *testing.T, profile FingerprintProfile) { entropy := &webResumptionPolicyRand{} config := &tls.Config{ ServerName: "cover.example", @@ -142,7 +158,7 @@ func TestChrome133ResumptionPolicyConcurrentPreCancellationDoesNotWaitForPeer(t defer clientPipe.Close() defer serverPipe.Close() raw := &webResumptionPolicyConn{Conn: clientPipe, writeStarted: make(chan struct{}, 1)} - client, err := newWebH2TLSClientConn(raw, config, FingerprintChrome133, newWebH2UTLSSessionCache(config)) + client, err := newWebH2TLSClientConn(raw, config, profile, newWebH2UTLSSessionCache(config)) if err != nil { t.Fatal(err) } @@ -204,7 +220,11 @@ func TestChrome133ResumptionPolicyConcurrentPreCancellationDoesNotWaitForPeer(t } } -func TestChrome133ResumptionPolicyPreparationErrorIsStableAndDoesNotTouchSocket(t *testing.T) { +func TestChromeResumptionPolicyPreparationErrorIsStableAndDoesNotTouchSocket(t *testing.T) { + forEachWebH2ChromeProfile(t, testChromeResumptionPolicyPreparationErrorIsStableAndDoesNotTouchSocket) +} + +func testChromeResumptionPolicyPreparationErrorIsStableAndDoesNotTouchSocket(t *testing.T, profile FingerprintProfile) { fault := errors.New("resumption-policy entropy fault") entropy := &webResumptionPolicyRand{fault: fault} raw := &webResumptionPolicyConn{} @@ -213,7 +233,7 @@ func TestChrome133ResumptionPolicyPreparationErrorIsStableAndDoesNotTouchSocket( Rand: entropy, ClientSessionCache: tls.NewLRUClientSessionCache(1), } - client, err := newWebH2TLSClientConn(raw, config, FingerprintChrome133, newWebH2UTLSSessionCache(config)) + client, err := newWebH2TLSClientConn(raw, config, profile, newWebH2UTLSSessionCache(config)) if err != nil { t.Fatalf("factory eagerly prepared the handshake: %v", err) } @@ -244,7 +264,11 @@ func TestChrome133ResumptionPolicyPreparationErrorIsStableAndDoesNotTouchSocket( } } -func TestChrome133ResumptionPolicyStillVerifiesPeer(t *testing.T) { +func TestChromeResumptionPolicyStillVerifiesPeer(t *testing.T) { + forEachWebH2ChromeProfile(t, testChromeResumptionPolicyStillVerifiesPeer) +} + +func testChromeResumptionPolicyStillVerifiesPeer(t *testing.T, profile FingerprintProfile) { for _, name := range []string{"untrusted-roots", "wrong-server-name"} { t.Run(name, func(t *testing.T) { serverTLS, clientTLS := testTLSConfigs(t) @@ -263,7 +287,7 @@ func TestChrome133ResumptionPolicyStillVerifiesPeer(t *testing.T) { defer clientPipe.Close() defer serverPipe.Close() raw := &webResumptionPolicyConn{Conn: clientPipe} - client, err := newWebH2TLSClientConn(raw, clientTLS, FingerprintChrome133, newWebH2UTLSSessionCache(clientTLS)) + client, err := newWebH2TLSClientConn(raw, clientTLS, profile, newWebH2UTLSSessionCache(clientTLS)) if err != nil { t.Fatal(err) } diff --git a/internal/tunnel/web_fingerprint_test.go b/internal/tunnel/web_fingerprint_test.go index b85dfdf..de773e1 100644 --- a/internal/tunnel/web_fingerprint_test.go +++ b/internal/tunnel/web_fingerprint_test.go @@ -14,6 +14,13 @@ import ( "time" ) +func forEachWebH2ChromeProfile(t *testing.T, test func(*testing.T, FingerprintProfile)) { + t.Helper() + for _, profile := range []FingerprintProfile{FingerprintChrome133, FingerprintChrome155} { + t.Run(string(profile), func(t *testing.T) { test(t, profile) }) + } +} + func TestFingerprintProfileValidation(t *testing.T) { for _, input := range []FingerprintProfile{"", FingerprintChrome133} { got, err := normalizeFingerprintProfile(input) @@ -24,6 +31,9 @@ func TestFingerprintProfileValidation(t *testing.T) { t.Fatalf("normalizeFingerprintProfile(%q) = %q, want %q", input, got, FingerprintChrome133) } } + if got, err := normalizeFingerprintProfile(FingerprintChrome155); err != nil || got != FingerprintChrome155 { + t.Fatalf("Chrome 155 profile = %q, %v", got, err) + } if got, err := normalizeFingerprintProfile(FingerprintNative); err != nil || got != FingerprintNative { t.Fatalf("native profile = %q, %v", got, err) } @@ -127,7 +137,7 @@ func TestChrome133ClientHelloHasVersionedBrowserShape(t *testing.T) { } } -func TestChrome133TLSConfigRemainsVerified(t *testing.T) { +func TestChromeTLSConfigRemainsVerified(t *testing.T) { roots := x509.NewCertPool() input := &tls.Config{ ServerName: "cover.example", @@ -136,7 +146,7 @@ func TestChrome133TLSConfigRemainsVerified(t *testing.T) { ClientSessionCache: tls.NewLRUClientSessionCache(64), } cache := newWebH2UTLSSessionCache(input) - got, err := chrome133UTLSConfig(input, cache) + got, err := chromeUTLSConfig(input, cache) if err != nil { t.Fatal(err) } @@ -158,27 +168,27 @@ func TestChrome133TLSConfigRemainsVerified(t *testing.T) { insecure := input.Clone() insecure.InsecureSkipVerify = true - if _, err := chrome133UTLSConfig(insecure, cache); err == nil { + if _, err := chromeUTLSConfig(insecure, cache); err == nil { t.Fatal("Chrome profile accepted InsecureSkipVerify") } callback := input.Clone() callback.VerifyConnection = func(tls.ConnectionState) error { return nil } - if _, err := chrome133UTLSConfig(callback, cache); err == nil { + if _, err := chromeUTLSConfig(callback, cache); err == nil { t.Fatal("Chrome profile silently dropped VerifyConnection") } } -func TestChrome133SessionCacheFollowsCallerPolicyAndIsReusable(t *testing.T) { +func TestChromeSessionCacheFollowsCallerPolicyAndIsReusable(t *testing.T) { enabled := &tls.Config{ClientSessionCache: tls.NewLRUClientSessionCache(2)} cache := newWebH2UTLSSessionCache(enabled) if cache == nil { t.Fatal("enabled standard TLS resumption did not create a uTLS cache") } - first, err := chrome133UTLSConfig(enabled, cache) + first, err := chromeUTLSConfig(enabled, cache) if err != nil { t.Fatal(err) } - second, err := chrome133UTLSConfig(enabled, cache) + second, err := chromeUTLSConfig(enabled, cache) if err != nil { t.Fatal(err) } @@ -192,7 +202,7 @@ func TestChrome133SessionCacheFollowsCallerPolicyAndIsReusable(t *testing.T) { if disabledCache != nil { t.Fatal("disabled session tickets created a cache") } - got, err := chrome133UTLSConfig(disabled, disabledCache) + got, err := chromeUTLSConfig(disabled, disabledCache) if err != nil { t.Fatal(err) } @@ -201,7 +211,11 @@ func TestChrome133SessionCacheFollowsCallerPolicyAndIsReusable(t *testing.T) { } } -func TestChrome133ProfileRejectsTLS12Negotiation(t *testing.T) { +func TestChromeProfilesRejectTLS12Negotiation(t *testing.T) { + forEachWebH2ChromeProfile(t, testChromeProfileRejectsTLS12Negotiation) +} + +func testChromeProfileRejectsTLS12Negotiation(t *testing.T, profile FingerprintProfile) { serverTLS, clientTLS := testTLSConfigs(t) serverTLS.MinVersion = tls.VersionTLS12 serverTLS.MaxVersion = tls.VersionTLS12 @@ -217,7 +231,7 @@ func TestChrome133ProfileRejectsTLS12Negotiation(t *testing.T) { serverDone := make(chan error, 1) go func() { serverDone <- server.Handshake() }() - client, err := newWebH2TLSClientConn(clientSide, clientTLS, FingerprintChrome133, newWebH2UTLSSessionCache(clientTLS)) + client, err := newWebH2TLSClientConn(clientSide, clientTLS, profile, newWebH2UTLSSessionCache(clientTLS)) if err != nil { t.Fatal(err) } diff --git a/internal/tunnel/web_h2_client.go b/internal/tunnel/web_h2_client.go index c0f794f..a6956e6 100644 --- a/internal/tunnel/web_h2_client.go +++ b/internal/tunnel/web_h2_client.go @@ -30,7 +30,7 @@ type WebH2ClientConfig struct { TLSConfig *tls.Config // FingerprintProfile defaults to chrome-133, the fixed Chrome 133 // explicit HelloChrome_133 reference originally shipped in uTLS v1.8.2. - // Native is for tests/debugging. + // Chrome 155 is an explicit opt-in; native is for tests/debugging. FingerprintProfile FingerprintProfile HandshakeTimeout time.Duration // DialTimeout bounds each TCP connect, separately from TLS/H2 setup. @@ -162,9 +162,9 @@ func newWebH2ClientWithSigner(config WebH2ClientConfig, auth *webAuthSigner, cla // Validate the conversion at construction time so unsupported security // callbacks never fail only after the first network dial. var utlsSessionCache utls.ClientSessionCache - if fingerprint == FingerprintChrome133 { + if fingerprint == FingerprintChrome133 || fingerprint == FingerprintChrome155 { utlsSessionCache = newWebH2UTLSSessionCache(tlsConfig) - if _, err := chrome133UTLSConfig(tlsConfig, utlsSessionCache); err != nil { + if _, err := chromeUTLSConfig(tlsConfig, utlsSessionCache); err != nil { return nil, err } } diff --git a/internal/tunnel/web_h2_dial_sharing_test.go b/internal/tunnel/web_h2_dial_sharing_test.go index 483380a..692ebb7 100644 --- a/internal/tunnel/web_h2_dial_sharing_test.go +++ b/internal/tunnel/web_h2_dial_sharing_test.go @@ -377,7 +377,7 @@ func (f *webH2DialSharingFixture) raw(raws <-chan *webH2DialSharingRaw) *webH2Di } func TestWebH2DialSharingFailureAndLaterRetry(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { f := newWebH2DialSharingFixture(t) _, clientTLS := testTLSConfigs(t) @@ -494,7 +494,7 @@ func (f *webH2DialSharingFixture) target() string { } func TestWebH2DialInitiatingCallerCancellationKeepsPhysicalInitialization(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { f := newWebH2DialSharingFixture(t) serverTLS, clientTLS := testTLSConfigs(t) @@ -643,7 +643,7 @@ func TestWebH2DialInitiatingCallerCancellationKeepsPhysicalInitialization(t *tes } func TestWebH2DialAllWaitersAbandonThenClose(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { f := newWebH2DialSharingFixture(t) _, clientTLS := testTLSConfigs(t) diff --git a/internal/tunnel/web_h2_initialization_test.go b/internal/tunnel/web_h2_initialization_test.go index d9a8201..cad76ec 100644 --- a/internal/tunnel/web_h2_initialization_test.go +++ b/internal/tunnel/web_h2_initialization_test.go @@ -17,7 +17,7 @@ import ( ) func TestWebH2PostTLSInitializationHonorsCancellation(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { for _, mode := range []string{"caller_cancel", "caller_deadline", "client_close", "initialization_deadline"} { t.Run(mode, func(t *testing.T) { @@ -170,7 +170,7 @@ func TestWebH2PostTLSInitializationHonorsCancellation(t *testing.T) { } func TestWebH2InitializationWatcherDetachesAfterSuccess(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { serverTLS, clientTLS := testTLSConfigs(t) server := startWebH2TestServer(t, WebH2ServerConfig{ @@ -202,7 +202,7 @@ func TestWebH2InitializationWatcherDetachesAfterSuccess(t *testing.T) { } func TestWebH2CloseAtInitializationHandoff(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { for range 10 { t.Run("close", func(t *testing.T) { diff --git a/internal/tunnel/web_h2_resumption_fallback_test.go b/internal/tunnel/web_h2_resumption_fallback_test.go index 5353312..d76a139 100644 --- a/internal/tunnel/web_h2_resumption_fallback_test.go +++ b/internal/tunnel/web_h2_resumption_fallback_test.go @@ -20,11 +20,15 @@ import ( ) func TestWebH2ResumptionHRRInitializationCancellation(t *testing.T) { + forEachWebH2ChromeProfile(t, testWebH2ResumptionHRRInitializationCancellation) +} + +func testWebH2ResumptionHRRInitializationCancellation(t *testing.T, profile FingerprintProfile) { for _, stage := range []string{"hrr_read", "h2_preface"} { t.Run(stage, func(t *testing.T) { for _, mode := range []string{"handshake_timeout", "caller_cancel", "client_close"} { t.Run(mode, func(t *testing.T) { - fixture := webH2HRRPrime(t) + fixture := webH2HRRPrime(t, profile) fixture.client.handshakeTimeout = 1500 * time.Millisecond warm := webH2HRRInstallWarm(t, fixture) warm.blockPreface = stage == "h2_preface" @@ -83,7 +87,11 @@ func TestWebH2ResumptionHRRInitializationCancellation(t *testing.T) { } func TestWebH2ResumptionHRRWriteFailureDoesNotRetry(t *testing.T) { - fixture := webH2HRRPrime(t) + forEachWebH2ChromeProfile(t, testWebH2ResumptionHRRWriteFailureDoesNotRetry) +} + +func testWebH2ResumptionHRRWriteFailureDoesNotRetry(t *testing.T, profile FingerprintProfile) { + fixture := webH2HRRPrime(t, profile) warm := webH2HRRInstallWarm(t, fixture) // Even the retired library's exact error text is only an I/O failure here. // A genuine HRR must not turn it into an extra cold physical connection. @@ -115,9 +123,13 @@ func TestWebH2ResumptionHRRWriteFailureDoesNotRetry(t *testing.T) { } func TestWebH2ResumptionDoesNotRetryOtherTLSErrors(t *testing.T) { + forEachWebH2ChromeProfile(t, testWebH2ResumptionDoesNotRetryOtherTLSErrors) +} + +func testWebH2ResumptionDoesNotRetryOtherTLSErrors(t *testing.T, profile FingerprintProfile) { for _, mode := range []string{"certificate", "alpn", "matching_text_without_hrr"} { t.Run(mode, func(t *testing.T) { - fixture := webH2HRRPrime(t) + fixture := webH2HRRPrime(t, profile) serverTLS := fixture.serverTLS.Clone() serverTLS.SessionTicketsDisabled = true serverTLS.CurvePreferences = []tls.CurveID{tls.X25519} @@ -196,13 +208,13 @@ type webH2HRRFixture struct { // GET primes only TLS tickets and H2 initialization, deliberately bypassing // CONNECT/app authentication. Healthy authenticated HRR tests live separately. -func webH2HRRPrime(t *testing.T) *webH2HRRFixture { +func webH2HRRPrime(t *testing.T, profile FingerprintProfile) *webH2HRRFixture { t.Helper() serverTLS, config := webH2ResumptionTLSConfigs(t) waitingSecondHello := make(chan struct{}) address, attempts, requests, handlerDone, destination := webH2HRRServer(t, serverTLS, "unused.invalid:443", 2, waitingSecondHello) config.ClientSessionCache = tls.NewLRUClientSessionCache(2) - client, err := NewWebH2Client(WebH2ClientConfig{ServerAddress: address, Token: webTestToken, TLSConfig: config, HandshakeTimeout: 3 * time.Second}) + client, err := NewWebH2Client(WebH2ClientConfig{ServerAddress: address, Token: webTestToken, TLSConfig: config, FingerprintProfile: profile, HandshakeTimeout: 3 * time.Second}) if err != nil { t.Fatal(err) } diff --git a/internal/tunnel/web_h2_resumption_hrr_test.go b/internal/tunnel/web_h2_resumption_hrr_test.go index 9ce2da9..9683f42 100644 --- a/internal/tunnel/web_h2_resumption_hrr_test.go +++ b/internal/tunnel/web_h2_resumption_hrr_test.go @@ -400,6 +400,9 @@ func TestWebH2ResumptionHRRKeepsHealthyTunnel(t *testing.T) { {name: "chrome_enabled", profile: FingerprintChrome133, cacheEnabled: true, resumed: true}, {name: "chrome_nil_cache", profile: FingerprintChrome133}, {name: "chrome_tickets_disabled", profile: FingerprintChrome133, cacheEnabled: true, ticketsDisabled: true}, + {name: "chrome155_enabled", profile: FingerprintChrome155, cacheEnabled: true, resumed: true}, + {name: "chrome155_nil_cache", profile: FingerprintChrome155}, + {name: "chrome155_tickets_disabled", profile: FingerprintChrome155, cacheEnabled: true, ticketsDisabled: true}, {name: "native_positive", profile: FingerprintNative, cacheEnabled: true, resumed: true}, } { t.Run(test.name, func(t *testing.T) { @@ -419,7 +422,7 @@ func TestWebH2ResumptionHRRKeepsHealthyTunnel(t *testing.T) { } t.Cleanup(func() { _ = client.Close() }) var chromeCache *webH2ResumptionUTLSCache - if test.profile == FingerprintChrome133 && test.resumed { + if (test.profile == FingerprintChrome133 || test.profile == FingerprintChrome155) && test.resumed { chromeCache = &webH2ResumptionUTLSCache{inner: client.utlsSessionCache} client.utlsSessionCache = chromeCache } diff --git a/internal/tunnel/web_h2_resumption_test.go b/internal/tunnel/web_h2_resumption_test.go index 75ee800..6eeb4a6 100644 --- a/internal/tunnel/web_h2_resumption_test.go +++ b/internal/tunnel/web_h2_resumption_test.go @@ -246,15 +246,19 @@ func webH2ResumptionOrigin(t *testing.T, serverTLS *tls.Config) (string, <-chan return listener.Addr().String(), results, requests, handlerDone, &dialCalls } -func webH2ResumptionCheckChromeHello(t *testing.T, hello parsedClientHello, warm bool) { +func webH2ResumptionCheckChromeHello(t *testing.T, hello parsedClientHello, profile FingerprintProfile, warm bool) { t.Helper() wantCiphers := []uint16{0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9, 0xcca8, 0xc013, 0xc014, 0x009c, 0x009d, 0x002f, 0x0035} if got := withoutGREASE(hello.cipherSuites); !reflect.DeepEqual(got, wantCiphers) { t.Errorf("Chrome cold/warm cipher order changed: %#v", got) } - // This exact non-GREASE membership is the original pinned Chrome-133 - // preset. Order is deliberately not frozen: the preset shuffles it. + // Both fixed profiles retain this common non-GREASE membership; Chrome155 + // additionally advertises its captured trust-anchor IDs. Extension order + // is deliberately not frozen: the presets shuffle it. wantExtensions := []uint16{0, 5, 10, 11, 13, 16, 18, 23, 27, 35, 43, 45, 51, 17613, 0xfe0d, 0xff01} + if profile == FingerprintChrome155 { + wantExtensions = append(wantExtensions, 0xca34) + } if warm { wantExtensions = append(wantExtensions, 41) } @@ -283,6 +287,9 @@ func TestWebH2RealTLSResumptionAndFreshAuthentication(t *testing.T) { {name: "chrome_enabled", profile: FingerprintChrome133, cacheEnabled: true, wantResume: true}, {name: "chrome_nil_cache", profile: FingerprintChrome133}, {name: "chrome_tickets_disabled", profile: FingerprintChrome133, cacheEnabled: true, ticketsDisabled: true}, + {name: "chrome155_enabled", profile: FingerprintChrome155, cacheEnabled: true, wantResume: true}, + {name: "chrome155_nil_cache", profile: FingerprintChrome155}, + {name: "chrome155_tickets_disabled", profile: FingerprintChrome155, cacheEnabled: true, ticketsDisabled: true}, {name: "native_positive", profile: FingerprintNative, cacheEnabled: true, wantResume: true}, } { t.Run(test.name, func(t *testing.T) { @@ -301,7 +308,7 @@ func TestWebH2RealTLSResumptionAndFreshAuthentication(t *testing.T) { } t.Cleanup(func() { _ = client.Close() }) var chromeCache *webH2ResumptionUTLSCache - if test.profile == FingerprintChrome133 { + if test.profile == FingerprintChrome133 || test.profile == FingerprintChrome155 { if test.wantResume { if client.utlsSessionCache == nil { t.Fatal("enabled cache policy did not create a uTLS cache") @@ -405,8 +412,8 @@ func TestWebH2RealTLSResumptionAndFreshAuthentication(t *testing.T) { if warm && result.hello.extensions[len(result.hello.extensions)-1] != 41 { t.Error("warm ClientHello PSK extension is not last") } - if test.profile == FingerprintChrome133 { - webH2ResumptionCheckChromeHello(t, result.hello, warm) + if test.profile == FingerprintChrome133 || test.profile == FingerprintChrome155 { + webH2ResumptionCheckChromeHello(t, result.hello, test.profile, warm) } case <-time.After(2 * time.Second): t.Fatal("real physical HTTP/2 worker did not return") diff --git a/internal/tunnel/web_h2_stream_handoff_test.go b/internal/tunnel/web_h2_stream_handoff_test.go index fed322a..1ef718f 100644 --- a/internal/tunnel/web_h2_stream_handoff_test.go +++ b/internal/tunnel/web_h2_stream_handoff_test.go @@ -58,7 +58,7 @@ func (c *webH2TCPHandoffContext) Deadline() (time.Time, bool) { } func TestWebH2TCPHandoffHonorsCallerCancellation(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { f := newWebH2DialSharingFixture(t) target := f.target() diff --git a/internal/tunnel/web_h2_write_timeout_integration_test.go b/internal/tunnel/web_h2_write_timeout_integration_test.go index 1f87bfd..ecf3aea 100644 --- a/internal/tunnel/web_h2_write_timeout_integration_test.go +++ b/internal/tunnel/web_h2_write_timeout_integration_test.go @@ -21,7 +21,7 @@ import ( const webH2IntegrationWriteTimeout = 300 * time.Millisecond func TestWebH2WriteByteTimeoutPreservesHealthyTLS(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { client := newWebH2WriteTimeoutIntegrationClient(t, profile) target := startWebTCPEcho(t) @@ -62,7 +62,7 @@ func TestWebH2WriteByteTimeoutPreservesHealthyTLS(t *testing.T) { } func TestWebH2WriteByteTimeoutReachesTLSRawConnection(t *testing.T) { - for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133} { + for _, profile := range []FingerprintProfile{FingerprintNative, FingerprintChrome133, FingerprintChrome155} { t.Run(string(profile), func(t *testing.T) { client := newWebH2WriteTimeoutIntegrationClient(t, profile) target := startWebTCPEcho(t) From a980842cea5140e2abc25b0637f74d0908399de9 Mon Sep 17 00:00:00 2001 From: cppla Date: Fri, 9 Oct 2026 09:50:18 +0800 Subject: [PATCH 2/4] fix: refresh Go security baseline and maintained TLS backport --- .github/workflows/ci.yml | 12 ++-- .github/workflows/release.yml | 2 +- Dockerfile | 2 +- README.md | 2 +- THIRD_PARTY_NOTICES.md | 4 +- docs/DEPENDENCY-MAINTENANCE.md | 26 ++++++++- docs/WEB_COVER.md | 11 ++++ go.mod | 6 +- go.sum | 8 +-- internal/cover/handler_test.go | 58 +++++++++++++++++++ .../tunnel/web_h3_alt_svc_consistency_test.go | 44 +++++++++++--- scripts/check-dependency-boundary.sh | 2 +- scripts/stealth-campaign-toy.Dockerfile | 2 +- scripts/stealth-capture.Dockerfile | 2 +- scripts/stealth-full-runner.Dockerfile | 2 +- scripts/stealth-lab.Dockerfile | 2 +- scripts/test_dependency_boundary.py | 2 +- 17 files changed, 153 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30ea50b..c10f81f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.27.1', '1.27.x'] + go: ['1.27.2', '1.27.x'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -64,26 +64,26 @@ jobs: if-no-files-found: error macos-test: - name: macOS test (Go 1.27.1) + name: macOS test (Go 1.27.2) runs-on: macos-14 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.1 + go-version: 1.27.2 cache: true - name: Test run: go test -shuffle=on -count=1 ./... windows-udp-test: - name: Windows UDP continuity (Go 1.27.1) + name: Windows UDP continuity (Go 1.27.2) runs-on: windows-2022 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.1 + go-version: 1.27.2 cache: true - name: Exercise native UDP continuity and platform bind errors shell: pwsh @@ -155,7 +155,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.1 + go-version: 1.27.2 cache: true - name: Run govulncheck run: ./scripts/govulncheck.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 690fcf5..f26fa35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.1 + go-version: 1.27.2 cache: true - name: Verify tagged source and successful main checks id: source diff --git a/Dockerfile b/Dockerfile index 58a3fc8..e1d32dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine3.24@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS build +FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine3.24@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS build ARG TARGETOS ARG TARGETARCH diff --git a/README.md b/README.md index 62e3363..b9947d5 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Web cover 的目标是正常网站兼容、减少主动探测暴露并在 UDP ## 快速开始 -客户端和服务端分别在各自机器上构建,需要 Go 1.27.1 或更高版本: +客户端和服务端分别在各自机器上构建,需要 Go 1.27.2 或更高版本: ```bash git clone https://github.com/cppla/autocar.git diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a18b98e..c0353e7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -427,7 +427,7 @@ SOFTWARE. ## `github.com/refraction-networking/utls` `v1.8.3-0.20261006222701-ff1b50fbbe9a` -Effective source replacement: `github.com/cppla/utls v0.0.0-20261008110020-7295e1b508c3`. +Effective source replacement: `github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30`. ### `LICENSE` @@ -527,7 +527,7 @@ rights granted to you under this License for this implementation of Go shall terminate as of the date such litigation is filed. ``` -## `golang.org/x/net` `v0.59.0` +## `golang.org/x/net` `v0.60.0` ### `LICENSE` diff --git a/docs/DEPENDENCY-MAINTENANCE.md b/docs/DEPENDENCY-MAINTENANCE.md index 2b6c98b..fff7188 100644 --- a/docs/DEPENDENCY-MAINTENANCE.md +++ b/docs/DEPENDENCY-MAINTENANCE.md @@ -11,7 +11,7 @@ identity instead of repairing the existing dependency graph. The upstream baseline is `v1.8.3-0.20261006222701-ff1b50fbbe9a` (`ff1b50fbbe9a6dff1dcb1cfc0493bd5b0f073f67`). It requires Go 1.27; -AutoCAR's minimum supported toolchain is Go 1.27.1. Native transport continues +AutoCAR's minimum supported toolchain is Go 1.27.2. Native transport continues to use official quic-go. Web H3 retains its existing, independently pinned QUIC dependency; this change does not require another maintained QUIC fork. @@ -105,3 +105,27 @@ that renamed fork. Record those source/patch reviews separately; do not call them automated reachability results or reuse an older review for changed code. This is an update/release procedure, not a background monitoring service. + +## October 2026 security refresh + +The October 9 pre-merge scan reported 11 reachable advisory IDs in the Go +1.27.1 / `x/net v0.59.0` build, following the October 8 advisory publication. +Current builds require [Go 1.27.2](https://go.dev/doc/devel/release#go1.27.0) +and select `x/net v0.60.0`, including the +[HTTP/2 HPACK race fix](https://pkg.go.dev/vuln/GO-2026-6617). CI, release builds +and all current Go builder images are updated together; historical validation +records and frozen capture inputs are not rewritten. + +The uTLS fork separately backports Go commit +[`f022e61963529d5e691f0e42a87fd5b520f01636`](https://github.com/golang/go/commit/f022e61963529d5e691f0e42a87fd5b520f01636) +for [GO-2026-6607](https://pkg.go.dev/vuln/GO-2026-6607). A newer compiler cannot +repair copied TLS source. This shared decoder serves ECH server processing and +local custom-client transcript reconstruction. AutoCAR's configured fork use is +client-side with GREASE ECH, limiting the affected server-input path, but the +public fork still receives the exact upstream fix and bounded regressions. + +Go 1.27.2 also rejects CONNECT in `httputil.ReverseProxy`; tests retain this +security behavior and assert no upstream dial. See the +[cover compatibility boundary](WEB_COVER.md#fixed-upstream-origin). Functional +passes, a clean reachability scan, and the separate copied-source review remain +distinct evidence, not a claim that every dependency is vulnerability-free. diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index 126a983..696ac2d 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -222,6 +222,17 @@ the upstream cannot be reached. Consequently, an upstream that requires an `Authorization` request header is not suitable without a separate authorized front end. +With the minimum Go 1.27.2 security update, the standard reverse proxy rejects +`CONNECT` before contacting the upstream, addressing +[GO-2026-6605](https://pkg.go.dev/vuln/GO-2026-6605). Unauthenticated or invalid +CONNECT/CONNECT-UDP requests delegated to a reverse-proxy cover therefore get +its generic `502`, with no upstream informational response. The combined +listener still supplies its bound `Alt-Svc`. These same-method invalid probes +must agree with each other, but are not expected to equal an ordinary GET. +AutoCAR does not rewrite CONNECT to GET or bypass the standard-library guard. +Authenticated tunnel handling occurs before cover delegation and is unchanged; +static or custom cover handlers retain their own method policy. + Source builds after v1.0.1 preserve declared end-to-end request trailers for nonempty streamed uploads through the fixed-origin and public-origin proxies. For example, a website can receive a late `Content-Digest` after consuming the diff --git a/go.mod b/go.mod index 7aed3f7..73fbd09 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/cppla/autocar -go 1.27.1 +go 1.27.2 // Preserve upstream type identity for H2 and the web-H3 adapter. See docs/DEPENDENCY-MAINTENANCE.md. -replace github.com/refraction-networking/utls => github.com/cppla/utls v0.0.0-20261008110020-7295e1b508c3 +replace github.com/refraction-networking/utls => github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30 require ( github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c github.com/quic-go/qpack v0.6.0 github.com/quic-go/quic-go v0.63.0 github.com/refraction-networking/utls v1.8.3-0.20261006222701-ff1b50fbbe9a - golang.org/x/net v0.59.0 + golang.org/x/net v0.60.0 ) require ( diff --git a/go.sum b/go.sum index fe8c8bf..357fdd8 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c h1:cxK8qTA0YCsj68A7zLYDCfPgMTOyt3ePkHh+oXEmYU0= github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c/go.mod h1:J7UZVOMF2M1LiE3iiUZWPuy8YCPAcLJOwVURrfJCvc0= -github.com/cppla/utls v0.0.0-20261008110020-7295e1b508c3 h1:fXBgtjrWvODw3p6u7AQkYPndqjVZR3x7LhlWUrQHVbc= -github.com/cppla/utls v0.0.0-20261008110020-7295e1b508c3/go.mod h1:rc/ctWeKlh9LSk5k90KlSPcwF7P6D31mn/9GI2NAfBI= +github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30 h1:7+CZIuN8ogrJtDEMSOJwZO0BpPYyiV2LVZK3nGCAQiM= +github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30/go.mod h1:rc/ctWeKlh9LSk5k90KlSPcwF7P6D31mn/9GI2NAfBI= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw= @@ -20,8 +20,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= diff --git a/internal/cover/handler_test.go b/internal/cover/handler_test.go index 3cd2613..70904a6 100644 --- a/internal/cover/handler_test.go +++ b/internal/cover/handler_test.go @@ -212,6 +212,64 @@ func TestReverseProxyFailureIsGeneric(t *testing.T) { assertNoProductMarker(t, response.Result()) } +func TestReverseProxyRejectsCONNECTBeforeUpstream(t *testing.T) { + for _, publicOrigin := range []bool{false, true} { + name := "fixed_origin" + if publicOrigin { + name = "public_origin" + } + t.Run(name, func(t *testing.T) { + var calls int + upstream := roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + if request.Method != http.MethodGet { + t.Errorf("unsupported method reached upstream: %s", request.Method) + } + return &http.Response{StatusCode: http.StatusNoContent, Header: make(http.Header), Body: http.NoBody}, nil + }) + target := &url.URL{Scheme: "http", Host: "fixed-origin.invalid"} + var handler http.Handler + var err error + if publicOrigin { + handler, err = NewReverseProxyHandlerWithPublicOrigin(target, &url.URL{Scheme: "https", Host: "public.example"}, upstream) + } else { + handler, err = NewReverseProxyHandler(target, upstream) + } + if err != nil { + t.Fatal(err) + } + get := func() { + t.Helper() + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "https://public.example/", nil)) + if response.Code != http.StatusNoContent { + t.Fatalf("ordinary GET status = %d, want 204", response.Code) + } + } + get() // Positive control: the transport is active before CONNECT. + for _, body := range []string{"", "unforwarded CONNECT body"} { + request := httptest.NewRequest(http.MethodConnect, "public.example:443", strings.NewReader(body)) + request.Header.Set("Authorization", "fictional origin credential") + request.Header.Set("Proxy-Authorization", "fictional proxy credential") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + _ = request.Body.Close() + if calls != 1 { + t.Fatalf("CONNECT called the upstream transport: %d calls, want only the initial GET", calls) + } + if response.Code != http.StatusBadGateway || response.Body.String() != "Bad Gateway\n" { + t.Fatalf("CONNECT status/body = %d/%q, want generic 502", response.Code, response.Body.String()) + } + assertNoProductMarker(t, response.Result()) + } + get() // Rejection must not disable later ordinary website requests. + if calls != 2 { + t.Fatalf("upstream calls = %d, want exactly the two ordinary GETs", calls) + } + }) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { diff --git a/internal/tunnel/web_h3_alt_svc_consistency_test.go b/internal/tunnel/web_h3_alt_svc_consistency_test.go index ae9a39d..c12119c 100644 --- a/internal/tunnel/web_h3_alt_svc_consistency_test.go +++ b/internal/tunnel/web_h3_alt_svc_consistency_test.go @@ -77,7 +77,7 @@ func TestWebH3AltSvcConsistencyPublicCommitModes(t *testing.T) { func TestWebH3AltSvcConsistencyReverseProxyInvalidProbes(t *testing.T) { var credentials, dials, resolves atomic.Int64 - website := webAltSvcReverseProxyOrigin(t, &credentials) + website, originRequests := webAltSvcReverseProxyOrigin(t, &credentials) server, clientTLS := webAltSvcCombinedServer(t, website, webAltSvcForbiddenDialer(&dials), &resolves) bound := []string{webH3AltSvcValue(server.UDPAddr())} for _, proto := range []int{1, 2, 3} { @@ -108,7 +108,7 @@ func TestWebH3AltSvcConsistencyReverseProxyInvalidProbes(t *testing.T) { }{"udp_malformed_invalid", http.MethodConnect, "/not-a-template", []string{"Bearer invalid"}, true}, ) } - var visitor http.Header + var visitor, unsupportedConnect http.Header for _, probe := range probes { t.Run(probe.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -128,14 +128,35 @@ func TestWebH3AltSvcConsistencyReverseProxyInvalidProbes(t *testing.T) { request.Proto = webConnectUDPProtocol request.Header.Set(webCapsuleProtocolHeader, webCapsuleProtocolValue) } + before := originRequests.Load() result := webAltSvcRoundTrip(t, rt, request, proto) - webAltSvcAssertPublic(t, result, []int{103, 102, 103}, 200, "ordinary website", bound) + if probe.method == http.MethodConnect { + // Go 1.27.2 rejects CONNECT before ReverseProxy calls its + // transport (GO-2026-6605). Keep that security boundary: + // compare unauthenticated probes with the same method, + // not with an unrelated ordinary GET response. + webAltSvcAssertPublic(t, result, nil, http.StatusBadGateway, "Bad Gateway\n", bound) + if got := originRequests.Load(); got != before { + t.Fatalf("unsupported CONNECT reached the fixed origin: %d -> %d", before, got) + } + } else { + webAltSvcAssertPublic(t, result, []int{103, 102, 103}, 200, "ordinary website", bound) + if got := originRequests.Load(); got != before+1 { + t.Fatalf("GET origin requests = %d, want %d", got, before+1) + } + } normalized := result.header.Clone() normalized.Del("Date") if probe.name == "visitor" { visitor = normalized - } else if !reflect.DeepEqual(visitor, normalized) { - t.Errorf("same-protocol invalid probe metadata differs from visitor: %v vs %v", normalized, visitor) + } else if probe.method == http.MethodGet { + if !reflect.DeepEqual(visitor, normalized) { + t.Errorf("same-protocol invalid GET metadata differs from visitor: %v vs %v", normalized, visitor) + } + } else if probe.name == "connect_missing" { + unsupportedConnect = normalized + } else if !reflect.DeepEqual(unsupportedConnect, normalized) { + t.Errorf("same-protocol invalid CONNECT metadata differs from missing credentials: %v vs %v", normalized, unsupportedConnect) } }) } @@ -144,7 +165,7 @@ func TestWebH3AltSvcConsistencyReverseProxyInvalidProbes(t *testing.T) { if dials.Load() != 0 || resolves.Load() != 0 || credentials.Load() != 0 { t.Fatalf("public target dial/resolution/credential counters=%d/%d/%d", dials.Load(), resolves.Load(), credentials.Load()) } - t.Log("real H1/H2/H3 visitor and invalid probes: target dial/resolution/credential counters=0/0/0") + t.Logf("real H1/H2/H3 visitor and invalid probes: %d fixed-origin GETs, zero origin CONNECTs, target dial/resolution/credential counters=0/0/0", originRequests.Load()) } func TestWebH3AltSvcConsistencyAuthenticatedResponsesRemainPrivate(t *testing.T) { @@ -204,7 +225,7 @@ func TestWebH3AltSvcConsistencyAuthenticatedResponsesRemainPrivate(t *testing.T) func TestWebH3AltSvcConsistencyStandalonePreservesOriginPolicy(t *testing.T) { var credentials, dials atomic.Int64 - website := webAltSvcReverseProxyOrigin(t, &credentials) + website, originRequests := webAltSvcReverseProxyOrigin(t, &credentials) serverTLS, clientTLS := testTLSConfigs(t) server, err := ListenWebH3(WebH3ServerConfig{Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, Cover: website, Dialer: webAltSvcForbiddenDialer(&dials)}) if err != nil { @@ -223,6 +244,9 @@ func TestWebH3AltSvcConsistencyStandalonePreservesOriginPolicy(t *testing.T) { if dials.Load() != 0 || credentials.Load() != 0 { t.Fatal("standalone public request touched tunnel destination or credentials") } + if got := originRequests.Load(); got != 1 { + t.Fatalf("standalone fixed origin requests = %d, want 1", got) + } } func webAltSvcWebsite(w http.ResponseWriter, mode string) { @@ -261,9 +285,11 @@ func webAltSvcWebsite(w http.ResponseWriter, mode string) { } } -func webAltSvcReverseProxyOrigin(t *testing.T, credentials *atomic.Int64) http.Handler { +func webAltSvcReverseProxyOrigin(t *testing.T, credentials *atomic.Int64) (http.Handler, *atomic.Int64) { t.Helper() + originRequests := new(atomic.Int64) origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + originRequests.Add(1) if r.Header.Get("Authorization") != "" || r.Header.Get("Proxy-Authorization") != "" { credentials.Add(1) } @@ -282,7 +308,7 @@ func webAltSvcReverseProxyOrigin(t *testing.T, credentials *atomic.Int64) http.H if err != nil { t.Fatal(err) } - return website + return website, originRequests } func webAltSvcForbiddenDialer(dials *atomic.Int64) transport.Dialer { diff --git a/scripts/check-dependency-boundary.sh b/scripts/check-dependency-boundary.sh index fafc756..ea61392 100755 --- a/scripts/check-dependency-boundary.sh +++ b/scripts/check-dependency-boundary.sh @@ -6,7 +6,7 @@ readonly ALLOWED_WEB_QUIC_VERSION='v0.63.1-0.20261004180939-a10df75c260c' readonly ALLOWED_UTLS_MODULE='github.com/refraction-networking/utls' readonly ALLOWED_UTLS_UPSTREAM_VERSION='v1.8.3-0.20261006222701-ff1b50fbbe9a' readonly ALLOWED_UTLS_REPLACEMENT='github.com/cppla/utls' -readonly ALLOWED_UTLS_VERSION='v0.0.0-20261008110020-7295e1b508c3' +readonly ALLOWED_UTLS_VERSION='v0.0.0-20261009014536-ff869e255a30' readonly FORBIDDEN_HYSTERIA_PATTERN='github\.com/apernet/hysteria(/|[[:space:]"`]|$)' readonly FORBIDDEN_HYSTERIA_GO_PATTERN='["`]github\.com/apernet/hysteria(/[^"`[:space:]]*)?["`]' status=0 diff --git a/scripts/stealth-campaign-toy.Dockerfile b/scripts/stealth-campaign-toy.Dockerfile index eea4486..e4a37bc 100644 --- a/scripts/stealth-campaign-toy.Dockerfile +++ b/scripts/stealth-campaign-toy.Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM golang:1.27.1-alpine3.24@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS build +FROM golang:1.27.2-alpine3.24@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS build WORKDIR /src COPY scripts/stealth-campaign-toy/main.go . RUN CGO_ENABLED=0 GO111MODULE=off go build -trimpath -o /out/stealth-campaign-toy ./main.go diff --git a/scripts/stealth-capture.Dockerfile b/scripts/stealth-capture.Dockerfile index 0b9288a..760d33c 100644 --- a/scripts/stealth-capture.Dockerfile +++ b/scripts/stealth-capture.Dockerfile @@ -3,7 +3,7 @@ # This helper is never a workload endpoint. The campaign driver uses its # tcpdump/ip/tc binaries only inside explicitly labelled container namespaces. # Reuse the already-pinned Alpine base used by stealth-lab.Dockerfile. -FROM golang:1.27.1-alpine3.24@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 +FROM golang:1.27.2-alpine3.24@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 RUN apk add --no-cache iproute2 tcpdump diff --git a/scripts/stealth-full-runner.Dockerfile b/scripts/stealth-full-runner.Dockerfile index f32356d..a1fd29a 100644 --- a/scripts/stealth-full-runner.Dockerfile +++ b/scripts/stealth-full-runner.Dockerfile @@ -3,7 +3,7 @@ # Build both formal-campaign clients from the same pinned Go toolchain and # module graph. The launcher records the resulting immutable image ID, then # extracts the two binaries for campaign provenance. -FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine3.24@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS build +FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine3.24@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS build ARG TARGETOS ARG TARGETARCH diff --git a/scripts/stealth-lab.Dockerfile b/scripts/stealth-lab.Dockerfile index 4b96797..a79768f 100644 --- a/scripts/stealth-lab.Dockerfile +++ b/scripts/stealth-lab.Dockerfile @@ -3,7 +3,7 @@ # Keep the builder aligned with the product Dockerfile so the probe uses the # same module graph and Go toolchain. The runtime is scratch: the test gateway, # origin, probe, and control server require no shell or ambient packages. -FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine3.24@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS build +FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine3.24@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS build ARG TARGETOS ARG TARGETARCH diff --git a/scripts/test_dependency_boundary.py b/scripts/test_dependency_boundary.py index 5f84e91..1dd4db0 100644 --- a/scripts/test_dependency_boundary.py +++ b/scripts/test_dependency_boundary.py @@ -32,7 +32,7 @@ def setUp(self): self.fork = PINS["ALLOWED_UTLS_REPLACEMENT"] self.replacement = f"replace {self.upstream} => {self.fork} {FORK_VERSION}" self.module = ( - "module example.invalid/fixture\n\ngo 1.27.1\n\nrequire (\n" + "module example.invalid/fixture\n\ngo 1.27.2\n\nrequire (\n" f"\t{PINS['ALLOWED_WEB_QUIC_MODULE']} {PINS['ALLOWED_WEB_QUIC_VERSION']}\n" f"\t{self.upstream} {PINS['ALLOWED_UTLS_UPSTREAM_VERSION']}\n" ")\n\n" + self.replacement + "\n" From 74f3896ac016019bd2ae72c0a0d34b54312f1323 Mon Sep 17 00:00:00 2001 From: cppla Date: Fri, 9 Oct 2026 09:52:12 +0800 Subject: [PATCH 3/4] fix(ci): refresh floating Go toolchains beyond cached patch versions --- .github/workflows/ci.yml | 4 ++++ .github/workflows/codeql.yml | 1 + .github/workflows/fuzz.yml | 1 + .github/workflows/netem.yml | 1 + 4 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c10f81f..9423abb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} + # Floating versions must not reuse an older cached security patch. + check-latest: ${{ matrix.go == '1.27.x' }} cache: true - name: Verify formatting if: matrix.go == '1.27.x' @@ -145,6 +147,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.27.x + check-latest: true cache: true - run: make race @@ -204,6 +207,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.27.x + check-latest: true cache: true - name: Cross-build env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 320c1ba..c264e39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,6 +26,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.27.x + check-latest: true cache: true - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 3ea6197..703db74 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -41,6 +41,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.27.x + check-latest: true cache: true - name: Fuzz ${{ matrix.target }} run: >- diff --git a/.github/workflows/netem.yml b/.github/workflows/netem.yml index 071a0d0..682e4fe 100644 --- a/.github/workflows/netem.yml +++ b/.github/workflows/netem.yml @@ -23,6 +23,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 1.27.x + check-latest: true cache: true - name: Install network test tools run: | From b507a608f0d632e888110ae667b2ff6fd1e7364c Mon Sep 17 00:00:00 2001 From: cppla Date: Fri, 9 Oct 2026 09:54:33 +0800 Subject: [PATCH 4/4] fix(ci): enforce the minimum patch in floating Go version ranges --- .github/workflows/ci.yml | 13 +++++++++---- .github/workflows/codeql.yml | 2 +- .github/workflows/fuzz.yml | 2 +- .github/workflows/netem.yml | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9423abb..1a04d4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,12 +24,17 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.27.2', '1.27.x'] + include: + - go: '1.27.2' + version: '1.27.2' + - go: '1.27.x' + # Reject stale manifests and caches below the security baseline. + version: '>=1.27.2 <1.28.0' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: ${{ matrix.go }} + go-version: ${{ matrix.version }} # Floating versions must not reuse an older cached security patch. check-latest: ${{ matrix.go == '1.27.x' }} cache: true @@ -146,7 +151,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.x + go-version: '>=1.27.2 <1.28.0' check-latest: true cache: true - run: make race @@ -206,7 +211,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.x + go-version: '>=1.27.2 <1.28.0' check-latest: true cache: true - name: Cross-build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c264e39..248e875 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.x + go-version: '>=1.27.2 <1.28.0' check-latest: true cache: true - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 703db74..a636f49 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.x + go-version: '>=1.27.2 <1.28.0' check-latest: true cache: true - name: Fuzz ${{ matrix.target }} diff --git a/.github/workflows/netem.yml b/.github/workflows/netem.yml index 682e4fe..28e7001 100644 --- a/.github/workflows/netem.yml +++ b/.github/workflows/netem.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: 1.27.x + go-version: '>=1.27.2 <1.28.0' check-latest: true cache: true - name: Install network test tools