[object Object]

← back to Cli Printing Press

fix(cli): use Chrome User-Agent for synthetic/cookie/composed/session_handshake CLIs (#1479)

d7ccd8502e41eca152f03e88985527ec2338b839 · 2026-05-15 17:12:27 -0700 · Trevin Chow

* fix(cli): use Chrome User-Agent for synthetic/cookie/composed/session_handshake CLIs

Generated CLIs default to `<cli>-pp-cli/<version>` in the User-Agent
header. For documented JSON APIs this is fine — vendor support uses
the identifier. But for browser-sniffed (Kind: synthetic) CLIs and any
auth.type in {cookie, composed, session_handshake}, the script-shaped
UA is exactly what WAFs (Wordfence, Imperva, Akamai bot-mode,
DataDome, Cloudflare bot-fight) bucket as bot traffic and reject with
5xx, 403, or a challenge. Confirmed on bbquality.nl WP/Wordfence and
documented as a hand-fixed pattern in the picnic-pp-cli source.

Add UsesBrowserLikeUserAgent() on APISpec that flips true for the
above kinds. Wire client.go.tmpl and auth_browser.go.tmpl to emit a
Chrome UA on that branch while keeping the script-flavored default for
documented APIs. UsesBrowserManagedUserAgent (chrome/h3 transports)
still short-circuits both paths entirely.

Closes #1293

* test(cli): extend UA template coverage and drop fragile nil dispatch

Greptile flagged two valid test gaps in the previous commit:

- TestClientTemplateBrowserLikeUserAgent only read client.go.tmpl, so
  an accidental revert of the parallel auth_browser.go.tmpl change
  would go undetected (auth-verify silently reverts to script UA).
  Rename to TestBrowserLikeUserAgentTemplates and loop over both
  template files with the same three pin assertions.

- TestUsesBrowserLikeUserAgent dispatched the nil-receiver case via
  a literal name-string comparison against tc.name. Renaming the
  case would silently fall through to UsesBrowserLikeUserAgent on a
  zero-value APISpec{} (which also returns false) and leave the nil
  guard untested. Switch the table to *APISpec so the nil path is a
  value, not a string match.

Refs #1293

Files touched

Diff

commit d7ccd8502e41eca152f03e88985527ec2338b839
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 15 17:12:27 2026 -0700

    fix(cli): use Chrome User-Agent for synthetic/cookie/composed/session_handshake CLIs (#1479)
    
    * fix(cli): use Chrome User-Agent for synthetic/cookie/composed/session_handshake CLIs
    
    Generated CLIs default to `<cli>-pp-cli/<version>` in the User-Agent
    header. For documented JSON APIs this is fine — vendor support uses
    the identifier. But for browser-sniffed (Kind: synthetic) CLIs and any
    auth.type in {cookie, composed, session_handshake}, the script-shaped
    UA is exactly what WAFs (Wordfence, Imperva, Akamai bot-mode,
    DataDome, Cloudflare bot-fight) bucket as bot traffic and reject with
    5xx, 403, or a challenge. Confirmed on bbquality.nl WP/Wordfence and
    documented as a hand-fixed pattern in the picnic-pp-cli source.
    
    Add UsesBrowserLikeUserAgent() on APISpec that flips true for the
    above kinds. Wire client.go.tmpl and auth_browser.go.tmpl to emit a
    Chrome UA on that branch while keeping the script-flavored default for
    documented APIs. UsesBrowserManagedUserAgent (chrome/h3 transports)
    still short-circuits both paths entirely.
    
    Closes #1293
    
    * test(cli): extend UA template coverage and drop fragile nil dispatch
    
    Greptile flagged two valid test gaps in the previous commit:
    
    - TestClientTemplateBrowserLikeUserAgent only read client.go.tmpl, so
      an accidental revert of the parallel auth_browser.go.tmpl change
      would go undetected (auth-verify silently reverts to script UA).
      Rename to TestBrowserLikeUserAgentTemplates and loop over both
      template files with the same three pin assertions.
    
    - TestUsesBrowserLikeUserAgent dispatched the nil-receiver case via
      a literal name-string comparison against tc.name. Renaming the
      case would silently fall through to UsesBrowserLikeUserAgent on a
      zero-value APISpec{} (which also returns false) and leave the nil
      guard untested. Switch the table to *APISpec so the nil path is a
      value, not a string match.
    
    Refs #1293
---
 internal/generator/generator_test.go              | 28 ++++++++
 internal/generator/templates/auth_browser.go.tmpl |  7 ++
 internal/generator/templates/client.go.tmpl       | 12 ++++
 internal/spec/spec.go                             | 29 ++++++++
 internal/spec/spec_test.go                        | 80 +++++++++++++++++++++++
 5 files changed, 156 insertions(+)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 3d9d44bb..9e191a45 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -10237,6 +10237,34 @@ func TestSearchTemplateEmptyTypeQueriesGenericFTS(t *testing.T) {
 		"the generic-search error path must mention resources_fts so the failure is debuggable")
 }
 
+// TestBrowserLikeUserAgentTemplates pins that BOTH the client.go.tmpl
+// and auth_browser.go.tmpl branch User-Agent emission on
+// UsesBrowserLikeUserAgent so that cookie/composed/session_handshake
+// auth and Kind: synthetic ship a Chrome-shaped UA instead of the
+// script-flavored `<cli>-pp-cli/<ver>`. Otherwise WAFs (Wordfence,
+// Imperva, Akamai bot-mode, DataDome, Cloudflare bot-fight) bucket
+// every CLI request as bot traffic — including the auth-verify call,
+// which must clear the same bar or the user can't bootstrap session
+// state.
+func TestBrowserLikeUserAgentTemplates(t *testing.T) {
+	t.Parallel()
+	for _, tmpl := range []string{"client.go.tmpl", "auth_browser.go.tmpl"} {
+		t.Run(tmpl, func(t *testing.T) {
+			t.Parallel()
+			data, err := os.ReadFile(filepath.Join("templates", tmpl))
+			require.NoError(t, err)
+			body := string(data)
+
+			assert.Contains(t, body, "{{- if .UsesBrowserLikeUserAgent}}",
+				"%s must branch UA emission on UsesBrowserLikeUserAgent", tmpl)
+			assert.Contains(t, body, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
+				"%s must emit a Chrome UA string on the browser-like branch", tmpl)
+			assert.Contains(t, body, `"{{.Name}}-pp-cli/{{.Version}}"`,
+				"%s must keep the script-flavored UA on the default branch so documented APIs stay identifiable", tmpl)
+		})
+	}
+}
+
 // TestSearchTemplateEmitsEmptyJSONEnvelope pins the contract: the
 // generated `search` command in --json (or piped) mode must always emit
 // a valid JSON envelope, including on no matches. Agents pipe stdout
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index 4fea558d..82d43b56 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -1328,7 +1328,14 @@ func validateComposedAuth(authHeader string) error {
 {{- end}}
 {{- if not .UsesBrowserManagedUserAgent}}
 	if req.Header.Get("User-Agent") == "" {
+{{- if .UsesBrowserLikeUserAgent}}
+		// Browser-shaped UA matches the main client default; WAF fingerprint
+		// checks on the auth-verify call have to clear the same bar as the
+		// per-resource calls or the user can't log in to begin with.
+		req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
+{{- else}}
 		req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
+{{- end}}
 	}
 {{- end}}
 
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 8fc592ee..8fd8aa95 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -994,7 +994,19 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 		}
 {{- if not .UsesBrowserManagedUserAgent}}
 		if req.Header.Get("User-Agent") == "" {
+{{- if .UsesBrowserLikeUserAgent}}
+			// Browser-shaped UA: synthetic specs and cookie/composed/
+			// session_handshake auth almost always target website-itself
+			// origins whose WAFs (Wordfence, Imperva, Akamai bot-mode,
+			// DataDome, Cloudflare bot-fight) bucket the script-shaped
+			// `<cli>-pp-cli/<version>` string as a bot and answer with
+			// empty 5xx or a challenge. RequiredHeaders or per-endpoint
+			// headerOverrides still win, and any caller that wants the
+			// CLI-flavored UA can set it on the request explicitly.
+			req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
+{{- else}}
 			req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
+{{- end}}
 		}
 		// Go's net/http omits Accept by default; browsers, curl, and other
 		// stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 283d7162..d522906b 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -420,6 +420,35 @@ func (s *APISpec) UsesBrowserManagedUserAgent() bool {
 	}
 }
 
+// UsesBrowserLikeUserAgent reports whether the generated CLI should
+// default to a browser-shaped User-Agent rather than the
+// `<cli>-pp-cli/<version>` script identifier. Triggered by:
+//   - Kind: synthetic — browser-sniffed specs typically talk to
+//     origins whose WAFs (Wordfence, Imperva, Akamai bot-mode,
+//     DataDome, Cloudflare bot-fight) flag the script-shaped UA as a
+//     bot and answer with 5xx, 403, or a challenge redirect.
+//   - Auth.Type in {cookie, composed, session_handshake} — same
+//     bot-detection surface; these CLIs are almost always speaking to
+//     a website-itself rather than a public API.
+//
+// The browser-managed transports (chrome, chrome-h3) handle their own
+// UA already — UsesBrowserManagedUserAgent short-circuits the template
+// emission entirely there. This method only matters for the standard
+// Go HTTP client path.
+func (s *APISpec) UsesBrowserLikeUserAgent() bool {
+	if s == nil {
+		return false
+	}
+	if s.Kind == KindSynthetic {
+		return true
+	}
+	switch strings.ToLower(strings.TrimSpace(s.Auth.Type)) {
+	case "cookie", "composed", "session_handshake":
+		return true
+	}
+	return false
+}
+
 func (s *APISpec) HasRequiredHeader(name string) bool {
 	if s == nil {
 		return false
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 4381794a..f59984be 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -2750,6 +2750,86 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
 	require.ErrorContains(t, invalid.Validate(), "http_transport must be one of")
 }
 
+func TestUsesBrowserLikeUserAgent(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		spec *APISpec
+		want bool
+	}{
+		{
+			name: "documented JSON API stays script-shaped",
+			spec: &APISpec{
+				Name:    "stripe",
+				BaseURL: "https://api.stripe.com",
+				Auth:    AuthConfig{Type: "bearer"},
+			},
+			want: false,
+		},
+		{
+			name: "kind: synthetic flips to browser-shaped",
+			spec: &APISpec{
+				Name:    "bbquality",
+				BaseURL: "https://bbquality.nl",
+				Kind:    KindSynthetic,
+				Auth:    AuthConfig{Type: "bearer"},
+			},
+			want: true,
+		},
+		{
+			name: "cookie auth flips to browser-shaped",
+			spec: &APISpec{
+				Name:    "marktplaats",
+				BaseURL: "https://www.marktplaats.nl",
+				Auth:    AuthConfig{Type: "cookie"},
+			},
+			want: true,
+		},
+		{
+			name: "composed auth flips to browser-shaped",
+			spec: &APISpec{
+				Name:    "picnic",
+				BaseURL: "https://storefront-prod.nl.picnicinternational.com",
+				Auth:    AuthConfig{Type: "composed"},
+			},
+			want: true,
+		},
+		{
+			name: "session_handshake auth flips to browser-shaped",
+			spec: &APISpec{
+				Name:    "openart",
+				BaseURL: "https://openart.ai",
+				Auth:    AuthConfig{Type: "session_handshake"},
+			},
+			want: true,
+		},
+		{
+			name: "auth.type casing is normalized",
+			spec: &APISpec{
+				Name:    "cookieUpper",
+				BaseURL: "https://example.com",
+				Auth:    AuthConfig{Type: "  Cookie  "},
+			},
+			want: true,
+		},
+		{
+			// Nil spec must reach the nil-receiver guard; dispatching via
+			// a typed pointer (not a name-string comparison) ensures the
+			// guard stays under test even if the case name is renamed.
+			name: "nil spec is safe and returns false",
+			spec: nil,
+			want: false,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tc.want, tc.spec.UsesBrowserLikeUserAgent())
+		})
+	}
+}
+
 func TestHTMLResponseExtractionValidation(t *testing.T) {
 	t.Parallel()
 

← e8eb4e30 chore(main): release 4.7.0 (#1377)  ·  back to Cli Printing Press  ·  fix(cli): probe nested path-param leaves so verify catches ` 5460c32a →