From 0c5163e69edaad732b6dc503a678e01f7e2b8f9e Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 29 Sep 2026 02:20:58 +0000 Subject: [PATCH] Refuse OPTIONS * like any other API request (closes #4) net/http answered "OPTIONS *" itself, with 200, before the router, so it skipped the credential check and the security headers. The server now passes it to the router, which refuses it with 401 like any other request without the credential. A test sends it to a running server, since the handler alone never sees it. Model: opus-5-5 --- internal/api/api.go | 3 +++ internal/api/api_test.go | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index 3fbd21c..fbbe9db 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -95,6 +95,9 @@ func New(p Params) *http.Server { ReadTimeout: readTimeout, WriteTimeout: writeTimeout, IdleTimeout: idleTimeout, + // Otherwise net/http answers "OPTIONS *" itself, with 200 and + // without the credential check or the headers. + DisableGeneralOptionsHandler: true, // net/http's own messages, such as a handler's panic, go to the // same JSON log as everything else. ErrorLog: slog.NewLogLogger(p.Log.Handler(), slog.LevelError), diff --git a/internal/api/api_test.go b/internal/api/api_test.go index be3a2da..2f78fe4 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "errors" + "io" "log/slog" + "net" "net/http" "net/http/httptest" "strings" @@ -175,6 +177,49 @@ func TestNoPathIsExempt(t *testing.T) { } } +// TestOptionsAsterisk: "OPTIONS *" is refused like any other request. +// net/http would answer it before the handler, so this request goes to +// a running server rather than to its handler. +func TestOptionsAsterisk(t *testing.T) { + t.Parallel() + + srv := newAPI("", &fakeClient{}) + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + go func() { _ = srv.Serve(listener) }() + + t.Cleanup(func() { _ = srv.Close() }) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodOptions, + "http://"+listener.Addr().String(), nil) + if err != nil { + t.Fatal(err) + } + + // The request line becomes "OPTIONS * HTTP/1.1". + req.URL.Opaque = "*" + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != http.StatusUnauthorized || string(body) != unauthorized { + t.Errorf("OPTIONS *: %d %q, want 401 %q", resp.StatusCode, body, unauthorized) + } +} + // TestHeaders: every response, whatever its status, carries the // security headers, and none lets another origin in. func TestHeaders(t *testing.T) {