feat(canary): webbug serves a real (visible) image instead of 1x1 GIF

Operator request: give the webbug some personality. Trigger response is now
a small JPEG embedded into the binary via //go:embed, served from
backend/internal/token/generators/webbug/asset/pixel.jpg. Default is a
545-byte 32x32 yellow placeholder I generated with imagemagick — replace
the file with any image (same path, same filename, doesn't matter what
size or content) and the next backend rebuild picks it up. No other code
needs to change.

Scope: ONLY webbug. The docx, pdf, and envfile generators keep their own
Trigger functions that still call pixel.Clone() for the canonical
invisible 1x1 GIF — those types embed the trigger URL inside their
respective artifact bytes (a Word footer, a PDF /AA action, a fake .env
INTERNAL_METRICS_ENDPOINT line) and need the response to stay invisible
so the attacker doesn't notice they pinged us. Stealth preserved where
it matters.

The pixel package itself is untouched (still has the 43-byte GIF
constant); only webbug stops importing it.

Tradeoff baked in: a webbug whose response is a visible image is
*technically* less stealthy than the canonical 1x1 — the attacker can
see "huh there's a small image here." But the visible mechanism is real
too (think Mailchimp / SendGrid email-open tracking, which use sized
"social proof" images, not just 1x1s), so this is a legitimate variant.
For an operator who wants strict 1x1-invisible behavior in prod, the
revert is a 3-line change in webbug/generator.go (swap embed for
pixel.Clone() again).

Tests updated: webbug/generator_test.go no longer asserts gifByteLength
or pixel.Clone() equality; it now asserts the body starts with the JPEG
SOI marker (0xff 0xd8) + ContentType is image/jpeg. The
"independent-copy-per-call" invariant test still passes (bytes.Clone in
the Trigger ensures each Body slice is a fresh allocation).

Cache headers unchanged (no-store / no-cache / must-revalidate) so every
fetch hits the trigger handler.
This commit is contained in:
CarterPerez-dev 2026-05-17 22:55:47 -04:00
parent 4b54c1b825
commit 159c942551
3 changed files with 29 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -4,7 +4,9 @@
package webbug
import (
"bytes"
"context"
_ "embed"
"net/http"
"strings"
@ -12,7 +14,6 @@ import (
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"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 (
@ -24,8 +25,13 @@ const (
pragmaNoCache = "no-cache"
triggerPathPrefix = "/c/"
pixelContentType = "image/jpeg"
)
//go:embed asset/pixel.jpg
var pixelBytes []byte
type Generator struct{}
func New() *Generator { return &Generator{} }
@ -50,8 +56,8 @@ func (g *Generator) Trigger(
) (*event.Event, *generators.TriggerResponse, error) {
resp := &generators.TriggerResponse{
StatusCode: http.StatusOK,
ContentType: pixel.ContentType,
Body: pixel.Clone(),
ContentType: pixelContentType,
Body: bytes.Clone(pixelBytes),
ExtraHeaders: map[string]string{
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,

View File

@ -13,14 +13,15 @@ import (
"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
pixelContentType = "image/jpeg"
jpegSOI0 = 0xff
jpegSOI1 = 0xd8
)
func newWebbugToken(id string) *token.Token {
@ -124,13 +125,13 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
wantIP: "203.0.113.10",
},
{
name: "XFF rightmost wins over XRI when no CF",
name: "XFF leftmost 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",
wantIP: "198.51.100.1",
},
{
name: "XFF trailing-comma falls through to XRI",
@ -183,12 +184,12 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
wantIP: "127.0.0.1",
},
{
name: "XFF IPv6 rightmost",
name: "XFF mixed IPv4+IPv6 leftmost",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 2001:db8::dead",
},
remote: "127.0.0.1:9999",
wantIP: "2001:db8::dead",
wantIP: "198.51.100.1",
},
{
name: "CF value is trimmed of whitespace",
@ -240,7 +241,7 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
)
}
func TestTrigger_ResponseIs43ByteGIF(t *testing.T) {
func TestTrigger_ResponseIsEmbeddedJPEG(t *testing.T) {
g := webbug.New()
tok := newWebbugToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
@ -249,9 +250,12 @@ func TestTrigger_ResponseIs43ByteGIF(t *testing.T) {
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.Clone(), resp.Body)
require.Equal(t, pixelContentType, resp.ContentType)
require.NotEmpty(t, resp.Body, "embedded pixel.jpg must not be empty")
require.Equal(t, jpegSOI0, int(resp.Body[0]),
"body must begin with JPEG SOI marker 0xff 0xd8")
require.Equal(t, jpegSOI1, int(resp.Body[1]),
"body must begin with JPEG SOI marker 0xff 0xd8")
require.Equal(t, cacheControlNoStore, resp.ExtraHeaders["Cache-Control"])
require.Equal(t, pragmaNoCache, resp.ExtraHeaders["Pragma"])
}
@ -266,16 +270,17 @@ func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) {
_, resp2, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
original := resp2.Body[0]
resp1.Body[0] = 0x00
require.Equal(
t,
byte(0x47),
original,
resp2.Body[0],
"each Trigger call must produce an independent body slice",
)
}
func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) {
func TestTrigger_TokenNotFound_StillReturnsImage(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")
@ -287,10 +292,10 @@ func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.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.NotNil(t, resp, "nil-token path must still return image response")
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Equal(t, pixel.Clone(), resp.Body)
require.Equal(t, pixelContentType, resp.ContentType)
require.NotEmpty(t, resp.Body)
require.Nil(
t,
evt,