fix(server): proxy routes forward WebSocket upgrades (rewrite Origin) (#257)#291
Conversation
…#257) `routes: type: proxy` loaded HTML fine but every WebSocket upgrade returned 403, so the embedded LiveTemplate client silently fell back to HTTP polling — masking the regression because isReady() stays true under fallback. Root cause: the reverse-proxy Director rewrites req.Host to the upstream but left the Origin header pointing at the proxy's own domain. The upstream (a LiveTemplate app) runs createSecureOriginChecker, whose prod default requires Origin == scheme://Host, so the handshake mismatched and gorilla/websocket returned 403. The stdlib proxy forwards WS upgrades natively (Go 1.12+) — forwarding was never the problem. Fix: rewrite the Origin header to the upstream origin, gated on the WS upgrade headers (new isWebSocketUpgrade helper) so non-WS CORS behavior is untouched. This is the standard reverse-proxy pattern (nginx proxy_set_header Origin) and covers both config-declared proxy routes and embed-lvt auto-routes, which share newProxyRoute. Tests (all proven to fail without the fix, pass with it): - TestProxyRoute_WebSocketCrossOrigin: Go integration reproducing the cross-origin 403 against a strict same-origin upstream. - TestProxyRoute_RewritesOriginOnlyForWS: asserts Origin is rewritten for WS, left unchanged for plain HTTP. - TestProxyRoute_WebSocketUpgradeE2E: chromedp E2E asserting a 101 handshake + WS frame via CDP (not isReady()), capturing console logs, server logs, WS frames, and rendered HTML. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewPR: fix(server): proxy routes forward WebSocket upgrades (rewrite Origin) (#257) OverviewThe fix is correct and tightly scoped. The root cause diagnosis is accurate: What's Good
Issues & SuggestionsMinor —
|
- waitForStatus: return a timeout error so the chromedp log surfaces the real cause instead of a confusing "#status = connecting" assertion. - E2E log capture: also save/restore log.Prefix() alongside Writer/Flags. - proxy_routes: make the Origin-rewrite security contract explicit (the real browser origin is hidden from the upstream; upstreams using Origin for authorization need additional auth). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewOverviewThis PR fixes a real and well-diagnosed bug: the proxy Director rewrote Code Quality —
|
| Area | Finding | Severity |
|---|---|---|
| Security docs | "Hides real browser origin" is code-comment-only; config reference should warn operators | Low |
waitForStatus ctx handling |
Polling loop ignores cancellation; benign in practice | Low |
| Multi-value Upgrade header | Header.Get returns first value only; not a real-world WS issue |
Negligible |
Summary
Correct, well-scoped fix with sound root-cause analysis and excellent test coverage. Main actionable item: add a user-facing documentation note about the Origin-rewrite security contract for proxy routes. The waitForStatus context-cancellation issue is a minor follow-up.
- docs/sources/embed-lvt.md: document the Origin-rewrite security contract for proxy routes (upstreams using Origin for authorization, not just same-origin CSRF protection, need additional auth behind a proxy route). - proxy_ws_e2e_test.go: waitForStatus now respects context cancellation (ctx-scoped timeout + ticker/select) instead of looping on time.Sleep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewOverviewThis is a clean, tightly-scoped fix for the WebSocket 403 in proxied routes. The root-cause analysis is correct: Correctness
Guard condition — Nit:
|
| Test | What it proves |
|---|---|
TestProxyRoute_WebSocketCrossOrigin |
Integration: cross-origin 403 is gone, echo works |
TestProxyRoute_RewritesOriginOnlyForWS |
Unit: Origin rewritten for WS, untouched for plain HTTP |
TestProxyRoute_WebSocketUpgradeE2E |
Browser: 101 handshake + frame via CDP, not the masked isReady() signal |
The E2E waitForStatus helper correctly avoids time.Sleep races. The four-channel diagnostic dump on any failure is excellent practice.
One small observation in authorProxyFixture: YAML config is built with string concatenation. For httptest URLs (http://127.0.0.1:<port>) this is safe; worth noting if the helper is ever reused with user-supplied upstream URLs.
Security
The trade-off is correctly identified and documented in both the code comment and the new docs note. The isWebSocketUpgrade gate ensures non-WS CORS is untouched.
Summary
Well-executed. The root-cause write-up is accurate, the implementation is minimal, and the tests specifically reproduce the failure mode rather than just asserting the happy path. The req.Header.Values nit is the only thing worth a follow-up; everything else is correct or cosmetic. LGTM.
…#291) Addresses the Claude round-3 nit: use http.Header.Values("Connection") to scan every header line, not just the first via Get(). Browsers never split the header, so this is robustness/RFC-correctness rather than a behavior fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — fix(server): proxy routes forward WebSocket upgrades (rewrite Origin) (#257)Overall: Well-scoped, accurately diagnosed fix with solid test coverage. The root cause analysis is correct and the change is minimal. A few items below worth addressing or acknowledging. What works well
Issues / concerns1. for tok := range strings.SplitSeq(line, ",") {
2. Missing test case: WS upgrade with empty The guard // WS upgrade with no Origin header — must not inject one.
noOriginReq := httptest.NewRequest(http.MethodGet, "http://docs.example.com/proxy/ws", nil)
noOriginReq.Header.Set("Upgrade", "websocket")
noOriginReq.Header.Set("Connection", "Upgrade")
noOriginRec := httptest.NewRecorder()
pr.handler.ServeHTTP(noOriginRec, noOriginReq)
if got := noOriginRec.Header().Get("X-Saw-Origin"); got != "" {
t.Errorf("no-Origin WS: upstream saw Origin %q, want empty", got)
}Not a blocker, but this edge case is real — some dialers omit Origin on same-host WS connections. 3. Global log.SetOutput(&tdLog)
t.Cleanup(func() { log.SetOutput(prevOut); ... })The comment says tests run serially — that's fine for today. But if a future author adds 4. Upgrade-without-Connection edge case (informational)
Nits
SummaryThe fix is correct and well-targeted. Items 1 and 2 above are low-effort improvements (a comment and one extra test case). Item 3 warrants at least a |
- proxy_routes.go: note the strings.SplitSeq Go 1.24+ dependency in the isWebSocketUpgrade doc comment. - proxy_routes_test.go: add a sub-case covering a WS upgrade with no Origin header — the != "" guard must not inject one. - proxy_ws_e2e_test.go: strengthen the global-log-redirect comment to a SERIAL warning so a future t.Parallel() doesn't silently introduce a race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewGood diagnosis and a well-scoped fix. The root cause identification (Origin header mismatch, not missing WS forwarding) is confirmed correct —
|
- isWebSocketUpgrade: guard on req.Method == GET (RFC 6455 §4.1) so the helper's intent is precise — a non-GET carrying Upgrade headers is not a real handshake and shouldn't have its Origin rewritten. - authorProxyFixture: build the YAML upstream value with %q for hygiene if the test helper is ever reused with a URL containing special characters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewSummary: This PR fixes a 403 on proxied WebSocket upgrades by rewriting the Overall this is a well-scoped, well-documented fix. A few observations below. Correctness
Potential Issues1. E2E test: global // proxy_ws_e2e_test.go:57-60
log.SetOutput(&tdLog)
t.Cleanup(func() { log.SetOutput(prevOut); ... })The comment warns against 2. if strings.HasPrefix(txt, wantPrefix) || strings.HasPrefix(txt, "WS_ERROR") {
return nil // no error returned
}Exiting without an error means 3. The current three-condition check (GET + Test CoverageThe three-tier test strategy (integration → unit gate → E2E) is well-structured:
The SecurityThe PR is appropriately transparent about the security contract:
One minor doc nit: the security note in CosmeticThe alignment-only change to VerdictApprove with suggestions. The fix is correct, minimal, and well-tested. Items 1 and 2 above are worth addressing before merge; item 3 is optional hardening. |
Summary
Fixes #257.
routes: type: proxyloaded HTML fine but every WebSocket upgrade returned 403, so the embedded LiveTemplate client silently fell back to HTTP polling — invisible becauseisReady()staystrueunder fallback.Root cause (confirmed from primary source, not inferred): the issue title ("doesn't forward WebSocket upgrades") misdiagnoses it. Go's stdlib
httputil.ReverseProxyforwards WS upgrades natively (Go 1.12+; repo is on 1.26). The real bug: the Director rewritesreq.Hostto the upstream but left theOriginheader pointing at the proxy's domain. The upstream (a LiveTemplate app) runscreateSecureOriginChecker, whose production default requiresOrigin == scheme://Host— so the handshake mismatched and gorilla/websocket returned 403.Fix
Rewrite the
Originheader to the upstream origin inside the proxy Director, gated on the WebSocket upgrade headers (newisWebSocketUpgradehelper) so non-WS CORS behavior is untouched. This is the standard reverse-proxy pattern (nginxproxy_set_header Origin). One change point innewProxyRoutecovers both config-declaredroutes: type: proxyandembed-lvtauto-routes.Tests (each proven to fail without the fix, pass with it)
TestProxyRoute_WebSocketCrossOrigin— Go integration reproducing the cross-origin 403 against a strict same-origin upstream.TestProxyRoute_RewritesOriginOnlyForWS— asserts Origin is rewritten for WS, left unchanged for plain HTTP.TestProxyRoute_WebSocketUpgradeE2E— chromedp E2E asserting a 101 handshake + WS frame via CDP (deliberately notisReady(), which masks the bug), capturing all four diagnostic channels: console logs, server logs, WS frames, rendered HTML.Verification
go build ./...clean;go test -race -tags=ci -skip='E2E|e2e' ./...→ all packages pass.Scope
Tight per triage. The new WS-origin E2E partially addresses #143. #144 (configurable CSP) is the inverse scenario (tinkerdown's own
/wsbehind a proxy) and stays separate.🤖 Generated with Claude Code