diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go new file mode 100644 index 00000000..df36075c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go @@ -0,0 +1,75 @@ +// ©AngelaMos | 2026 +// fingerprint_handler.go + +package slowredirect + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" +) + +const ( + fingerprintWindow = 30 * time.Second + fingerprintMaxBytes = 64 * 1024 + urlParamTokenID = "id" +) + +type FingerprintAttacher interface { + AttachFingerprint( + ctx context.Context, + tokenID, sourceIP string, + fingerprint json.RawMessage, + window time.Duration, + ) error +} + +type FingerprintHandler struct { + attacher FingerprintAttacher + window time.Duration +} + +func NewFingerprintHandler(a FingerprintAttacher) *FingerprintHandler { + return &FingerprintHandler{ + attacher: a, + window: fingerprintWindow, + } +} + +func (h *FingerprintHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tokenID := chi.URLParam(r, urlParamTokenID) + if tokenID == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, fingerprintMaxBytes)) + if err != nil || len(body) == 0 || !json.Valid(body) { + w.WriteHeader(http.StatusNoContent) + return + } + + if err := h.attacher.AttachFingerprint( + ctx, + tokenID, + realIP(r), + json.RawMessage(body), + h.window, + ); err != nil && !errors.Is(err, event.ErrNotFound) { + slog.WarnContext(ctx, "attach fingerprint failed", + "token_id", tokenID, + "error", err, + ) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go new file mode 100644 index 00000000..33899173 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go @@ -0,0 +1,180 @@ +// ©AngelaMos | 2026 +// fingerprint_handler_test.go + +//go:build integration + +package slowredirect_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect" +) + +const ( + fingerprintRoute = "/c/{id}/fingerprint" + clientIP = "203.0.113.45" +) + +func newSlowredirectRepos( + t *testing.T, +) (*token.Repository, *event.Repository) { + t.Helper() + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + return token.NewRepository(db), event.NewRepository(db) +} + +func seedSlowredirectToken( + t *testing.T, + repo *token.Repository, + id, destination string, +) *token.Token { + t.Helper() + metaJSON, _ := json.Marshal(map[string]string{ + "destination_url": destination, + }) + tok := &token.Token{ + ID: id, + ManageID: uuid.New().String(), + Type: token.TypeSlowRedirect, + Memo: "integration-fp", + AlertChannel: token.ChannelWebhook, + WebhookURL: testutil.Ptr("https://example.com/hook"), + CreatedIP: clientIP, + CreatedFP: "abcdef0123456789", + Metadata: json.RawMessage(metaJSON), + Enabled: true, + } + require.NoError(t, repo.Insert(context.Background(), tok)) + return tok +} + +func mountFingerprintRouter(h http.Handler) http.Handler { + r := chi.NewRouter() + r.Post(fingerprintRoute, h.ServeHTTP) + return r +} + +func TestFingerprintHandler_AttachesToRecentEvent(t *testing.T) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + ctx := context.Background() + + tok := seedSlowredirectToken(t, tokRepo, "fpattach0001", "https://news.example.com") + evt := &event.Event{ + TokenID: tok.ID, + SourceIP: clientIP, + Extra: json.RawMessage(`{"initial":"value"}`), + } + require.NoError(t, evtRepo.Insert(ctx, evt)) + + handler := slowredirect.NewFingerprintHandler(evtRepo) + router := mountFingerprintRouter(handler) + + fpBody := []byte(`{"screen":{"w":1920,"h":1080},"timezone":"America/Los_Angeles"}`) + req := httptest.NewRequest( + http.MethodPost, + "/c/"+tok.ID+"/fingerprint", + bytes.NewReader(fpBody), + ) + req.Header.Set("CF-Connecting-IP", clientIP) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code) + + got, err := evtRepo.GetByID(ctx, evt.ID) + require.NoError(t, err) + + var merged map[string]any + require.NoError(t, json.Unmarshal(got.Extra, &merged)) + require.Equal(t, "value", merged["initial"]) + require.Equal(t, "America/Los_Angeles", merged["timezone"]) + screen, ok := merged["screen"].(map[string]any) + require.True(t, ok, "screen sub-object must round-trip as nested map") + require.EqualValues(t, 1920, screen["w"]) + require.EqualValues(t, 1080, screen["h"]) +} + +func TestFingerprintHandler_NoMatchingEvent_Returns204(t *testing.T) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + + tok := seedSlowredirectToken(t, tokRepo, "fpnomatch001", "https://news.example.com") + + handler := slowredirect.NewFingerprintHandler(evtRepo) + router := mountFingerprintRouter(handler) + + fpBody := []byte(`{"screen":{"w":800,"h":600}}`) + req := httptest.NewRequest( + http.MethodPost, + "/c/"+tok.ID+"/fingerprint", + bytes.NewReader(fpBody), + ) + req.Header.Set("CF-Connecting-IP", clientIP) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + require.Equal( + t, + http.StatusNoContent, + rr.Code, + "no matching event must still return 204 — fingerprint is enrichment, not the trigger", + ) +} + +func TestFingerprintHandler_InvalidJSON_Returns204AndDoesNotTouchEvent(t *testing.T) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + ctx := context.Background() + + tok := seedSlowredirectToken(t, tokRepo, "fpinvjson001", "https://news.example.com") + evt := &event.Event{ + TokenID: tok.ID, + SourceIP: clientIP, + Extra: json.RawMessage(`{"initial":"value"}`), + } + require.NoError(t, evtRepo.Insert(ctx, evt)) + + handler := slowredirect.NewFingerprintHandler(evtRepo) + router := mountFingerprintRouter(handler) + + req := httptest.NewRequest( + http.MethodPost, + "/c/"+tok.ID+"/fingerprint", + bytes.NewReader([]byte("not-json-at-all")), + ) + req.Header.Set("CF-Connecting-IP", clientIP) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code) + + got, err := evtRepo.GetByID(ctx, evt.ID) + require.NoError(t, err) + + var stored map[string]any + require.NoError(t, json.Unmarshal(got.Extra, &stored)) + require.Equal( + t, + "value", + stored["initial"], + "invalid JSON must not corrupt the stored Extra JSONB", + ) + _, hasInvalid := stored["not-json"] + require.False(t, hasInvalid) +}