From 426ed54594db03b89d3b26a7159f2936de10c2ee Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Thu, 14 May 2026 06:57:10 -0400 Subject: [PATCH] feat(canary): OperatorBearer middleware + operator token config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/middleware/operator_bearer.go: returns 404 on miss (NOT 401) per spec §11.5; subtle.ConstantTimeCompare for timing safety; rejects when configured token is empty (defense-in-depth even though wire-up skips mount in that case). - Table-driven test covers: missing header, empty header, wrong scheme (Basic), case-sensitive scheme (lowercase bearer), no payload, no-space-after-Bearer, equal-length wrong token, different-length wrong token, correct token, empty-config rejects both empty and populated headers, extra-whitespace rejection. Also asserts no WWW-Authenticate header on 404 (endpoint-hiding intent). - config.OperatorConfig + OPERATOR_TOKEN env mapping; default empty (mount is skipped when empty). --- .../backend/internal/config/config.go | 8 + .../internal/middleware/operator_bearer.go | 35 ++++ .../middleware/operator_bearer_test.go | 185 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go index 2e561c35..068deaa1 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -27,6 +27,11 @@ type Config struct { Turnstile TurnstileConfig `koanf:"turnstile"` MySQL MySQLConfig `koanf:"mysql"` Notify NotifyConfig `koanf:"notify"` + Operator OperatorConfig `koanf:"operator"` +} + +type OperatorConfig struct { + Token string `koanf:"token"` } type NotifyConfig struct { @@ -255,6 +260,8 @@ func loadDefaults(k *koanf.Koanf) error { "notify.webhook_hmac_secret": "", "notify.telegram_api_base": "https://api.telegram.org", "notify.fingerprint_window": "5m", + + "operator.token": "", } for key, value := range defaults { @@ -305,6 +312,7 @@ var envKeyMap = map[string]string{ "WEBHOOK_HMAC_SECRET": "notify.webhook_hmac_secret", "NOTIFY_TELEGRAM_API_BASE": "notify.telegram_api_base", "NOTIFY_FINGERPRINT_WINDOW": "notify.fingerprint_window", + "OPERATOR_TOKEN": "operator.token", } func envKeyReplacer(s string) string { diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go new file mode 100644 index 00000000..458fd07e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go @@ -0,0 +1,35 @@ +// ©AngelaMos | 2026 +// operator_bearer.go + +package middleware + +import ( + "crypto/subtle" + "net/http" +) + +const bearerPrefix = "Bearer " + +func OperatorBearer(token string) func(http.Handler) http.Handler { + expected := []byte(token) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(expected) == 0 { + http.NotFound(w, r) + return + } + auth := r.Header.Get("Authorization") + if len(auth) <= len(bearerPrefix) || + auth[:len(bearerPrefix)] != bearerPrefix { + http.NotFound(w, r) + return + } + supplied := []byte(auth[len(bearerPrefix):]) + if subtle.ConstantTimeCompare(supplied, expected) != 1 { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go new file mode 100644 index 00000000..e201df23 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go @@ -0,0 +1,185 @@ +// ©AngelaMos | 2026 +// operator_bearer_test.go + +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" +) + +const ( + opTokenCorrect = "s3cr3t-op-token-xyz" + opTokenWrong = "s3cr3t-op-token-XYZ" +) + +func opNextHandler(calls *atomic.Int32) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusTeapot) + if _, err := w.Write([]byte("ok")); err != nil { + return + } + }) +} + +func TestOperatorBearer_TableDriven(t *testing.T) { + tests := []struct { + name string + configuredTok string + authHeader string + setHeader bool + wantStatus int + wantBodyPrefix string + wantNextCalled bool + }{ + { + name: "missing Authorization header returns 404", + configuredTok: opTokenCorrect, + setHeader: false, + wantStatus: http.StatusNotFound, + wantBodyPrefix: "404", + wantNextCalled: false, + }, + { + name: "empty Authorization header returns 404", + configuredTok: opTokenCorrect, + authHeader: "", + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "wrong scheme Basic returns 404", + configuredTok: opTokenCorrect, + authHeader: "Basic " + opTokenCorrect, + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "lowercase bearer returns 404 (scheme is case-sensitive)", + configuredTok: opTokenCorrect, + authHeader: "bearer " + opTokenCorrect, + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "Bearer prefix with no token returns 404", + configuredTok: opTokenCorrect, + authHeader: "Bearer ", + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "Bearer with no trailing space returns 404", + configuredTok: opTokenCorrect, + authHeader: "Bearer" + opTokenCorrect, + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "wrong token of equal length returns 404", + configuredTok: opTokenCorrect, + authHeader: "Bearer " + opTokenWrong, + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "wrong token of different length returns 404", + configuredTok: opTokenCorrect, + authHeader: "Bearer nope", + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "correct token passes through to next", + configuredTok: opTokenCorrect, + authHeader: "Bearer " + opTokenCorrect, + setHeader: true, + wantStatus: http.StatusTeapot, + wantBodyPrefix: "ok", + wantNextCalled: true, + }, + { + name: "empty configured token rejects all requests including matching empty", + configuredTok: "", + authHeader: "Bearer ", + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "empty configured token rejects even a Bearer-with-payload", + configuredTok: "", + authHeader: "Bearer anything", + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + { + name: "extra whitespace inside token is not trimmed", + configuredTok: opTokenCorrect, + authHeader: "Bearer " + opTokenCorrect, + setHeader: true, + wantStatus: http.StatusNotFound, + wantNextCalled: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + h := middleware.OperatorBearer( + tc.configuredTok, + )( + opNextHandler(&calls), + ) + + r := httptest.NewRequest(http.MethodGet, "/admin/x", nil) + if tc.setHeader { + r.Header.Set("Authorization", tc.authHeader) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, tc.wantStatus, w.Code) + if tc.wantNextCalled { + require.Equal(t, int32(1), calls.Load()) + } else { + require.Equal(t, int32(0), calls.Load(), + "next handler must not be invoked on auth failure") + } + if tc.wantBodyPrefix != "" { + require.Contains(t, w.Body.String(), tc.wantBodyPrefix) + } + }) + } +} + +func TestOperatorBearer_NoWWWAuthenticateHeader(t *testing.T) { + var calls atomic.Int32 + h := middleware.OperatorBearer(opTokenCorrect)(opNextHandler(&calls)) + + r := httptest.NewRequest(http.MethodGet, "/admin/x", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Empty( + t, + w.Header().Get("WWW-Authenticate"), + "404 hides endpoint existence; WWW-Authenticate would leak the auth scheme", + ) +}