feat(canary): webbug generator (Generate + Trigger)

- 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.
This commit is contained in:
CarterPerez-dev 2026-05-12 02:42:17 -04:00
parent 0b6e586eb1
commit 9c0ad74973
2 changed files with 299 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}