feat(canary): slowredirect fingerprint endpoint
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).
This commit is contained in:
parent
71823f5de2
commit
6dd520f966
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue