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) {