Files
zero/internal/oauth/loopback_test.go
Pujitha PaladuguandPujitha Paladugu 176f5a242b fix(oauth): force-close loopback callback connections past the shutdown deadline (#1051)
* fix(oauth): force-close loopback callback connections past the shutdown deadline

Motivation:
LoopbackListener.Close shuts the callback server down with a 1-second
context and discards the result. http.Server.Shutdown only closes idle
connections, so a client that sends a partial request header and never
finishes it counts as active; Shutdown blocks out the full deadline,
times out, and Close returns while that connection and its Serve
goroutine remain open indefinitely. This is a loopback-only (127.0.0.1),
short-lived, single-use server, so the impact is a resource leak past
the end of the flow, not a remote vulnerability or crash.

Approach:
- add a ReadHeaderTimeout to the server so a stalled request header is
  bounded on its own;
- follow a timed-out or otherwise failed Shutdown with server.Close()
  to forcibly tear down any connection still open past the deadline;
- join the Serve goroutine (via a done channel) before Close returns,
  so its completion is observable instead of left dangling.

internal/mcp/oauth.go and internal/provideroauth/openrouter.go build
their own loopback servers with the same pattern; they are left
untouched here to keep this change scoped to the file the issue's
reproduction demonstrates.

Validation:
- go build ./internal/oauth/... and go vet ./internal/oauth/...: clean.
- gofmt -l internal/oauth/: clean (make fmt-check clean repo-wide).
- go test ./internal/oauth/... -race -v -count=1: all pass, including
  three new tests (TestLoopbackCloseForceClosesSlowHeaderConnection,
  TestLoopbackCloseIsIdempotent,
  TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback).
- Confirmed TestLoopbackCloseForceClosesSlowHeaderConnection fails
  against the pre-fix loopback.go (with the new test kept) and passes
  with the fix applied.
- Full repo go build ./..., go vet ./..., and go test ./... pass.
- Could not run make lint-static (golangci-lint) locally: it needs a
  very large dependency download and the local environment ran out of
  disk space fetching it. That check is continue-on-error (advisory)
  in .github/workflows/ci.yml, not a required gate.

Report: https://github.com/Gitlawb/zero/issues/1028
Signed-off-by: Pujitha Paladugu <[email protected]>
Assisted-by: claude-sonnet-5 (via Claude Code)

* test(oauth): synchronize loopback regression tests instead of sleeping

TestLoopbackCloseForceClosesSlowHeaderConnection relied on a fixed
100ms sleep to let Serve accept a connection before sending a stalled
header; under load this could fail or silently skip the force-close
path it's meant to exercise. Use an http.Server.ConnState hook (via a
new unexported newLoopbackListener, set before Serve starts to avoid
a race) to wait for the real StateNew signal instead.

TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback measured
Close's elapsed time without joining its http.Get goroutine or
draining/closing the response body; join it first so the timing
starts from a connection that's actually back to idle.

Also register t.Cleanup(l.Close) in both tests so a Fatalf between
construction and the existing explicit Close doesn't leak the
listener's Serve goroutine.

Signed-off-by: Pujitha Paladugu <[email protected]>

---------

Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
2026-09-15 08:37:58 +08:00

224 lines
6.4 KiB
Go

package oauth
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
)
func TestLoopbackCapturesCode(t *testing.T) {
l, err := NewLoopbackListener("the-state")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
if !strings.HasPrefix(l.RedirectURI(), "http://127.0.0.1:") {
t.Fatalf("redirect URI not loopback: %q", l.RedirectURI())
}
go func() {
_, _ = http.Get(l.RedirectURI() + "?code=auth-code&state=the-state")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
code, err := l.Wait(ctx)
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != "auth-code" {
t.Fatalf("code = %q, want auth-code", code)
}
}
func TestNewLoopbackListenerRejectsEmptyState(t *testing.T) {
if _, err := NewLoopbackListener(" "); err == nil {
t.Fatal("an empty CSRF state must be rejected (fail closed)")
}
}
func TestRedirectURIWithHostUsesGivenHostAndPath(t *testing.T) {
l, err := NewLoopbackListener("the-state")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
got := l.RedirectURIWithHost("localhost", "/auth/callback")
if !strings.HasPrefix(got, "http://localhost:") {
t.Fatalf("redirect URI host = %q, want localhost", got)
}
if !strings.HasSuffix(got, "/auth/callback") {
t.Fatalf("redirect URI path = %q, want /auth/callback suffix", got)
}
}
func TestLoopbackCapturesCodeOnAuthCallbackPath(t *testing.T) {
// The ChatGPT flow uses /auth/callback, not /callback; the listener must
// capture the code on that path too.
l, err := NewLoopbackListener("the-state")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
uri := l.RedirectURIWithHost("127.0.0.1", "/auth/callback")
go func() {
_, _ = http.Get(uri + "?code=auth-code&state=the-state")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
code, err := l.Wait(ctx)
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != "auth-code" {
t.Fatalf("code = %q, want auth-code", code)
}
}
func TestLoopbackRejectsStateMismatch(t *testing.T) {
l, err := NewLoopbackListener("expected-state")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
go func() {
_, _ = http.Get(l.RedirectURI() + "?code=x&state=WRONG")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = l.Wait(ctx)
if !errors.Is(err, ErrStateMismatch) {
t.Fatalf("Wait err = %v, want ErrStateMismatch", err)
}
}
func TestLoopbackTimesOut(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
if _, err := l.Wait(ctx); err == nil {
t.Fatal("Wait should time out cleanly")
}
}
func TestLoopbackProviderError(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
go func() {
_, _ = http.Get(l.RedirectURI() + "?error=access_denied&error_description=nope&state=s")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = l.Wait(ctx)
if err == nil || !strings.Contains(err.Error(), "access_denied") {
t.Fatalf("Wait err = %v, want provider error", err)
}
}
func TestLoopbackCloseForceClosesSlowHeaderConnection(t *testing.T) {
accepted := make(chan struct{}, 1)
l, err := newLoopbackListener("audit-state", 0, func(_ net.Conn, state http.ConnState) {
// StateNew fires as soon as Serve accepts the connection, before it
// blocks reading the (never-completed) header. That's the earliest
// point at which net/http's Shutdown will treat this connection as
// non-idle, which is what this test needs to exercise.
if state == http.StateNew {
select {
case accepted <- struct{}{}:
default:
}
}
})
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
t.Cleanup(l.Close)
addr := l.listener.Addr().String()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
// Wait for Serve to accept the connection before sending a request line
// and a header key with no terminating CRLF, so Serve is guaranteed to
// be blocked reading a partial header (rather than a fixed sleep guessing
// that Accept has run).
select {
case <-accepted:
case <-time.After(3 * time.Second):
t.Fatal("server never accepted the connection")
}
fmt.Fprintf(conn, "GET /callback?code=audit-code&state=audit-state HTTP/1.1\r\nHost: %s\r\nX-Stall:", addr)
started := time.Now()
l.Close()
if elapsed := time.Since(started); elapsed < 900*time.Millisecond {
t.Fatalf("Close returned after %s; expected it to wait out its one-second shutdown deadline first", elapsed)
}
conn.SetReadDeadline(time.Now().Add(time.Second))
var one [1]byte
_, readErr := conn.Read(one[:])
if readErr == nil {
t.Fatal("read after Close succeeded; wanted the stalled connection to be closed")
}
if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() {
t.Fatalf("read after Close timed out (%v); wanted the stalled connection closed rather than left open", readErr)
}
}
func TestLoopbackCloseIsIdempotent(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
l.Close()
l.Close() // must not panic or hang
}
func TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
t.Cleanup(l.Close)
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
resp, err := http.Get(l.RedirectURI() + "?code=x&state=s")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := l.Wait(ctx); err != nil {
t.Fatalf("Wait: %v", err)
}
// Join the client goroutine so the response is fully read (and its
// connection returned to idle) before timing Close, instead of racing it.
select {
case <-requestDone:
case <-time.After(3 * time.Second):
t.Fatal("http.Get goroutine did not complete")
}
started := time.Now()
l.Close()
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("Close took %s after a completed, idle callback; want a prompt return", elapsed)
}
}