mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* 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]>
224 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|