Skip to content

Instantly share code, notes, and snippets.

@hackmajoris
Last active July 23, 2026 08:10
Show Gist options
  • Select an option

  • Save hackmajoris/69ed4c0109206e2e89f70e0c7f80c620 to your computer and use it in GitHub Desktop.

Select an option

Save hackmajoris/69ed4c0109206e2e89f70e0c7f80c620 to your computer and use it in GitHub Desktop.
go-cookies-proxy
// cookieproxy mirrors your live browser session onto an API client.
//
// It reads cookies for a given domain out of the local browser cookie store
// and (a) injects them into requests it reverse-proxies to the real host, and
// (b) serves them as JSON so an HTTP client can pick them up directly.
//
// Intended for testing SAML/SSO-protected JSON endpoints from IntelliJ's HTTP
// client, Postman, curl, etc. -- tools that cannot perform the interactive
// SAML redirect flow themselves.
//
// Build:
//
// go build -o cookieproxy
// GOOS=windows GOARCH=amd64 go build -o cookieproxy.exe # cross-compile from WSL
//
// Run:
//
// ./cookieproxy -target https://myapp.example.com
//
// Then, from your HTTP client:
//
// GET http://localhost:8080/api/data
//
// NOTE: the binary must run on the machine and under the user account whose
// browser holds the session -- the cookie store is encrypted with an OS-level
// key tied to that account. Under WSL that means building for Windows and
// running the .exe there, not running it inside WSL.
package main
import (
"context"
"encoding/json"
"flag"
"log"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/browserutils/kooky"
_ "github.com/browserutils/kooky/browser/all" // registers the browser backends
)
var (
target = flag.String("target", "https://myapp.example.com", "upstream base URL to proxy to")
domain = flag.String("domain", "", "cookie domain suffix (defaults to the target's host)")
listen = flag.String("listen", "127.0.0.1:8080", "address for the reverse proxy")
cookieListen = flag.String("cookie-listen", "127.0.0.1:7777", "address for the /cookie JSON endpoint (empty to disable)")
ttl = flag.Duration("ttl", 5*time.Minute, "how long to cache the cookie header before re-reading the store")
verbose = flag.Bool("v", false, "log every proxied request")
)
// cache holds the assembled Cookie header so we are not decrypting the
// browser's store on every single request -- that is slow and, on macOS,
// can trigger repeated keychain prompts.
type cache struct {
mu sync.Mutex
value string
expiry time.Time
}
var jar cache
// header returns a "name=value; name=value" string suitable for a Cookie
// header, refreshing from the browser store when the cached copy is stale.
func (c *cache) header(cookieDomain string) string {
c.mu.Lock()
defer c.mu.Unlock()
if c.value != "" && time.Now().Before(c.expiry) {
return c.value
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cookies, err := kooky.ReadCookies(ctx,
kooky.Valid, // drop expired entries
kooky.DomainHasSuffix(cookieDomain), // only this app's cookies
)
if err != nil {
// Partial failures are normal: kooky probes paths for browsers that
// are not installed ("Chrome SxS" = Canary, plus Chromium, Brave,
// Edge Dev, ...). Only give up if we actually got nothing back.
//
// A stale cookie is still more useful than none: the request may
// succeed anyway, and the failure mode is a clear 302 rather than a
// confusing empty header.
if len(cookies) == 0 {
log.Printf("cookie read failed (%v); reusing previous value", err)
return c.value
}
log.Printf("cookie read partial, ignoring: %v", err)
}
// Later stores win on name collision; dedupe so we do not send the same
// cookie twice with different values.
seen := make(map[string]string, len(cookies))
for _, ck := range cookies {
if ck.Name == "" {
continue
}
seen[ck.Name] = ck.Value
}
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names) // stable output makes logs diffable
parts := make([]string, 0, len(names))
for _, name := range names {
parts = append(parts, name+"="+seen[name])
}
c.value = strings.Join(parts, "; ")
c.expiry = time.Now().Add(*ttl)
if len(seen) == 0 {
log.Printf("WARNING: found 0 cookies for %q -- is the browser logged in, "+
"and is this binary running as the right OS user?", cookieDomain)
} else {
log.Printf("refreshed %d cookies for %s: %s", len(seen), cookieDomain, strings.Join(names, ", "))
}
return c.value
}
func main() {
flag.Parse()
upstream, err := url.Parse(*target)
if err != nil {
log.Fatalf("bad -target %q: %v", *target, err)
}
if upstream.Scheme == "" || upstream.Host == "" {
log.Fatalf("bad -target %q: need a full URL, e.g. https://myapp.example.com", *target)
}
cookieDomain := *domain
if cookieDomain == "" {
cookieDomain = upstream.Hostname()
}
// Warm the cache once at startup so problems surface immediately rather
// than on the first request.
jar.header(cookieDomain)
proxy := httputil.NewSingleHostReverseProxy(upstream)
inner := proxy.Director
proxy.Director = func(r *http.Request) {
inner(r)
// NewSingleHostReverseProxy preserves the inbound Host header, which
// would be "localhost:8080". Gateways in front of SSO-protected apps
// routinely route on Host, so rewrite it.
r.Host = upstream.Host
r.Header.Set("Cookie", jar.header(cookieDomain))
// Identify ourselves rather than leaking Go's default UA; some
// gateways behave differently for non-browser agents.
if r.Header.Get("User-Agent") == "" {
r.Header.Set("User-Agent", "cookieproxy")
}
if *verbose {
log.Printf("-> %s %s%s", r.Method, upstream.Host, r.URL.RequestURI())
}
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("proxy error for %s: %v", r.URL.Path, err)
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
}
// Optional side channel: hand the raw header to clients that would rather
// keep pointing at the real host and set Cookie themselves.
if *cookieListen != "" {
mux := http.NewServeMux()
mux.HandleFunc("/cookie", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"cookie": jar.header(cookieDomain),
"domain": cookieDomain,
})
})
go func() {
log.Printf("cookie endpoint http://%s/cookie", *cookieListen)
if err := http.ListenAndServe(*cookieListen, mux); err != nil {
log.Printf("cookie endpoint stopped: %v", err)
}
}()
}
log.Printf("reverse proxy http://%s -> %s", *listen, upstream)
log.Fatal(http.ListenAndServe(*listen, proxy))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment