From 2a2a3c4d8f4e839b9f8911a16381e4bc95441aca Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 14:36:06 -0400 Subject: [PATCH] feat(canary): envfile generator (shuffled bait + embedded canary URL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generator builds the artifact per spec §9.6: - extractIncludeKeys(t.Metadata) reads include_keys from JSON metadata, defaults to ["aws", "db"] on missing/malformed metadata (defensive fallback covers empty raw, invalid JSON, wrong type, null value, empty array, missing field, all-whitespace entries) - buildSections iterates recipe keys, appending the canary section last: INTERNAL_METRICS_ENDPOINT=/c/ + INTERNAL_METRICS_TOKEN=tok_live_<32-char alnum> - shuffleSections uses crypto/rand-driven Fisher-Yates so the canary section can land at any position. Spec §9.6 line 1301 uses math/rand.Shuffle; we use crypto/rand to stay consistent with the Phase 0 supplement choice and the recipes package - renderSections writes the fixed "# Production environment / NODE_ENV / PORT" header followed by each section's comment + key=value lines - Artifact: KindText, ContentType "text/plain; charset=utf-8", Filename default ".env" Trigger is a byte-identical content-copy of webbug/docx/pdf: 200 + pixel.Clone() GIF + cache headers + realIP triplet + nil-token-returns- GIF defense-in-depth. Tests (~30 cases): - Generator: Type, KindText, ContentType, Filename defaulting (4), header presence, canary URL embedded, canary token format (tok_live_+32 alnum regex), trailing-slash trim, default include_keys vs metadata-driven include_keys, unknown keys skipped, 6 malformed-metadata fallback subcases, empty-string filtering in keys list, shuffle variability (30 invocations → canary at multiple positions), distinct outputs (random bait + random shuffle), token id appears exactly once in output - Trigger: GIF response shape, source-IP precedence (11 subcases matching docx/pdf/kubeconfig), missing UA/Referer → nil pointers, response body independence (mutate one, other unchanged), nil-token still returns GIF + nil event Registry wire-up next. --- .../token/generators/envfile/generator.go | 244 +++++++ .../generators/envfile/generator_test.go | 594 ++++++++++++++++++ 2 files changed, 838 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go new file mode 100644 index 00000000..86a268bb --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go @@ -0,0 +1,244 @@ +// ©AngelaMos | 2026 +// generator.go + +package envfile + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "net" + "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/envfile/recipes" + "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/" + metadataIncludeKey = "include_keys" + + contentType = "text/plain; charset=utf-8" + defaultFilename = ".env" + + canaryComment = "Internal monitoring (Datadog-style integration)" + canaryEndpointKey = "INTERNAL_METRICS_ENDPOINT" + canaryTokenKey = "INTERNAL_METRICS_TOKEN" + canaryTokenPrefix = "tok_live_" + canaryTokenLength = 32 + + envfileHeader = "# Production environment\n" + + "NODE_ENV=production\n" + + "PORT=8080\n\n" +) + +var defaultIncludeKeys = []string{"aws", "db"} + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypeEnvfile } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + baseURL string, +) (generators.Artifact, error) { + keys := extractIncludeKeys(t.Metadata) + triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID + + sections := buildSections(keys, triggerURL) + if err := shuffleSections(sections); err != nil { + return generators.Artifact{}, fmt.Errorf( + "envfile: shuffle sections: %w", + err, + ) + } + + body := renderSections(sections) + + return generators.Artifact{ + Kind: generators.KindText, + Filename: resolveFilename(t.Filename), + Content: body, + ContentType: contentType, + }, nil +} + +func (g *Generator) Trigger( + _ context.Context, + t *token.Token, + r *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + resp := &generators.TriggerResponse{ + StatusCode: http.StatusOK, + ContentType: pixel.ContentType, + Body: pixel.Clone(), + ExtraHeaders: map[string]string{ + headerCacheControl: cacheControlNoStore, + headerPragma: pragmaNoCache, + }, + } + + if t == nil { + return nil, resp, nil + } + + evt := &event.Event{ + TokenID: t.ID, + SourceIP: realIP(r), + UserAgent: optionalHeader(r.UserAgent()), + Referer: optionalHeader(r.Header.Get(headerReferer)), + } + return evt, resp, nil +} + +func buildSections(keys []string, triggerURL string) [][]recipes.EnvLine { + sections := make([][]recipes.EnvLine, 0, len(keys)+1) + for _, k := range keys { + if r, ok := recipes.Get(k); ok { + sections = append(sections, r.Generate()) + } + } + sections = append(sections, []recipes.EnvLine{ + {Comment: canaryComment}, + {Key: canaryEndpointKey, Value: triggerURL}, + { + Key: canaryTokenKey, + Value: canaryTokenPrefix + + recipes.RandomAlnumMixed(canaryTokenLength), + }, + }) + return sections +} + +func shuffleSections(sections [][]recipes.EnvLine) error { + for i := len(sections) - 1; i > 0; i-- { + jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + return fmt.Errorf("crypto/rand.Int: %w", err) + } + j := int(jBig.Int64()) + sections[i], sections[j] = sections[j], sections[i] + } + return nil +} + +func renderSections(sections [][]recipes.EnvLine) []byte { + var buf bytes.Buffer + buf.WriteString(envfileHeader) + for _, s := range sections { + for _, l := range s { + if l.Comment != "" { + fmt.Fprintf(&buf, "# %s\n", l.Comment) + } + if l.Key != "" { + fmt.Fprintf(&buf, "%s=%s\n", l.Key, l.Value) + } + } + buf.WriteString("\n") + } + return buf.Bytes() +} + +func extractIncludeKeys(metadata json.RawMessage) []string { + if len(metadata) == 0 { + return cloneStrings(defaultIncludeKeys) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(metadata, &m); err != nil { + return cloneStrings(defaultIncludeKeys) + } + raw, ok := m[metadataIncludeKey] + if !ok { + return cloneStrings(defaultIncludeKeys) + } + var keys []string + if err := json.Unmarshal(raw, &keys); err != nil || len(keys) == 0 { + return cloneStrings(defaultIncludeKeys) + } + out := make([]string, 0, len(keys)) + for _, k := range keys { + k = strings.TrimSpace(k) + if k == "" { + continue + } + out = append(out, k) + } + if len(out) == 0 { + return cloneStrings(defaultIncludeKeys) + } + return out +} + +func cloneStrings(in []string) []string { + out := make([]string, len(in)) + copy(out, in) + return out +} + +func resolveFilename(name *string) string { + if name == nil { + return defaultFilename + } + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + return defaultFilename + } + return trimmed +} + +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 := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { + return v + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func lastNonEmptyXFF(header string) string { + if header == "" { + return "" + } + parts := strings.Split(header, ",") + for i := len(parts) - 1; i >= 0; i-- { + if v := strings.TrimSpace(parts[i]); v != "" { + return v + } + } + return "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go new file mode 100644 index 00000000..32428bab --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go @@ -0,0 +1,594 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package envfile_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "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/envfile" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" +) + +const ( + testBaseURL = "https://canary.example.com" + envfileContentTypeMIME = "text/plain; charset=utf-8" + defaultFilenameValue = ".env" + cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCacheValue = "no-cache" + gifByteLength = 43 +) + +func newEnvfileToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeEnvfile, + Memo: "unit test envfile", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func newEnvfileTokenWithFilename(id, filename string) *token.Token { + tok := newEnvfileToken(id) + tok.Filename = &filename + return tok +} + +func newEnvfileTokenWithIncludeKeys(keys []string) *token.Token { + tok := newEnvfileToken("abc") + raw, err := json.Marshal(map[string]any{"include_keys": keys}) + if err != nil { + panic(err) + } + tok.Metadata = raw + return tok +} + +func newEnvfileTokenWithRawMetadata(metadata string) *token.Token { + tok := newEnvfileToken("abc") + tok.Metadata = json.RawMessage(metadata) + return tok +} + +func envLines(content []byte) []string { + return strings.Split(string(content), "\n") +} + +func TestGenerator_TypeIsEnvfile(t *testing.T) { + g := envfile.New() + require.Equal(t, token.TypeEnvfile, g.Type()) +} + +func TestGenerate_ArtifactKindIsText(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, generators.KindText, art.Kind) +} + +func TestGenerate_ContentTypeIsTextPlain(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, envfileContentTypeMIME, art.ContentType) +} + +func TestGenerate_Filename(t *testing.T) { + g := envfile.New() + + t.Run("nil Filename defaults to .env", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilenameValue, art.Filename) + }) + + t.Run("empty Filename pointer defaults to .env", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newEnvfileTokenWithFilename("abc", ""), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilenameValue, art.Filename) + }) + + t.Run("whitespace Filename defaults to .env", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newEnvfileTokenWithFilename("abc", " "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, defaultFilenameValue, art.Filename) + }) + + t.Run("set Filename is preserved (trimmed)", func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newEnvfileTokenWithFilename("abc", " .env.production "), + testBaseURL, + ) + require.NoError(t, err) + require.Equal(t, ".env.production", art.Filename) + }) +} + +func TestGenerate_ContainsHeader(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + body := string(art.Content) + require.Contains(t, body, "# Production environment") + require.Contains(t, body, "NODE_ENV=production") + require.Contains(t, body, "PORT=8080") +} + +func TestGenerate_EmbedsCanaryURL(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("token42"), + testBaseURL, + ) + require.NoError(t, err) + body := string(art.Content) + require.Contains( + t, + body, + "INTERNAL_METRICS_ENDPOINT=https://canary.example.com/c/token42", + "envfile must contain the canary URL as the INTERNAL_METRICS_ENDPOINT line", + ) +} + +func TestGenerate_EmbedsCanaryToken(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + body := string(art.Content) + require.Regexp( + t, + `INTERNAL_METRICS_TOKEN=tok_live_[A-Za-z0-9]{32}`, + body, + "INTERNAL_METRICS_TOKEN must follow the tok_live_ + 32-alnum format", + ) +} + +func TestGenerate_TriggerURLTrailingSlashTrim(t *testing.T) { + g := envfile.New() + + artA, err := g.Generate( + context.Background(), + newEnvfileToken("tk1"), + "https://canary.example.com", + ) + require.NoError(t, err) + artB, err := g.Generate( + context.Background(), + newEnvfileToken("tk1"), + "https://canary.example.com/", + ) + require.NoError(t, err) + + require.Contains( + t, + string(artA.Content), + "INTERNAL_METRICS_ENDPOINT=https://canary.example.com/c/tk1", + ) + require.Contains( + t, + string(artB.Content), + "INTERNAL_METRICS_ENDPOINT=https://canary.example.com/c/tk1", + ) +} + +func TestGenerate_DefaultIncludeKeysAreAWSAndDB(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + body := string(art.Content) + require.Contains( + t, + body, + "AWS_ACCESS_KEY_ID=", + "default include_keys should include aws", + ) + require.Contains( + t, + body, + "DATABASE_URL=postgres://", + "default include_keys should include db", + ) + require.NotContains( + t, + body, + "STRIPE_SECRET_KEY=", + "default include_keys must NOT include stripe", + ) + require.NotContains( + t, + body, + "GITHUB_TOKEN=", + "default include_keys must NOT include github", + ) +} + +func TestGenerate_IncludeKeysFromMetadata(t *testing.T) { + g := envfile.New() + tok := newEnvfileTokenWithIncludeKeys([]string{"stripe", "github"}) + + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + body := string(art.Content) + require.Contains(t, body, "STRIPE_SECRET_KEY=") + require.Contains(t, body, "GITHUB_TOKEN=") + require.NotContains(t, body, "AWS_ACCESS_KEY_ID=") + require.NotContains(t, body, "DATABASE_URL=postgres://") +} + +func TestGenerate_UnknownKeyInMetadataSkipped(t *testing.T) { + g := envfile.New() + tok := newEnvfileTokenWithIncludeKeys( + []string{"aws", "nonexistent", "stripe"}, + ) + + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + body := string(art.Content) + require.Contains(t, body, "AWS_ACCESS_KEY_ID=") + require.Contains(t, body, "STRIPE_SECRET_KEY=") + require.NotContains(t, body, "nonexistent") +} + +func TestGenerate_MalformedMetadataFallsBackToDefaults(t *testing.T) { + g := envfile.New() + + cases := []struct { + name string + metadata string + }{ + {"empty raw", ""}, + {"invalid json", "{not valid"}, + {"include_keys is not array", `{"include_keys": "aws"}`}, + {"include_keys is null", `{"include_keys": null}`}, + {"include_keys is empty array", `{"include_keys": []}`}, + {"missing include_keys field", `{"other_field": "value"}`}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tok := newEnvfileTokenWithRawMetadata(tc.metadata) + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + body := string(art.Content) + require.Contains( + t, + body, + "AWS_ACCESS_KEY_ID=", + "malformed metadata must fall back to aws default", + ) + require.Contains( + t, + body, + "DATABASE_URL=postgres://", + "malformed metadata must fall back to db default", + ) + }) + } +} + +func TestGenerate_IncludeKeysFiltersEmptyStrings(t *testing.T) { + g := envfile.New() + tok := newEnvfileTokenWithIncludeKeys( + []string{"", " ", "aws"}, + ) + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + require.Contains(t, string(art.Content), "AWS_ACCESS_KEY_ID=") +} + +func TestGenerate_IncludeKeysAllWhitespaceFallsBackToDefaults(t *testing.T) { + g := envfile.New() + tok := newEnvfileTokenWithIncludeKeys([]string{"", " "}) + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + body := string(art.Content) + require.Contains(t, body, "AWS_ACCESS_KEY_ID=") + require.Contains(t, body, "DATABASE_URL=postgres://") +} + +func TestGenerate_SectionShuffleProducesVariableOrder(t *testing.T) { + g := envfile.New() + tok := newEnvfileTokenWithIncludeKeys( + []string{"aws", "stripe", "github", "db"}, + ) + + canaryPositions := make(map[int]struct{}) + for range 30 { + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + lines := envLines(art.Content) + for i, line := range lines { + if strings.HasPrefix(line, "INTERNAL_METRICS_ENDPOINT=") { + canaryPositions[i] = struct{}{} + break + } + } + } + require.Greater( + t, + len(canaryPositions), + 1, + "shuffle must place the canary section at varying positions across 30 invocations", + ) +} + +func TestGenerate_DistinctInvocationsProduceDistinctOutputs(t *testing.T) { + g := envfile.New() + seen := make(map[string]struct{}) + for range 10 { + art, err := g.Generate( + context.Background(), + newEnvfileToken("abc"), + testBaseURL, + ) + require.NoError(t, err) + seen[string(art.Content)] = struct{}{} + } + require.Greater( + t, + len(seen), + 8, + "random bait + random canary token + shuffle should produce near-unique outputs", + ) +} + +func TestGenerate_TokenIDDoesNotLeakOutsideCanaryLine(t *testing.T) { + g := envfile.New() + art, err := g.Generate( + context.Background(), + newEnvfileToken("uniqueprobe"), + testBaseURL, + ) + require.NoError(t, err) + body := string(art.Content) + require.Equal( + t, + 1, + strings.Count(body, "uniqueprobe"), + "token id must appear exactly once (in the canary URL), never in bait lines", + ) +} + +func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) { + g := envfile.New() + tok := newEnvfileToken("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.Clone(), resp.Body) + require.Equal( + t, + cacheControlNoStoreValue, + resp.ExtraHeaders["Cache-Control"], + ) + require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"]) +} + +func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { + g := envfile.New() + tok := newEnvfileToken("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", "curl/8.0.0") + r.Header.Set("Referer", "https://victim.example.com/") + + 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, "curl/8.0.0", *evt.UserAgent) + require.NotNil(t, evt.Referer) + require.Equal(t, "https://victim.example.com/", *evt.Referer) + }, + ) + + t.Run("source ip precedence", 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: "XFF trailing-comma falls through to last non-empty", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, ", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "198.51.100.1", + }, + { + name: "XFF entirely empty entries fall through to XRI", + headers: map[string]string{ + "X-Forwarded-For": ", ,", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "192.0.2.99", + }, + { + 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 IPv4 strips port", + headers: nil, + remote: "127.0.0.1:9999", + wantIP: "127.0.0.1", + }, + { + name: "RemoteAddr IPv6 strips brackets and port", + headers: nil, + remote: "[2001:db8::1]:54321", + wantIP: "2001:db8::1", + }, + { + name: "RemoteAddr loopback IPv6 strips brackets and port", + headers: nil, + remote: "[::1]:9999", + wantIP: "::1", + }, + { + name: "RemoteAddr without port falls back to raw value", + headers: nil, + remote: "127.0.0.1", + wantIP: "127.0.0.1", + }, + { + name: "XFF IPv6 rightmost", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", + }, + remote: "127.0.0.1:9999", + wantIP: "2001:db8::dead", + }, + { + 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.NotNil(t, evt) + 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.NotNil(t, evt) + require.Nil(t, evt.UserAgent) + require.Nil(t, evt.Referer) + }, + ) +} + +func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) { + g := envfile.New() + tok := newEnvfileToken("abc") + r := httptest.NewRequest(http.MethodGet, "/c/abc", nil) + + _, resp1, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + _, resp2, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + + resp1.Body[0] = 0x00 + require.Equal(t, byte(0x47), resp2.Body[0]) +} + +func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) { + g := envfile.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) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, pixel.ContentType, resp.ContentType) + require.Equal(t, pixel.Clone(), resp.Body) + require.Nil(t, evt) +}