From 6dd520f966d779be6f8ab656f5a299c8a1040af2 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 12 May 2026 06:34:04 -0400 Subject: [PATCH] feat(canary): slowredirect fingerprint endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /c/{id}/fingerprint handler that decodes a JSON fingerprint body and merges it into the most recent event's Extra JSONB for the same (token_id, source_ip) tuple within a 30s window via the existing event.Repository.AttachFingerprint method. Decoupled from the concrete repository through a small FingerprintAttacher interface so future event.Service can swap in without touching this package. Per spec §9.2 the fingerprint POST is enrichment-only: a missing match (event.ErrNotFound), an invalid JSON body, an empty body, and a body exceeding 64 KiB all silently return 204. Only unexpected repository errors are logged via slog. The token id is read from chi.URLParam("id") so the route mounts naturally under the existing trigger router in Phase 9. Three integration tests against testcontainers cover the happy-path JSONB merge, the no-matching-event path (silent 204), and the invalid-JSON path (204 plus stored Extra left untouched). --- .../slowredirect/fingerprint_handler.go | 75 ++++++++ .../slowredirect/fingerprint_handler_test.go | 180 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go 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) +}