From 142511915f61ac4515ab2eddc896454858d2f25a Mon Sep 17 00:00:00 2001 From: Stefan Meinecke Date: Mon, 31 Aug 2026 15:17:28 +0200 Subject: [PATCH] fix(http2): wire custom HTTP/2 fingerprints to the transport The http2_fingerprint option was parsed but never actually emitted on the wire. The old HTTP2Fingerprint.Apply only wrote Transport.Settings, which fhttp's Transport.ApplyPreset immediately overwrote with hardcoded Firefox/ Chrome defaults derived from the User-Agent. The fhttp HTTP2Settings struct (ConnectionFlow, PriorityFrames, HeaderPriority, and Settings) was left empty, so connection flow, initial priority frames, and the HEADERS stream ID stayed driven by the UA regardless of the supplied fingerprint. Rewrite HTTP2Fingerprint parsing to match the canonical SETTINGS|WINDOW_UPDATE|PRIORITY_FRAMES|PSEUDO_HEADERS format and populate http2.HTTP2Settings. Apply that to the transport and use the fingerprint to pick the remaining fhttp defaults (e.g. the implicit HEADERS stream ID when no priority frames are present). Also map the pseudo-header order letters onto http.PHeaderOrderKey so the request header ordering matches the fingerprint too. This makes http2_fingerprint a first-class option and lets profiles that ship both ja4r and http2_fingerprint produce a coherent TLS + HTTP/2 identity. --- golang/http2.go | 211 +++++++++++++++++++++++++++++++---------- golang/roundtripper.go | 61 +++++++++--- 2 files changed, 208 insertions(+), 64 deletions(-) diff --git a/golang/http2.go b/golang/http2.go index e82e3a5..e933c5b 100644 --- a/golang/http2.go +++ b/golang/http2.go @@ -7,105 +7,214 @@ import ( http2 "github.com/Danny-Dasilva/fhttp/http2" ) -// HTTP2Fingerprint represents an HTTP/2 client fingerprint +// HTTP2Fingerprint represents an HTTP/2 client fingerprint. +// The canonical string format is: +// +// SETTINGS|WINDOW_UPDATE|PRIORITY_FRAMES|PSEUDO_HEADERS +// +// Example Firefox: +// +// 1:65536,4:131072,5:16384|12517377|3:0:0:201,5:0:0:1,7:0:0:1,9:0:7:1,11:0:3:1,13:0:0:241|m,p,a,s +// +// Example Chrome: +// +// 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p type HTTP2Fingerprint struct { - Settings []http2.Setting - StreamDependency uint32 - Exclusive bool - PriorityOrder []string + Settings []http2.Setting + ConnectionFlow int + PriorityFrames []http2.PriorityFrame + HeaderPriority *http2.PriorityParam + HeaderTableSize uint32 + InitialWindowSize uint32 + MaxHeaderListSize uint32 + PriorityOrder []string } -// NewHTTP2Fingerprint creates a new HTTP2Fingerprint from string format -// Format: settings|streamDependency|exclusive|priorityOrder -// Example: "1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p" +// NewHTTP2Fingerprint creates a new HTTP2Fingerprint from a canonical +// HTTP/2 fingerprint string. +// +// The four pipe-separated parts are: +// 1. SETTINGS: a list of "id:value" pairs separated by ',' or ';'. +// 2. WINDOW_UPDATE: the connection-level flow-control increment. +// 3. PRIORITY_FRAMES: either "0" (none) or a comma-separated list of +// "stream:exclusive:dep:weight" priority frames. +// 4. PSEUDO_HEADERS: the order of pseudo-headers, e.g. "m,p,a,s". func NewHTTP2Fingerprint(fingerprint string) (*HTTP2Fingerprint, error) { parts := strings.Split(fingerprint, "|") if len(parts) != 4 { return nil, fmt.Errorf("invalid HTTP/2 fingerprint format: expected 4 parts, got %d", len(parts)) } - // Parse settings + // Parse SETTINGS. settingsStr := parts[0] - // Determine the separator used in the settings string + // Determine the separator used in the settings string. var settingsParts []string if strings.Contains(settingsStr, ";") && !strings.Contains(settingsStr, ",") { - // If settings use semicolons exclusively, split by semicolon settingsParts = strings.Split(settingsStr, ";") } else { - // Default to comma separator settingsParts = strings.Split(settingsStr, ",") } settings := make([]http2.Setting, 0, len(settingsParts)) + var headerTableSize, initialWindowSize, maxHeaderListSize uint32 for _, setting := range settingsParts { - var id, val uint32 - if strings.Contains(setting, ":") { - // Handle standard format (ID:VALUE) - _, err := fmt.Sscanf(setting, "%d:%d", &id, &val) - if err != nil { - return nil, fmt.Errorf("invalid setting format: %s", setting) - } - } else { + if !strings.Contains(setting, ":") { return nil, fmt.Errorf("invalid setting format: %s - expected ID:VALUE", setting) } + var id, val uint32 + if _, err := fmt.Sscanf(setting, "%d:%d", &id, &val); err != nil { + return nil, fmt.Errorf("invalid setting format: %s", setting) + } settings = append(settings, http2.Setting{ID: http2.SettingID(id), Val: val}) + + switch http2.SettingID(id) { + case http2.SettingHeaderTableSize: + headerTableSize = val + case http2.SettingInitialWindowSize: + initialWindowSize = val + case http2.SettingMaxHeaderListSize: + maxHeaderListSize = val + } } - // Parse stream dependency - var streamDependency uint32 - _, err := fmt.Sscanf(parts[1], "%d", &streamDependency) - if err != nil { - return nil, fmt.Errorf("invalid stream dependency: %s", parts[1]) + // Parse WINDOW_UPDATE value (connection-level flow-control increment). + var connectionFlow uint32 + if _, err := fmt.Sscanf(parts[1], "%d", &connectionFlow); err != nil { + return nil, fmt.Errorf("invalid WINDOW_UPDATE value: %s", parts[1]) } - // Parse exclusive flag - var exclusiveFlag int - _, err = fmt.Sscanf(parts[2], "%d", &exclusiveFlag) - if err != nil { - return nil, fmt.Errorf("invalid exclusive flag: %s", parts[2]) + // Parse PRIORITY frames. + var priorityFrames []http2.PriorityFrame + priorityStr := parts[2] + if priorityStr != "" && priorityStr != "0" { + priorityParts := strings.Split(priorityStr, ",") + priorityFrames = make([]http2.PriorityFrame, 0, len(priorityParts)) + for _, p := range priorityParts { + if p == "" { + continue + } + var streamID, exclusive, dep, weight uint32 + n, err := fmt.Sscanf(p, "%d:%d:%d:%d", &streamID, &exclusive, &dep, &weight) + if err != nil || n != 4 { + return nil, fmt.Errorf("invalid priority frame format: %s", p) + } + // The fingerprint string uses wire display weights (1-256), but + // fhttp stores the raw 8-bit value (0-255). + if weight > 0 { + weight-- + } + priorityFrames = append(priorityFrames, http2.PriorityFrame{ + FrameHeader: http2.FrameHeader{StreamID: streamID}, + PriorityParam: http2.PriorityParam{ + StreamDep: dep, + Exclusive: exclusive != 0, + Weight: uint8(weight), + }, + }) + } } - exclusive := exclusiveFlag != 0 - // Parse priority order + // Parse pseudo-header order. priorityOrder := strings.Split(parts[3], ",") return &HTTP2Fingerprint{ - Settings: settings, - StreamDependency: streamDependency, - Exclusive: exclusive, - PriorityOrder: priorityOrder, + Settings: settings, + ConnectionFlow: int(connectionFlow), + PriorityFrames: priorityFrames, + HeaderPriority: nil, + HeaderTableSize: headerTableSize, + InitialWindowSize: initialWindowSize, + MaxHeaderListSize: maxHeaderListSize, + PriorityOrder: priorityOrder, }, nil } -// String returns the string representation of the HTTP/2 fingerprint +// String returns the canonical string representation of the HTTP/2 fingerprint. func (f *HTTP2Fingerprint) String() string { - // Format settings settingStrs := make([]string, len(f.Settings)) for i, setting := range f.Settings { settingStrs[i] = fmt.Sprintf("%d:%d", setting.ID, setting.Val) } settingsStr := strings.Join(settingStrs, ",") - // Format exclusive flag - exclusiveFlag := 0 - if f.Exclusive { - exclusiveFlag = 1 + priorityStr := "0" + if len(f.PriorityFrames) > 0 { + parts := make([]string, 0, len(f.PriorityFrames)) + for _, pf := range f.PriorityFrames { + exclusive := 0 + if pf.Exclusive { + exclusive = 1 + } + // Convert the raw 0-255 weight back to the wire display value (1-256). + weight := uint32(pf.Weight) + 1 + parts = append(parts, fmt.Sprintf("%d:%d:%d:%d", pf.StreamID, exclusive, pf.StreamDep, weight)) + } + priorityStr = strings.Join(parts, ",") } - // Format priority order - priorityStr := strings.Join(f.PriorityOrder, ",") + pseudoStr := strings.Join(f.PriorityOrder, ",") + + return fmt.Sprintf("%s|%d|%s|%s", settingsStr, f.ConnectionFlow, priorityStr, pseudoStr) +} - return fmt.Sprintf("%s|%d|%d|%s", settingsStr, f.StreamDependency, exclusiveFlag, priorityStr) +// Navigator guesses the underlying browser family from the fingerprint. +// This is used to drive fhttp defaults that are still required when a +// fingerprint omits explicit priority frames. +func (f *HTTP2Fingerprint) Navigator() string { + if len(f.PriorityFrames) > 0 { + return firefox + } + for _, s := range f.Settings { + if s.ID == http2.SettingMaxConcurrentStreams || s.ID == http2.SettingMaxHeaderListSize { + return chrome + } + } + return chrome } -// Apply configures the HTTP/2 connection with the specified fingerprint +// PseudoHeaderOrder maps the fingerprint's pseudo-header order letters to +// actual HTTP/2 pseudo-header names, e.g. "m,p,a,s" -> [":method", ":path", ":authority", ":scheme"]. +func (f *HTTP2Fingerprint) PseudoHeaderOrder() []string { + if len(f.PriorityOrder) == 0 { + return nil + } + order := make([]string, 0, len(f.PriorityOrder)) + for _, c := range f.PriorityOrder { + switch c { + case "m": + order = append(order, ":method") + case "a": + order = append(order, ":authority") + case "s": + order = append(order, ":scheme") + case "p": + order = append(order, ":path") + } + } + return order +} + +// Apply configures the HTTP/2 transport with the specified fingerprint. func (f *HTTP2Fingerprint) Apply(conn *http2.Transport) { - // Set HTTP/2 settings - conn.Settings = f.Settings + conn.HTTP2Settings = &http2.HTTP2Settings{ + Settings: f.Settings, + ConnectionFlow: f.ConnectionFlow, + PriorityFrames: f.PriorityFrames, + HeaderPriority: f.HeaderPriority, + } - // Set priority and weight parameters - // Note: Currently dummy implementation as utls/http2 doesn't expose these directly - // In a real implementation, this would configure the priority tree + // These are used by fhttp for local flow control and HPACK state. They + // may be overwritten by Transport.AutoUpdate depending on the Navigator, + // but we set them here so they match the advertised SETTINGS when possible. + if f.HeaderTableSize != 0 { + conn.HeaderTableSize = f.HeaderTableSize + } + if f.InitialWindowSize != 0 { + conn.InitialWindowSize = f.InitialWindowSize + } + if f.MaxHeaderListSize != 0 { + conn.MaxHeaderListSize = f.MaxHeaderListSize + } } diff --git a/golang/roundtripper.go b/golang/roundtripper.go index bd9a884..6de28a8 100644 --- a/golang/roundtripper.go +++ b/golang/roundtripper.go @@ -68,6 +68,21 @@ type roundTripper struct { cachedTransports map[string]http.RoundTripper dialer proxy.ContextDialer + + // Parsed HTTP/2 fingerprint, populated lazily because newRoundTripper + // cannot return an error. + h2FingerprintOnce sync.Once + parsedH2Fingerprint *HTTP2Fingerprint + h2FingerprintErr error +} + +func (rt *roundTripper) getH2Fingerprint() (*HTTP2Fingerprint, error) { + rt.h2FingerprintOnce.Do(func() { + if rt.HTTP2Fingerprint != "" { + rt.parsedH2Fingerprint, rt.h2FingerprintErr = NewHTTP2Fingerprint(rt.HTTP2Fingerprint) + } + }) + return rt.parsedH2Fingerprint, rt.h2FingerprintErr } func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { @@ -97,8 +112,21 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req.Header = ConvertHttpHeader(MarshalHeader(req.Header, rt.HeaderOrder)) // Note: rt.HeaderOrder contains regular headers like "cache-control", "accept", etc. - // Do NOT overwrite http.PHeaderOrderKey which contains pseudo-headers like ":method", ":path" - // The pseudo-header order is already set correctly in index.go based on UserAgent parsing + // The pseudo-header order is set below from the HTTP/2 fingerprint or UserAgent. + } + + // Apply HTTP/2 pseudo-header order from the fingerprint when one is provided, + // overriding the UserAgent-based order set by the caller. + if rt.HTTP2Fingerprint != "" { + h2fp, err := rt.getH2Fingerprint() + if err != nil { + return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint: %w", err) + } + if h2fp != nil { + if pseudoOrder := h2fp.PseudoHeaderOrder(); len(pseudoOrder) > 0 { + req.Header[http.PHeaderOrderKey] = pseudoOrder + } + } } // Get address for dialing @@ -338,8 +366,7 @@ func (rt *roundTripper) dialTLS(ctx context.Context, network, addr string) (net. // Use HTTP/2 fingerprint if specified var http2Transport http2.Transport if rt.HTTP2Fingerprint != "" { - // Parse and apply HTTP/2 fingerprint - h2Fingerprint, err := NewHTTP2Fingerprint(rt.HTTP2Fingerprint) + h2Fingerprint, err := rt.getH2Fingerprint() if err != nil { return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint: %v", err) } @@ -347,11 +374,15 @@ func (rt *roundTripper) dialTLS(ctx context.Context, network, addr string) (net. http2Transport = http2.Transport{ DialTLS: rt.dialTLSHTTP2, PushHandler: &http2.DefaultPushHandler{}, - Navigator: parsedUserAgent.UserAgent, } - // Apply HTTP/2 fingerprint settings - h2Fingerprint.Apply(&http2Transport) + // Apply HTTP/2 fingerprint settings and use the fingerprint to + // guess the remaining fhttp defaults (e.g. stream ID when no + // explicit priority frames are provided). + if h2Fingerprint != nil { + h2Fingerprint.Apply(&http2Transport) + http2Transport.Navigator = h2Fingerprint.Navigator() + } } else { http2Transport = http2.Transport{ DialTLS: rt.dialTLSHTTP2, @@ -446,7 +477,7 @@ func (rt *roundTripper) retryWithTLS13CompatibleCurves(ctx context.Context, netw var http2Transport http2.Transport if rt.HTTP2Fingerprint != "" { - h2Fingerprint, err := NewHTTP2Fingerprint(rt.HTTP2Fingerprint) + h2Fingerprint, err := rt.getH2Fingerprint() if err != nil { return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint for TLS 1.3 retry: %v", err) } @@ -454,10 +485,12 @@ func (rt *roundTripper) retryWithTLS13CompatibleCurves(ctx context.Context, netw http2Transport = http2.Transport{ DialTLS: rt.dialTLSHTTP2, PushHandler: &http2.DefaultPushHandler{}, - Navigator: parsedUserAgent.UserAgent, } - h2Fingerprint.Apply(&http2Transport) + if h2Fingerprint != nil { + h2Fingerprint.Apply(&http2Transport) + http2Transport.Navigator = h2Fingerprint.Navigator() + } } else { http2Transport = http2.Transport{ DialTLS: rt.dialTLSHTTP2, @@ -536,7 +569,7 @@ func (rt *roundTripper) retryWithOriginalTLS12JA3(ctx context.Context, network, var http2Transport http2.Transport if rt.HTTP2Fingerprint != "" { - h2Fingerprint, err := NewHTTP2Fingerprint(rt.HTTP2Fingerprint) + h2Fingerprint, err := rt.getH2Fingerprint() if err != nil { return nil, fmt.Errorf("failed to parse HTTP/2 fingerprint for TLS 1.2 fallback: %v", err) } @@ -544,10 +577,12 @@ func (rt *roundTripper) retryWithOriginalTLS12JA3(ctx context.Context, network, http2Transport = http2.Transport{ DialTLS: rt.dialTLSHTTP2, PushHandler: &http2.DefaultPushHandler{}, - Navigator: parsedUserAgent.UserAgent, } - h2Fingerprint.Apply(&http2Transport) + if h2Fingerprint != nil { + h2Fingerprint.Apply(&http2Transport) + http2Transport.Navigator = h2Fingerprint.Navigator() + } } else { http2Transport = http2.Transport{ DialTLS: rt.dialTLSHTTP2,