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:
parent
4b54c1b825
commit
159c942551
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue