From c988e4b902073284b5567532902943a7644714b6 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 15:04:29 -0400 Subject: [PATCH] feat(canary): middleware additions (realip, fingerprint, recovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 first commit. Three middleware additions that the token handler + service will consume. - realip.go: RealIP(r) + OptionalHeader(v) + lastNonEmptyXFF — promotion of the helpers currently duplicated across webbug/ slowredirect/docx/pdf/kubeconfig/envfile/mysql generators. The bodies are byte-identical to those copies. Phase 9 task 9.8 refactors webbug to use this shared helper next; the other generators' duplication remains until Phase 10's Trigger plumbing refactor (Phase 9 keeps the scope to webbug per the plan) - fingerprint.go: ExtractFingerprint(r) = hex(sha256(realIP|UA))[:16] + KeyByFingerprint(r) = "ratelimit:fp:" + fp, per spec §11.2 lines 1568-1591. The 16-char prefix is a deliberate space/ collision trade-off documented in the spec — full sha256 is 64 chars, 16 chars = 64 bits ≈ 1e19 keyspace, fine for fingerprint rate-limiting where collisions are caught by the rate limit granularity anyway - recovery.go: Recovery(logger) middleware — defer recover(), logs panic + request_id + path + stack, writes a 500 with the project's standard {success:false, error:{code,message}} envelope. Per spec §11.1 line 1543 Tests (~16 cases): 11 source-IP precedence cases matching the generator suites + OptionalHeader empty/whitespace/value + fingerprint determinism/length/hex-format/different-IPs-distinct + KeyByFingerprint prefix + recovery panic-returns-500 + recovery passes-through-non-panic + recovery logs request ID through the RequestID middleware chain. Phase 9 task 9.1 + 9.4 discharged. --- .../internal/middleware/fingerprint.go | 26 +++ .../internal/middleware/fingerprint_test.go | 159 ++++++++++++++++++ .../backend/internal/middleware/realip.go | 53 ++++++ .../backend/internal/middleware/recovery.go | 46 +++++ .../internal/middleware/recovery_test.go | 84 +++++++++ 5 files changed, 368 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go new file mode 100644 index 00000000..da7a77dd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go @@ -0,0 +1,26 @@ +// ©AngelaMos | 2026 +// fingerprint.go + +package middleware + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" +) + +const ( + fingerprintHexLen = 16 + + rateLimitKeyByFingerprintPrefix = "ratelimit:fp:" +) + +func ExtractFingerprint(r *http.Request) string { + raw := RealIP(r) + "|" + r.UserAgent() + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:])[:fingerprintHexLen] +} + +func KeyByFingerprint(r *http.Request) string { + return rateLimitKeyByFingerprintPrefix + ExtractFingerprint(r) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go new file mode 100644 index 00000000..54a35b6f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go @@ -0,0 +1,159 @@ +// ©AngelaMos | 2026 +// fingerprint_test.go + +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" +) + +func newRequest(remote string, headers map[string]string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = remote + for k, v := range headers { + r.Header.Set(k, v) + } + return r +} + +func TestRealIP_Precedence(t *testing.T) { + cases := []struct { + name string + headers map[string]string + remote string + want string + }{ + { + "CF wins", + map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "X-Forwarded-For": "198.51.100.1", + "X-Real-IP": "192.0.2.99", + }, + "127.0.0.1:9", + "203.0.113.10", + }, + { + "XFF rightmost no CF", + map[string]string{ + "X-Forwarded-For": "198.51.100.1, 198.51.100.7", + "X-Real-IP": "192.0.2.99", + }, + "127.0.0.1:9", + "198.51.100.7", + }, + { + "XFF trailing comma falls through", + map[string]string{ + "X-Forwarded-For": "198.51.100.1, ", + "X-Real-IP": "192.0.2.99", + }, + "127.0.0.1:9", + "198.51.100.1", + }, + { + "XFF empty entries fall to XRI", + map[string]string{ + "X-Forwarded-For": ", ,", + "X-Real-IP": "192.0.2.99", + }, + "127.0.0.1:9", + "192.0.2.99", + }, + { + "XRI when no CF or XFF", + map[string]string{"X-Real-IP": "192.0.2.99"}, + "127.0.0.1:9", + "192.0.2.99", + }, + {"RemoteAddr IPv4 strips port", nil, "127.0.0.1:9999", "127.0.0.1"}, + { + "RemoteAddr IPv6 strips brackets", + nil, + "[2001:db8::1]:54321", + "2001:db8::1", + }, + {"RemoteAddr loopback IPv6", nil, "[::1]:9", "::1"}, + {"RemoteAddr no port fallback", nil, "127.0.0.1", "127.0.0.1"}, + { + "XFF IPv6 rightmost", + map[string]string{ + "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", + }, + "127.0.0.1:9", + "2001:db8::dead", + }, + { + "CF trimmed", + map[string]string{"CF-Connecting-IP": " 203.0.113.10 "}, + "127.0.0.1:9", + "203.0.113.10", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require.Equal( + t, + tc.want, + middleware.RealIP(newRequest(tc.remote, tc.headers)), + ) + }) + } +} + +func TestOptionalHeader(t *testing.T) { + require.Nil(t, middleware.OptionalHeader("")) + require.Nil(t, middleware.OptionalHeader(" ")) + v := middleware.OptionalHeader(" hello ") + require.NotNil(t, v) + require.Equal(t, "hello", *v) +} + +func TestExtractFingerprint_DeterministicAndLength(t *testing.T) { + r1 := newRequest("127.0.0.1:9", map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "User-Agent": "Mozilla/5.0", + }) + r2 := newRequest("10.0.0.1:9", map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "User-Agent": "Mozilla/5.0", + }) + + fp1 := middleware.ExtractFingerprint(r1) + fp2 := middleware.ExtractFingerprint(r2) + require.Equal(t, fp1, fp2, "same realIP + UA must yield same fingerprint") + require.Len(t, fp1, 16) + require.Regexp(t, `^[0-9a-f]{16}$`, fp1) +} + +func TestExtractFingerprint_DifferentIPsDifferentFingerprints(t *testing.T) { + a := middleware.ExtractFingerprint(newRequest( + "127.0.0.1:9", + map[string]string{ + "CF-Connecting-IP": "203.0.113.1", + "User-Agent": "X", + }, + )) + b := middleware.ExtractFingerprint(newRequest( + "127.0.0.1:9", + map[string]string{ + "CF-Connecting-IP": "203.0.113.2", + "User-Agent": "X", + }, + )) + require.NotEqual(t, a, b) +} + +func TestKeyByFingerprint_HasPrefix(t *testing.T) { + key := middleware.KeyByFingerprint(newRequest("127.0.0.1:9", nil)) + require.True(t, strings.HasPrefix(key, "ratelimit:fp:")) + require.Len(t, key, len("ratelimit:fp:")+16) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go new file mode 100644 index 00000000..e2a0ae9e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go @@ -0,0 +1,53 @@ +// ©AngelaMos | 2026 +// realip.go + +package middleware + +import ( + "net" + "net/http" + "strings" +) + +const ( + headerCFConnectingIP = "CF-Connecting-IP" + headerXForwardedFor = "X-Forwarded-For" + headerXRealIP = "X-Real-IP" +) + +func RealIP(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" { + return v + } + if v := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { + return v + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func OptionalHeader(v string) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return &v +} + +func lastNonEmptyXFF(header string) string { + if header == "" { + return "" + } + parts := strings.Split(header, ",") + for i := len(parts) - 1; i >= 0; i-- { + if v := strings.TrimSpace(parts[i]); v != "" { + return v + } + } + return "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go new file mode 100644 index 00000000..b900c3b5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// recovery.go + +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" +) + +const ( + recoveryContentType = "application/json" + recoveryBody = `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"internal server error"}}` +) + +func Recovery(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + requestID := "" + if rid, ok := r.Context().Value(RequestIDKey).(string); ok { + requestID = rid + } + logger.ErrorContext(r.Context(), "handler panic", + "panic", rec, + "request_id", requestID, + "path", r.URL.Path, + "method", r.Method, + "stack", string(debug.Stack()), + ) + w.Header().Set("Content-Type", recoveryContentType) + w.WriteHeader(http.StatusInternalServerError) + if _, err := w.Write([]byte(recoveryBody)); err != nil { + logger.WarnContext(r.Context(), "recovery write", + "error", err) + } + }() + next.ServeHTTP(w, r) + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go new file mode 100644 index 00000000..c280e4cf --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go @@ -0,0 +1,84 @@ +// ©AngelaMos | 2026 +// recovery_test.go + +package middleware_test + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" +) + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestRecovery_PanicReturns500(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + h := middleware.Recovery( + logger, + )( + http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic("boom") + }), + ) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/x", nil) + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), `"INTERNAL_ERROR"`) + require.Contains(t, buf.String(), "handler panic") + require.Contains(t, buf.String(), "boom") +} + +func TestRecovery_NoPanicPassesThrough(t *testing.T) { + h := middleware.Recovery( + quietLogger(), + )( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + if _, err := w.Write([]byte("ok")); err != nil { + t.Fatalf("write: %v", err) + } + }), + ) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, http.StatusTeapot, w.Code) + require.Equal(t, "ok", w.Body.String()) +} + +func TestRecovery_LogsRequestID(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + chain := middleware.RequestID( + middleware.Recovery( + logger, + )( + http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic("boom") + }), + ), + ) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/x", nil) + r.Header.Set("X-Request-ID", "test-rid-123") + chain.ServeHTTP(w, r) + + require.Contains(t, buf.String(), "test-rid-123") +}