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:
parent
de236328f8
commit
426ed54594
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue