feat(canary): OperatorBearer middleware + operator token config

- 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).
This commit is contained in:
CarterPerez-dev 2026-05-14 06:57:10 -04:00
parent de236328f8
commit 426ed54594
3 changed files with 228 additions and 0 deletions

View File

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

View File

@ -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)
})
}
}

View File

@ -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",
)
}