From 9c0ad74973cf7a20214a75f65d23134ed19781d7 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 12 May 2026 02:42:17 -0400 Subject: [PATCH] feat(canary): webbug generator (Generate + Trigger) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generate returns KindURL artifact: baseURL/c/{id} (trailing slash trimmed) - Trigger builds event from CF-Connecting-IP|XFF(rightmost)|XRI|RemoteAddr precedence, captures UA + Referer (nil when absent), returns 43-byte GIF response with no-store cache headers - Nil-token path still returns GIF (spec §8.5 defense-in-depth) - Tests: artifact shape, request metadata capture, IP precedence chain (5 cases), nil-pointer mapping for absent headers, GIF response shape + cache headers, missing-token path realIP() lives here temporarily; promotes to middleware in Phase 9. --- .../token/generators/webbug/generator.go | 87 +++++++ .../token/generators/webbug/generator_test.go | 212 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go new file mode 100644 index 00000000..c833a5e0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// generator.go + +package webbug + +import ( + "context" + "net/http" + "strings" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "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" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" +) + +const ( + headerCFConnectingIP = "CF-Connecting-IP" + headerXForwardedFor = "X-Forwarded-For" + headerXRealIP = "X-Real-IP" + headerReferer = "Referer" + headerCacheControl = "Cache-Control" + headerPragma = "Pragma" + + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + + triggerPathPrefix = "/c/" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypeWebbug } + +func (g *Generator) Generate(_ context.Context, t *token.Token, baseURL string) (generators.Artifact, error) { + return generators.Artifact{ + Kind: generators.KindURL, + URL: strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID, + }, nil +} + +func (g *Generator) Trigger(_ context.Context, t *token.Token, r *http.Request) (*event.Event, *generators.TriggerResponse, error) { + tokenID := "" + if t != nil { + tokenID = t.ID + } + evt := &event.Event{ + TokenID: tokenID, + SourceIP: realIP(r), + UserAgent: optionalHeader(r.UserAgent()), + Referer: optionalHeader(r.Header.Get(headerReferer)), + } + resp := &generators.TriggerResponse{ + StatusCode: http.StatusOK, + ContentType: pixel.ContentType, + Body: pixel.TransparentGIF, + ExtraHeaders: map[string]string{ + headerCacheControl: cacheControlNoStore, + headerPragma: pragmaNoCache, + }, + } + return evt, resp, nil +} + +func optionalHeader(v string) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return &v +} + +func realIP(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" { + return v + } + if v := r.Header.Get(headerXForwardedFor); v != "" { + parts := strings.Split(v, ",") + return strings.TrimSpace(parts[len(parts)-1]) + } + if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { + return v + } + return r.RemoteAddr +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go new file mode 100644 index 00000000..fc77d632 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go @@ -0,0 +1,212 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package webbug_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "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" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/webbug" +) + +const ( + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + gifByteLength = 43 +) + +func newWebbugToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeWebbug, + Memo: "unit test webbug", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func TestGenerator_TypeIsWebbug(t *testing.T) { + g := webbug.New() + require.Equal(t, token.TypeWebbug, g.Type()) +} + +func TestGenerate_ReturnsURLArtifact(t *testing.T) { + cases := []struct { + name string + baseURL string + id string + wantURL string + }{ + { + name: "no trailing slash", + baseURL: "https://canary.example.com", + id: "abc123", + wantURL: "https://canary.example.com/c/abc123", + }, + { + name: "trailing slash trimmed", + baseURL: "https://canary.example.com/", + id: "xyz999", + wantURL: "https://canary.example.com/c/xyz999", + }, + { + name: "subpath base URL preserved", + baseURL: "https://example.com/canary", + id: "p7", + wantURL: "https://example.com/canary/c/p7", + }, + } + + g := webbug.New() + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + art, err := g.Generate(context.Background(), newWebbugToken(tc.id), tc.baseURL) + require.NoError(t, err) + require.Equal(t, generators.KindURL, art.Kind) + require.Equal(t, tc.wantURL, art.URL) + }) + } +} + +func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { + g := webbug.New() + tok := newWebbugToken("token1") + + t.Run("captures token id, source ip, user agent, referer", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/c/token1", nil) + r.Header.Set("CF-Connecting-IP", "203.0.113.50") + r.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64)") + r.Header.Set("Referer", "https://victim.example.com/inbox") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Equal(t, "token1", evt.TokenID) + require.Equal(t, "203.0.113.50", evt.SourceIP) + require.NotNil(t, evt.UserAgent) + require.Equal(t, "Mozilla/5.0 (X11; Linux x86_64)", *evt.UserAgent) + require.NotNil(t, evt.Referer) + require.Equal(t, "https://victim.example.com/inbox", *evt.Referer) + }) + + t.Run("source ip precedence CF > XFF (last) > XRI > RemoteAddr", func(t *testing.T) { + cases := []struct { + name string + headers map[string]string + remote string + wantIP string + }{ + { + name: "CF wins over XFF and XRI", + headers: map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "X-Forwarded-For": "198.51.100.1, 198.51.100.2", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "203.0.113.10", + }, + { + name: "XFF rightmost wins over XRI when no CF", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, 198.51.100.7", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "198.51.100.7", + }, + { + name: "XRI when no CF or XFF", + headers: map[string]string{ + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "192.0.2.99", + }, + { + name: "RemoteAddr when no proxy headers", + headers: nil, + remote: "127.0.0.1:9999", + wantIP: "127.0.0.1:9999", + }, + { + name: "CF value is trimmed of whitespace", + headers: map[string]string{ + "CF-Connecting-IP": " 203.0.113.10 ", + }, + remote: "127.0.0.1:9999", + wantIP: "203.0.113.10", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/c/token1", nil) + for k, v := range tc.headers { + r.Header.Set(k, v) + } + r.RemoteAddr = tc.remote + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.Equal(t, tc.wantIP, evt.SourceIP) + }) + } + }) + + t.Run("missing user agent and referer record as nil pointers", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/c/token1", nil) + r.Header.Del("User-Agent") + r.Header.Del("Referer") + r.Header.Set("CF-Connecting-IP", "203.0.113.5") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.Nil(t, evt.UserAgent, "absent user agent must map to nil, not empty string") + require.Nil(t, evt.Referer, "absent referer must map to nil, not empty string") + }) +} + +func TestTrigger_ResponseIs43ByteGIF(t *testing.T) { + g := webbug.New() + tok := newWebbugToken("abc") + r := httptest.NewRequest(http.MethodGet, "/c/abc", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, pixel.ContentType, resp.ContentType) + require.Len(t, resp.Body, gifByteLength) + require.Equal(t, pixel.TransparentGIF, resp.Body) + require.Equal(t, cacheControlNoStore, resp.ExtraHeaders["Cache-Control"]) + require.Equal(t, pragmaNoCache, resp.ExtraHeaders["Pragma"]) +} + +func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) { + g := webbug.New() + r := httptest.NewRequest(http.MethodGet, "/c/does-not-exist", nil) + r.Header.Set("CF-Connecting-IP", "203.0.113.100") + r.Header.Set("User-Agent", "curl/8.0.0") + + evt, resp, err := g.Trigger(context.Background(), nil, r) + require.NoError(t, err, "nil-token path must not error (spec §8.5 defense-in-depth)") + require.NotNil(t, resp, "nil-token path must still return GIF response") + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, pixel.ContentType, resp.ContentType) + require.Equal(t, pixel.TransparentGIF, resp.Body) + require.NotNil(t, evt, "event value still produced for forensic continuity") + require.Empty(t, evt.TokenID, "empty TokenID signals nil token; persistence layer decides what to do") + require.Equal(t, "203.0.113.100", evt.SourceIP, "source IP still captured for forensics") + require.NotNil(t, evt.UserAgent) + require.Equal(t, "curl/8.0.0", *evt.UserAgent) +}