Skip to content

Add browser-like headers to all Slack-bound traffic (refs #5)#22

Open
gammons wants to merge 9 commits into
mainfrom
feat/browser-like-headers
Open

Add browser-like headers to all Slack-bound traffic (refs #5)#22
gammons wants to merge 9 commits into
mainfrom
feat/browser-like-headers

Conversation

@gammons
Copy link
Copy Markdown
Owner

@gammons gammons commented May 20, 2026

Summary

Addresses #5. Decorates every outbound request to *.slack.com with the
HTTP headers a recent desktop Chrome on app.slack.com would send, so
slk's xoxc-token traffic looks indistinguishable from the official
browser client at the header level. Goal: stop Enterprise Grid anomaly
detectors from signing users out / triggering security alerts.

What changed

  • New internal/slackhttp packageBrowserTransport
    (http.RoundTripper) that adds User-Agent (per-OS Chrome 120),
    Accept, Accept-Language, Origin, Referer, and Sec-Fetch-*
    headers to requests bound for *.slack.com hosts. Never overrides
    caller-set headers (so the per-team Authorization: Bearer xoxc-...
    • Cookie: d=... on the image fetcher and the hand-rolled endpoints
      survive). Non-Slack hosts pass through untouched.
  • Slack-go HTTP client (internal/slack/client.go) now constructs
    its inner *http.Client via slackhttp.NewBrowserHTTPClient, so
    every Web API call AND every hand-rolled endpoint POST inherits the
    headers for free.
  • WebSocket upgrade carries the same headers via a new
    wsUpgradeHeaders() helper, with Sec-Fetch-Dest: websocket to match
    what a real browser sends on a WS open. (Gorilla doesn't go through
    http.RoundTripper, so the headers are passed inline to Dialer.Dial.)
  • Image fetcher drops the slk/inline-image-fetcher User-Agent (a
    giveaway just as flagable as Go-http-client/1.1) and now goes
    through BrowserTransport via the client constructed in main.go.
  • README has a new Enterprise Grid section with honest framing:
    best-effort mitigation, please file an issue if you still get booted.

Out of scope (intentionally)

  • TLS fingerprint matching (uTLS). Re-assess only if header parity
    doesn't move the needle.
  • OAuth / Slack App route. Would cripple core features (no access
    to undocumented endpoints like client.counts,
    users.channelSections.list, etc.) and require enterprise admin
    approval. Tracked separately.
  • User-configurable UA override. YAGNI; add only if a real user
    needs it.

Test plan

Automated (all passing in CI / locally):

  • go test ./... -race -count=1 — all 39 packages pass
  • go vet ./... clean
  • go build ./... clean
  • Unit tests for BrowserTransport cover positive (Slack-host
    requests get all 8 headers), negative (non-Slack hosts untouched),
    subdomain matrix (slack.com, files.slack.com,
    hackclub.enterprise.slack.com, wss-primary.slack.com),
    caller-header preservation (UA / Authorization / Cookie), nil
    Header/URL defense, per-GOOS UA selection
  • Integration tests assert the wiring contracts: slack-go's client
    transport is *slackhttp.BrowserTransport; WS upgrade carries
    User-Agent: Mozilla/..., Origin: https://app.slack.com,
    Accept-Language, and Sec-Fetch-Dest: websocket; image fetcher
    no longer emits the legacy slk/inline-image-fetcher UA
  • No stray references to slk/inline-image-fetcher or
    Go-http-client in production code

Manual (maintainer eyes required before broad announce — see
docs/superpowers/plans/2026-05-20-browser-like-headers-verification-notes.md):

  • Run slk against a non-Enterprise workspace with mitmproxy in
    front; visually confirm every Slack request carries the expected
    headers and that neither Go-http-client nor
    slk/inline-image-fetcher appears
  • Functional smoke test: onboarding → channels → send → receive →
    image attachment → thread reply
  • After merge: post the draft Enterprise App Token Support #5 comment asking
    @focusaurus / @raff / @antoszka to retest on Enterprise Grid

Important caveats

This may not be sufficient. The next escalation is TLS fingerprinting
(Go's ClientHello looks nothing like Chrome's), which would require
uTLS. We won't know whether header parity is enough until an Enterprise
Grid user retests. The README and the draft #5 comment frame this
honestly.

gammons added 8 commits May 20, 2026 14:37
Adds a Slack-host-aware http.RoundTripper that decorates outbound
requests to *.slack.com with Chrome-like User-Agent, Origin, Referer,
Accept, Accept-Language, and Sec-Fetch-* headers. Caller-set headers
are preserved. Non-Slack hosts pass through unchanged.

Wiring follows in subsequent commits.

Refs #5
Code review caught that BrowserTransport.RoundTrip panics when a
caller constructs a literal *http.Request with Header == nil (since
http.Header.Clone() returns nil on a nil receiver and the subsequent
setIfMissing writes hit a nil map). Defend against both nil Header
and nil URL — neither fires from slk's current call sites, but the
slackhttp package is public-shaped and shouldn't have latent panics
on legal-but-unusual request shapes.

Also rename the test-local 'cap' variable (which shadowed Go's
builtin) to 'recorder' and tighten http.NewRequest error handling
in the test file.

Refs #5
NewClient and newCookieHTTPClient now construct the inner http.Client
via slackhttp.NewBrowserHTTPClient, so every slack-go Web API call AND
every hand-rolled endpoint POST (which all share the same client) is
decorated with browser-like User-Agent, Origin, Referer, Accept,
Accept-Language, and Sec-Fetch-* headers.

Refs #5
StartWebSocket now passes the same User-Agent, Accept-Language, and
Sec-Fetch-* headers BrowserTransport adds to HTTP calls. Sec-Fetch-Dest
is narrowed to 'websocket' to match what a real browser sends.

Refs #5
The image fetcher previously announced itself with a unique
User-Agent (slk/inline-image-fetcher). Replace it with a default
http.Client wrapped in slackhttp.BrowserTransport so file-CDN
requests on *.slack.com carry the same browser-like headers the
rest of slk's traffic does. Per-request Authorization Bearer + 'd'
cookie remain unchanged.

Refs #5
Records what's been verified automatically (tests, vet, build, header
absence checks) vs what still needs the maintainer's eyes
(mitmproxy/wire-level inspection against a real workspace, functional
smoke test, Enterprise Grid retest).

The draft issue #5 comment is intentionally not auto-posted — it
should ship only after the maintainer has confirmed wire-level
verification.

Refs #5
The plan that drove this branch. Captured here for historical
context alongside the verification-notes companion already committed.

Refs #5
Slack's official browser client never sends Authorization: Bearer to
app.slack.com — it puts the token in the form body as token=xoxc-... .
slk's five hand-rolled endpoints (client.counts, conversations.mark,
subscriptions.thread.mark, users.prefs.get, users.channelSections.list,
subscriptions.thread.getView) were using the Bearer pattern.

Once we started sending browser-shaped headers (Origin: app.slack.com,
Mozilla UA, Sec-Fetch-*) in the previous commits, the combination of
browser headers + Bearer auth on the same request became a contradictory
signature: real browsers never do that. An Enterprise Grid user who
retested PR #22 reported that login and channel-list (slack-go-driven
calls that already used form-body tokens) worked, then selecting a
channel — which fires these hand-rolled endpoints in rapid succession —
logged them out of all clients.

This commit aligns the hand-rolled endpoints with slack-go's convention:
token in the form body, no Authorization header.

The image fetcher's Authorization: Bearer for files.slack.com downloads
is a different surface (CDN auth) and is unchanged for now — the user's
report was channel-select-specific, not image-load.

Refs #5
@gammons
Copy link
Copy Markdown
Owner Author

gammons commented May 20, 2026

Update — fix pushed in response to user retest feedback

@focusaurus / @raff / @antoszka: an Enterprise Grid user retested the
initial commit and reported partial success — login and channel-list
worked, but selecting a channel logged them out of all clients. That's
useful: login and channel-list go through `slack-go`, which puts the
`xoxc` token in the form body (`token=xoxc-...`). Channel-select
fires several hand-rolled endpoints in rapid succession, and all of
them were using `Authorization: Bearer xoxc-...` instead.

Bearer is an OAuth pattern. Real browsers never send Bearer to
`app.slack.com`. Once we started sending browser-shaped headers
(`Origin: https://app.slack.com\`, Chrome UA, `Sec-Fetch-*`), the
combination of "browser headers + Bearer" became a contradictory
request signature — strictly more anomalous than the previous "Go UA +
Bearer" shape, because that one was at least internally consistent.

Commit 7aaf302
moves the token to the form body and removes the Authorization header
for all five hand-rolled endpoints (`client.counts`,
`conversations.mark`, `subscriptions.thread.mark`,
`users.prefs.get`, `users.channelSections.list`,
`subscriptions.thread.getView`). The image fetcher's Bearer for
`files.slack.com` is a different surface (file CDN) and is unchanged
for now — channel-select doesn't trigger image loads.

Empirical evidence the new shape works on Enterprise: it's exactly what
`slack-go` already does, and the same user reported `slack-go`-driven
calls (login, channel-list) were fine.

If you're up for another round of testing on Enterprise Grid, this fix
needs your hands — there's no Enterprise workspace we can verify against.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant