Tiny reverse proxy that sits in front of the Gladly v5 CloudFront distribution and strips non-whitelisted cookies before forwarding.
CloudFront enforces a 32 KB request-header limit and returns 494 Request Header Too Large when exceeded. Users whose browsers have accumulated a large cookie jar were hitting this. This proxy solves it by dropping every cookie that isn't on a known whitelist before the request reaches the upstream, and by telling the browser to delete the biggest offenders so they stop coming back.
- Client sends request to
https://<your-host>/... - Proxy parses the
Cookieheader and classifies each cookie:- Whitelisted → forwarded unchanged upstream
- Non-whitelisted, pair > 1000 bytes → stripped from the request AND a
Set-Cookie: <name>=; Max-Age=0is emitted in the response to delete it from the browser - Non-whitelisted, pair ≤ 1000 bytes → stripped from the request, left alone in the browser
- Response
Set-Cookieheaders from the upstream are also filtered to the whitelist so it can't reintroduce bloat.
Defined in main.go (allowedCookies):
| Cookie | Purpose |
|---|---|
gladly_session |
Laravel session ID (Redis key) |
XSRF-TOKEN |
CSRF token |
TabAuthToken |
Firebase ID token, set by v5 Blade JS |
TabAuth.AuthUser |
Firebase user object set by tab-web's next-firebase-auth |
TabAuth.AuthUser.sig |
Koa signed-cookie signature for TabAuth.AuthUser |
TabAuth.AuthUserTokens |
Firebase idToken+refreshToken from tab-web's next-firebase-auth |
TabAuth.AuthUserTokens.sig |
Koa signed-cookie signature for TabAuth.AuthUserTokens |
tabV4OptIn |
tab-web v4 opt-in flag |
tabV4OptIn.sig |
Koa signed-cookie signature for tabV4OptIn |
Edit the map and redeploy to change it. Threshold constant is deleteCookieThresholdBytes = 1000.
All via environment variables. Both modes work with the same binary.
| Var | Default | Notes |
|---|---|---|
UPSTREAM_HOST |
(unset; required) | Hostname requests are forwarded to (e.g. example.cloudfront.net) |
TLS_HOST |
(unset) | Set → production mode (autocert on :80 + :443). Unset → dev mode (plain HTTP). |
CERT_DIR |
/var/lib/tab-proxy/certs |
Where autocert caches Let's Encrypt certs |
ACME_EMAIL |
(unset) | Optional; LE uses it for expiry notifications |
PORT |
8080 |
Dev mode only (TLS_HOST unset) |
go run .
# listens on :8080 plain HTTP
curl -v http://localhost:8080/health
curl -v -H 'Cookie: gladly_session=x; junk=y' http://localhost:8080/Build + vet:
go build ./...
go vet ./..../deploy.shWhat it does:
- Cross-compiles
GOOS=linux GOARCH=arm64with-trimpath -ldflags='-s -w' - Generates a systemd unit pinned to the current
TLS_HOST/ACME_EMAIL scps binary + unit to the box- Creates system user
tabproxy, app dir, cert cache dir (idempotent) - Atomically swaps binary via
install(1) daemon-reload,restart, tails the last 15 journal lines
Overridable env:
SSH_HOST=<ssh-config-alias> # ~/.ssh/config alias for the target host
TLS_HOST=<your-host> # hostname (or comma-separated list) for LE cert
ACME_EMAIL=<you@example.com> # LE notificationsDowntime per deploy: one systemctl restart (~1s) plus at most one RestartSec window (2s).
Designed for a single-instance deployment. The reference setup is an AWS EC2 t4g.small running Ubuntu 24.04 LTS arm64, with an Elastic IP, a Route 53 A record, and a security group exposing 22/80/443. Any small Linux host with public 80/443 will work — autocert handles cert issuance from there.
App-level restarts are handled by systemd (Restart=always, RestartSec=2s). Pair with platform-level instance recovery (e.g. EC2 automatic recovery, CloudWatch status-check alarms) for hardware failure coverage.
Recommended: define a host alias in ~/.ssh/config so deploy scripts can reference it by name:
ssh <host-alias>Direct form:
ssh -i your-key.pem -o IdentitiesOnly=yes ubuntu@<your-host>IdentitiesOnly=yes is important — without it, ssh-agent offers every loaded key first and the server hits MaxAuthTries before trying the .pem.
App logs go to systemd-journald on the instance.
| Storage | Persistent (/var/log/journal/<machine-id>/system.journal) |
| Size cap | 4 GB (SystemMaxUse=4G in /etc/systemd/journald.conf.d/tab-proxy-retention.conf) |
| Retention | 5 days max (MaxRetentionSec=5day); whichever cap hits first wins. At current ~25 req/s burn rate the size cap kicks in around day 4. |
Useful commands:
# Live tail
ssh <host-alias> 'sudo journalctl -u tab-proxy -f'
# Last N lines
ssh <host-alias> 'sudo journalctl -u tab-proxy -n 200 --no-pager'
# Time range
ssh <host-alias> 'sudo journalctl -u tab-proxy --since "1 hour ago"'
# Grep cookie events
ssh <host-alias> 'sudo journalctl -u tab-proxy --since today' | grep "cookies deleted"
# Structured JSON
ssh <host-alias> 'sudo journalctl -u tab-proxy -o json'
# Export a chunk to disk
ssh <host-alias> 'sudo journalctl -u tab-proxy --since yesterday --no-pager' > tab-proxy.logOne line per proxied request. The optional deleted= field is appended only when at least one cookie crossed the size threshold:
<client-ip> (<country>) <method> <path> cookies=<total Cookie-header size> [deleted=<comma-separated names>]
Example:
2026/04/24 21:30:15 192.0.2.10 (US) GET /v5/cookies-test cookies=1.4 KB
2026/04/24 21:30:15 192.0.2.10 (US) POST /v5/login cookies=2.3 KB deleted=bigcookie
Single-line on purpose: two log.Printf calls per request would interleave under concurrent load and break per-IP attribution when grepping the journal.
The client IP is taken from the TCP peer (r.RemoteAddr). X-Forwarded-For is not honored — this proxy is hit directly by clients, so the peer IP is the real client. If a CDN ever ends up in front of this box, add a trusted-proxy allowlist before trusting XFF.
Only cookie names are logged, never values — so session tokens don't leak into logs.
/health hits are not routed through the proxy handler and do not produce these lines.
Every proxied response carries Via: 1.1 tab-proxy (appended to the existing Via chain from CloudFront, per RFC 9110 §7.6.3). One-liner check:
curl -sI https://<your-host>/v5/cookies-test | grep -i via
# via: 1.1 <id>.cloudfront.net (CloudFront)
# via: 1.1 tab-proxy/health does not include this header — it's served locally and never touches an upstream.
ssh <host-alias> 'sudo systemctl status tab-proxy'
ssh <host-alias> 'sudo systemctl is-active tab-proxy'ssh <host-alias> 'sudo systemctl restart tab-proxy'deploy.sh doesn't version artifacts. Easiest rollback is git checkout the previous SHA locally and re-run ./deploy.sh. For faster recovery we'd need to start keeping timestamped binaries under /opt/tab-proxy/releases/ and swapping a symlink — happy to add when needed.
autocert auto-renews ~30 days before expiry. To force early, wipe the cache and restart:
ssh <host-alias> 'sudo rm -rf /var/lib/tab-proxy/certs/* && sudo systemctl restart tab-proxy'
curl -v https://<your-host>/health # first request re-issuesEdit allowedCookies or deleteCookieThresholdBytes in main.go, then ./deploy.sh.
The unit runs the app as the non-root tabproxy user with:
CAP_NET_BIND_SERVICEonly (to bind :80 and :443)NoNewPrivileges=trueProtectSystem=strict+ReadWritePaths=/var/lib/tab-proxy/certs(cert cache is the only writable path)ProtectHome=true,PrivateTmp=true
- CloudWatch Logs forwarding. Logs are only on the instance. If it gets re-imaged, logs are lost. Ready to wire up the CloudWatch Agent whenever desired.
- External uptime check.
/healthexists, but nothing external is hitting it on a schedule. A Route 53 health check ($0.50/mo) + SNS email alarm is the obvious next step. - Alarm notifications. The CloudWatch alarms trigger EC2 recovery actions but don't notify anyone. Wire SNS → email/Slack when desired.
- Versioned releases / rollback. See runbook note above.
- SSH restricted to a CIDR. Port 22 is currently open to
0.0.0.0/0. Tighten once a stable office/VPN CIDR is available.