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