← 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
M internal/generator/generator_test.goM internal/generator/templates/auth_browser.go.tmplM internal/generator/templates/client.go.tmplM internal/spec/spec.goM internal/spec/spec_test.go
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 →