From a9d5ec62021eb1208c447a6a7e85e89559d4c03c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 18 Jun 2026 07:26:40 +0000 Subject: [PATCH] hscontrol/api: return a minimal 401 for failed security requirements ogen's SecurityError message echoes the operation name and internal security text. Map it to a clean 401 so the unauthorized body stays small and leaks nothing. --- hscontrol/api/v1/errors.go | 9 +++++++++ hscontrol/api/v1/errors_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 hscontrol/api/v1/errors_test.go diff --git a/hscontrol/api/v1/errors.go b/hscontrol/api/v1/errors.go index e174fbeb..6b5bc8b0 100644 --- a/hscontrol/api/v1/errors.go +++ b/hscontrol/api/v1/errors.go @@ -10,6 +10,7 @@ import ( "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/state" "github.com/juanfont/headscale/hscontrol/types" + "github.com/ogen-go/ogen/ogenerrors" "github.com/rs/zerolog/log" "gorm.io/gorm" ) @@ -96,6 +97,14 @@ func classify(err error) *oas.ErrorStatusCode { return esc } + // A failed security requirement (missing/malformed bearer) must not echo + // ogen's internal "operation X: security ...: not satisfied" message, which + // leaks the operation name. Return a minimal 401. + var secErr *ogenerrors.SecurityError + if errors.As(err, &secErr) { + return apiError(http.StatusUnauthorized, "valid API key required") + } + var coder interface{ Code() int } if errors.As(err, &coder) { return apiError(coder.Code(), err.Error()) diff --git a/hscontrol/api/v1/errors_test.go b/hscontrol/api/v1/errors_test.go new file mode 100644 index 00000000..cf4bf442 --- /dev/null +++ b/hscontrol/api/v1/errors_test.go @@ -0,0 +1,32 @@ +package apiv1 + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/ogen-go/ogen/ogenerrors" +) + +var errSecurityNotSatisfied = errors.New( + `operation ListUsers: security "": security requirement is not satisfied`, +) + +// TestClassifySecurityErrorIsMinimal ensures a failed security requirement +// becomes a clean 401 that does not leak ogen's internal operation/security +// message. +func TestClassifySecurityErrorIsMinimal(t *testing.T) { + secErr := &ogenerrors.SecurityError{Err: errSecurityNotSatisfied} + + esc := classify(secErr) + + if esc.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", esc.StatusCode) + } + + detail := esc.Response.Detail.Or("") + if strings.Contains(detail, "operation") || strings.Contains(detail, "ListUsers") { + t.Errorf("401 detail leaks internals: %q", detail) + } +}