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:
parent
0b6e586eb1
commit
9c0ad74973
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue