From 174aa65917db125f9a76e83753ea97c6551c20be Mon Sep 17 00:00:00 2001 From: zingzy Date: Wed, 22 Jul 2026 18:39:58 +0530 Subject: [PATCH 1/2] feat(api): send X-Spoo-Client header on backend requests Every request to the spoo.me API now carries X-Spoo-Client: cli/ so the backend can attribute traffic by client. The version is injected by goreleaser and falls back to a bare cli value when it does not fit the allowed charset. Only backend hosts receive the header. --- .goreleaser.yaml | 2 +- internal/api/client.go | 16 ++++++++++++++++ internal/api/client_test.go | 33 +++++++++++++++++++++++++++++++++ internal/api/inspect.go | 1 + 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c0eb3bd..45ea49b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,7 +17,7 @@ builds: goos: [linux, darwin, windows] goarch: [amd64, arm64] ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} + - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X github.com/spoo-me/spoo-cli/internal/api.Version={{.Version}} archives: - formats: [tar.gz] diff --git a/internal/api/client.go b/internal/api/client.go index 297cb2a..e50e3ec 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -10,12 +10,27 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" "github.com/spoo-me/spoo-cli/internal/auth" ) +// Version is the CLI release, injected by goreleaser via ldflags. +var Version = "dev" + +var versionRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,16}$`) + +// clientHeader identifies the CLI (and its version, when well-formed) to +// the backend so API traffic can be attributed per client. +func clientHeader() string { + if versionRe.MatchString(Version) { + return "cli/" + Version + } + return "cli" +} + type Client struct { base string http *http.Client @@ -96,6 +111,7 @@ func (c *Client) send(ctx context.Context, method, path string, query url.Values return nil, err } req.Header.Set("User-Agent", "spoo-cli") + req.Header.Set("X-Spoo-Client", clientHeader()) if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index d92d2b8..9422087 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -44,6 +44,39 @@ func TestDoSendsBearerToken(t *testing.T) { } } +func TestDoSendsClientHeader(t *testing.T) { + var gotClient string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClient = r.Header.Get("X-Spoo-Client") + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(srv.URL, newTestStore(t, nil)) + if err := c.do(context.Background(), http.MethodGet, "/auth/me", nil, nil, nil); err != nil { + t.Fatal(err) + } + if gotClient != "cli/dev" { + t.Fatalf("X-Spoo-Client = %q, want cli/dev", gotClient) + } +} + +func TestClientHeaderRejectsMalformedVersion(t *testing.T) { + orig := Version + defer func() { Version = orig }() + for version, want := range map[string]string{ + "1.2.3": "cli/1.2.3", + "0.2.0-SNAPSHOT-697203b": "cli", // >16 chars + "1.0+meta": "cli", // invalid charset + "": "cli", + } { + Version = version + if got := clientHeader(); got != want { + t.Errorf("clientHeader() with Version=%q = %q, want %q", version, got, want) + } + } +} + func TestDoParsesErrorEnvelope(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) diff --git a/internal/api/inspect.go b/internal/api/inspect.go index 9486268..0be840f 100644 --- a/internal/api/inspect.go +++ b/internal/api/inspect.go @@ -28,6 +28,7 @@ func (c *Client) Inspect(ctx context.Context, shortCode string) (*InspectResult, return nil, err } req.Header.Set("User-Agent", "spoo-cli") + req.Header.Set("X-Spoo-Client", clientHeader()) resp, err := noFollow.Do(req) if err != nil { return nil, err From 9584752dccef8458624a704a036cd6ab6f90a81f Mon Sep 17 00:00:00 2001 From: zingzy Date: Wed, 22 Jul 2026 20:16:39 +0530 Subject: [PATCH 2/2] fix: strip client header on cross-origin redirects The default Go client forwards custom headers to redirect targets on other hosts. Attribution belongs to the spoo API only, so CheckRedirect drops X-Spoo-Client when a redirect leaves the original host. Same-host redirects keep it. Regression tests cover both. --- internal/api/client.go | 16 ++++++++++++-- internal/api/client_test.go | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index e50e3ec..e6d2ac0 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -39,8 +39,20 @@ type Client struct { func New(base string, store *auth.Store) *Client { return &Client{ - base: strings.TrimRight(base, "/"), - http: &http.Client{Timeout: 30 * time.Second}, + base: strings.TrimRight(base, "/"), + http: &http.Client{ + Timeout: 30 * time.Second, + // Go forwards custom headers on redirects, including + // cross-origin ones. Attribution belongs to the spoo API + // only, so drop it whenever a redirect leaves the original + // host. Go itself strips Authorization on cross-domain hops. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Host != via[0].URL.Host { + req.Header.Del("X-Spoo-Client") + } + return nil + }, + }, store: store, } } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 9422087..8d24a4a 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -130,3 +130,46 @@ func TestDoRefreshesOn401AndRetries(t *testing.T) { t.Fatalf("store not updated after refresh: %+v", got) } } + +func TestClientHeaderStrippedOnCrossOriginRedirect(t *testing.T) { + gotClient := "unset" + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClient = r.Header.Get("X-Spoo-Client") + w.Write([]byte(`{}`)) + })) + defer target.Close() + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/final", http.StatusFound) + })) + defer redirector.Close() + + c := New(redirector.URL, newTestStore(t, nil)) + if err := c.do(context.Background(), http.MethodGet, "/start", nil, nil, nil); err != nil { + t.Fatal(err) + } + if gotClient != "" { + t.Fatalf("X-Spoo-Client forwarded cross-origin = %q, want empty", gotClient) + } +} + +func TestClientHeaderKeptOnSameHostRedirect(t *testing.T) { + gotClient := "unset" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/start" { + http.Redirect(w, r, "/final", http.StatusFound) + return + } + gotClient = r.Header.Get("X-Spoo-Client") + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(srv.URL, newTestStore(t, nil)) + if err := c.do(context.Background(), http.MethodGet, "/start", nil, nil, nil); err != nil { + t.Fatal(err) + } + if gotClient != "cli/dev" { + t.Fatalf("X-Spoo-Client after same-host redirect = %q, want cli/dev", gotClient) + } +}