feat(canary): envfile generator (shuffled bait + embedded canary URL)
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=<baseURL>/c/<t.ID> +
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.
This commit is contained in:
parent
003aa9970c
commit
2a2a3c4d8f
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue