diff --git a/apps/hosted/self-host/native/main.go b/apps/hosted/self-host/native/main.go index e16fe2e2e7..790be294c1 100644 --- a/apps/hosted/self-host/native/main.go +++ b/apps/hosted/self-host/native/main.go @@ -666,7 +666,10 @@ func serve(mode string) error { }, time.Second, 30*time.Second, time.After) defer stopMotel() - proxy := productProxy(filepath.Join(temporary, "product.sock"), workerdIdleTimeout) + // A proxy that ends TLS reaches this host over plain HTTP. The product derives its origin from + // Host and X-Forwarded-Proto, so the configured origin, never the client, names the scheme. + publicHTTPS := strings.HasPrefix(values["BETTER_AUTH_URL"], "https://") + proxy := productProxy(filepath.Join(temporary, "product.sock"), workerdIdleTimeout, publicHTTPS) publicListener, err := net.Listen("tcp", net.JoinHostPort(setting("HOST", "0.0.0.0"), port)) if err != nil { command.Process.Kill() @@ -714,7 +717,7 @@ const workerdIdleTimeout = 5 * time.Second // sends a request on a connection workerd is closing. Go replays only requests // that are safe to repeat, so losing that race failed POSTs such as workflow runs. -func productProxy(socket string, upstreamIdleTimeout time.Duration) *httputil.ReverseProxy { +func productProxy(socket string, upstreamIdleTimeout time.Duration, publicHTTPS bool) *httputil.ReverseProxy { return &httputil.ReverseProxy{ Rewrite: func(request *httputil.ProxyRequest) { request.SetURL(&url.URL{Scheme: "http", Host: "product.internal"}) @@ -724,6 +727,10 @@ func productProxy(socket string, upstreamIdleTimeout time.Duration) *httputil.Re address = request.In.RemoteAddr } request.Out.Header.Set("x-executor-client-ip", address) + // Rewrite has already removed any X-Forwarded-Proto the client sent. + if publicHTTPS { + request.Out.Header.Set("X-Forwarded-Proto", "https") + } }, Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { diff --git a/apps/hosted/self-host/native/main_test.go b/apps/hosted/self-host/native/main_test.go index 3ceee3f2d8..10c8df48b1 100644 --- a/apps/hosted/self-host/native/main_test.go +++ b/apps/hosted/self-host/native/main_test.go @@ -102,7 +102,7 @@ func TestProxyDoesNotReuseConnectionsWorkerdHasTimedOut(t *testing.T) { socket := socketPath(t) upstream := &fakeWorkerd{idleTimeout: 300 * time.Millisecond} upstream.serve(t, socket) - front := httptest.NewServer(productProxy(socket, upstream.idleTimeout)) + front := httptest.NewServer(productProxy(socket, upstream.idleTimeout, false)) defer front.Close() if status, body := post(t, front.URL); status != 200 { @@ -121,7 +121,7 @@ func TestProxyDoesNotRetryPostWorkerdMayHaveReceived(t *testing.T) { socket := socketPath(t) upstream := &fakeWorkerd{idleTimeout: time.Minute, dropFirst: true} upstream.serve(t, socket) - front := httptest.NewServer(productProxy(socket, upstream.idleTimeout)) + front := httptest.NewServer(productProxy(socket, upstream.idleTimeout, false)) defer front.Close() status, body := post(t, front.URL) @@ -134,7 +134,7 @@ func TestProxyDoesNotRetryPostWorkerdMayHaveReceived(t *testing.T) { } func TestProxyReportsStartingUntilWorkerdListens(t *testing.T) { - front := httptest.NewServer(productProxy(socketPath(t), workerdIdleTimeout)) + front := httptest.NewServer(productProxy(socketPath(t), workerdIdleTimeout, false)) defer front.Close() if status, body := post(t, front.URL); status != http.StatusServiceUnavailable || body != "Executor is starting" { @@ -142,6 +142,45 @@ func TestProxyReportsStartingUntilWorkerdListens(t *testing.T) { } } +// Behind a proxy that ends TLS, the product derives its origin from Host and X-Forwarded-Proto. +// The scheme workerd sees comes from the configured origin, whatever the client sent. +func TestProxyForwardsTheSchemeOfTheConfiguredOrigin(t *testing.T) { + for _, publicHTTPS := range []bool{true, false} { + socket := socketPath(t) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + seen := make(chan string, 1) + upstream := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen <- r.Host + " " + r.Header.Get("X-Forwarded-Proto") + })} + go upstream.Serve(listener) + t.Cleanup(func() { upstream.Close() }) + front := httptest.NewServer(productProxy(socket, workerdIdleTimeout, publicHTTPS)) + t.Cleanup(front.Close) + + request, err := http.NewRequest("POST", front.URL+"/api/dashboard/batch", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + request.Host = "executor.example:4788" + claimed, want := "https", "executor.example:4788 " + if publicHTTPS { + claimed, want = "http", want+"https" + } + request.Header.Set("X-Forwarded-Proto", claimed) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if got := <-seen; got != want { + t.Fatalf("publicHTTPS %v, client sent %q: workerd saw %q, want %q", publicHTTPS, claimed, got, want) + } + } +} + // The collector runs beside the product: an exit is followed by a restart, not by the product // stopping. The waits double from the minimum to the maximum, so a collector that cannot start // does not spin, and return to the minimum after a run longer than the maximum.