feat(canary): pdf generator (byte-replace fixed-width URI action)
Generator runs at Token-create time. Substitutes the 76-byte placeholder
in the embedded template.pdf with the canary trigger URL padded to the
same byte length. Length preservation is critical: the PDF's xref table
indexes objects by byte offset, so anything that shifts bytes after the
catalog corrupts the file.
Algorithm (spec §9.4 lines 1145-1165):
triggerURL := strings.TrimRight(baseURL, "/") + "/c/" + t.ID
if len(triggerURL) > 76 → ErrTriggerURLTooLong
padded := triggerURL + strings.Repeat("_", 76 - len(triggerURL))
out := bytes.Replace(pdfTemplate, placeholder, padded, 1)
Defensive checks after substitution:
- len(out) == len(pdfTemplate) (xref offsets stay valid)
- bytes.Contains(out, triggerURL) (substitution actually happened)
Trigger is a content-copy of webbug's response (sanctioned per Phase
2/3/4 anti-relitigation set): 200 + 43-byte pixel.Clone() + Cache-Control
+ Pragma headers. Nil-token path returns the same artifact shape with no
event (spec §8.5 defense-in-depth). realIP / lastNonEmptyXFF /
optionalHeader are duplicated byte-identically with docx and slowredirect
— Phase 9 collapses this into a middleware helper.
Test suite (~32 cases, mirrors the docx Phase 4 shape plus PDF-specific
structural guards):
Template invariants (5):
- placeholder root byte-locatable exactly once (literal, not encoded)
- full 76-byte placeholder byte-locatable exactly once
- starts with %PDF- header
- ends with %%EOF marker
- pdfcpu.api.Validate passes on the raw template
Generator behavior (10):
- Type() is TypePDF, Artifact.Kind is KindFile
- ContentType is application/pdf
- Filename defaulting (nil / empty / whitespace / set)
- trigger URL precedence (trailing-slash trim, subpath preserve,
distinct ids → distinct outputs)
- output length == template length (xref preservation)
- output contains trigger URL
- placeholder root removed from output
- URL > 76 chars returns ErrTriggerURLTooLong (errors.Is)
- boundary URL at exactly 76 chars succeeds (zero padding)
- api.Validate still passes on substituted output
- substitution preserves byte offset (URL occupies the same position
the placeholder did)
Trigger behavior (~16 incl subtests):
- response shape identical to webbug (200 + pixel.Clone + headers)
- records event with token id / source IP / UA / Referer
- source-IP precedence (CF > XFF rightmost > XRI > RemoteAddr),
11 subcases mirroring docx + webbug for full symmetry
- missing UA/Referer → nil pointers
- per-call body is an independent slice (mutate one, other unchanged)
- nil-token path returns GIF + nil event (FK-safe)
go test -race -timeout=60s ./internal/token/generators/pdf/... passes.
This commit is contained in:
parent
c9c9333294
commit
13cd6cd1a3
|
|
@ -0,0 +1,182 @@
|
|||
// ©AngelaMos | 2026
|
||||
// generator.go
|
||||
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/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/"
|
||||
|
||||
placeholderRoot = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH"
|
||||
|
||||
PlaceholderLength = 76
|
||||
|
||||
padChar = "_"
|
||||
|
||||
contentType = "application/pdf"
|
||||
defaultFilename = "Document.pdf"
|
||||
)
|
||||
|
||||
var ErrTriggerURLTooLong = errors.New(
|
||||
"pdf: trigger URL exceeds placeholder length",
|
||||
)
|
||||
|
||||
//go:embed template/template.pdf
|
||||
var pdfTemplate []byte
|
||||
|
||||
var placeholder = placeholderRoot +
|
||||
strings.Repeat(padChar, PlaceholderLength-len(placeholderRoot))
|
||||
|
||||
type Generator struct{}
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
|
||||
func (g *Generator) Type() token.Type { return token.TypePDF }
|
||||
|
||||
func (g *Generator) Generate(
|
||||
_ context.Context,
|
||||
t *token.Token,
|
||||
baseURL string,
|
||||
) (generators.Artifact, error) {
|
||||
triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID
|
||||
|
||||
if len(triggerURL) > PlaceholderLength {
|
||||
return generators.Artifact{}, fmt.Errorf(
|
||||
"%w: url=%d max=%d",
|
||||
ErrTriggerURLTooLong,
|
||||
len(triggerURL),
|
||||
PlaceholderLength,
|
||||
)
|
||||
}
|
||||
|
||||
padded := triggerURL +
|
||||
strings.Repeat(padChar, PlaceholderLength-len(triggerURL))
|
||||
|
||||
out := bytes.Replace(
|
||||
pdfTemplate,
|
||||
[]byte(placeholder),
|
||||
[]byte(padded),
|
||||
1,
|
||||
)
|
||||
|
||||
if len(out) != len(pdfTemplate) {
|
||||
return generators.Artifact{}, fmt.Errorf(
|
||||
"pdf: substitution changed byte length (was %d, now %d)",
|
||||
len(pdfTemplate),
|
||||
len(out),
|
||||
)
|
||||
}
|
||||
if !bytes.Contains(out, []byte(triggerURL)) {
|
||||
return generators.Artifact{}, fmt.Errorf(
|
||||
"pdf: substitution did not embed trigger URL",
|
||||
)
|
||||
}
|
||||
|
||||
return generators.Artifact{
|
||||
Kind: generators.KindFile,
|
||||
Filename: resolveFilename(t.Filename),
|
||||
Content: out,
|
||||
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 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,628 @@
|
|||
// ©AngelaMos | 2026
|
||||
// generator_test.go
|
||||
|
||||
package pdf_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"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/pdf"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
|
||||
)
|
||||
|
||||
//go:embed template/template.pdf
|
||||
var rawTemplate []byte
|
||||
|
||||
const (
|
||||
testBaseURL = "https://canary.example.com"
|
||||
placeholderRootLiteral = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH"
|
||||
pdfContentTypeMIME = "application/pdf"
|
||||
defaultFilename = "Document.pdf"
|
||||
cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
pragmaNoCacheValue = "no-cache"
|
||||
gifByteLength = 43
|
||||
pdfHeaderPrefix = "%PDF-"
|
||||
pdfTrailer = "%%EOF\n"
|
||||
)
|
||||
|
||||
func fullPlaceholder() string {
|
||||
return placeholderRootLiteral +
|
||||
strings.Repeat("_", pdf.PlaceholderLength-len(placeholderRootLiteral))
|
||||
}
|
||||
|
||||
func newPDFToken(id string) *token.Token {
|
||||
return &token.Token{
|
||||
ID: id,
|
||||
ManageID: "manage-" + id,
|
||||
Type: token.TypePDF,
|
||||
Memo: "unit test pdf",
|
||||
AlertChannel: token.ChannelWebhook,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newPDFTokenWithFilename(id, filename string) *token.Token {
|
||||
tok := newPDFToken(id)
|
||||
tok.Filename = &filename
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestTemplate_PlaceholderRootIsByteLocatableExactlyOnce(t *testing.T) {
|
||||
require.Equal(
|
||||
t,
|
||||
1,
|
||||
bytes.Count(rawTemplate, []byte(placeholderRootLiteral)),
|
||||
"template must contain the placeholder root exactly once as literal bytes",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTemplate_FullPlaceholderIsByteLocatableExactlyOnce(t *testing.T) {
|
||||
full := fullPlaceholder()
|
||||
require.Len(
|
||||
t,
|
||||
full,
|
||||
pdf.PlaceholderLength,
|
||||
"derived placeholder must be %d bytes",
|
||||
pdf.PlaceholderLength,
|
||||
)
|
||||
require.Equal(
|
||||
t,
|
||||
1,
|
||||
bytes.Count(rawTemplate, []byte(full)),
|
||||
"template must contain the full %d-byte placeholder exactly once as literal bytes (not encoded inside a Flate stream)",
|
||||
pdf.PlaceholderLength,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTemplate_StartsWithPDFHeader(t *testing.T) {
|
||||
require.True(
|
||||
t,
|
||||
bytes.HasPrefix(rawTemplate, []byte(pdfHeaderPrefix)),
|
||||
"template must start with %q",
|
||||
pdfHeaderPrefix,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTemplate_EndsWithEOFMarker(t *testing.T) {
|
||||
require.True(
|
||||
t,
|
||||
bytes.HasSuffix(rawTemplate, []byte(pdfTrailer)),
|
||||
"template must end with %q",
|
||||
pdfTrailer,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTemplate_PdfcpuValidates(t *testing.T) {
|
||||
require.NoError(
|
||||
t,
|
||||
api.Validate(bytes.NewReader(rawTemplate), nil),
|
||||
"template must pass pdfcpu validation",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerator_TypeIsPDF(t *testing.T) {
|
||||
g := pdf.New()
|
||||
require.Equal(t, token.TypePDF, g.Type())
|
||||
}
|
||||
|
||||
func TestGenerate_ArtifactKindIsFile(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, generators.KindFile, art.Kind)
|
||||
}
|
||||
|
||||
func TestGenerate_ContentTypeIsPDFMIME(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pdfContentTypeMIME, art.ContentType)
|
||||
}
|
||||
|
||||
func TestGenerate_Filename(t *testing.T) {
|
||||
g := pdf.New()
|
||||
|
||||
t.Run("nil Filename defaults to Document.pdf", func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFilename, art.Filename)
|
||||
})
|
||||
|
||||
t.Run(
|
||||
"empty Filename pointer defaults to Document.pdf",
|
||||
func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFTokenWithFilename("abc", ""),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFilename, art.Filename)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"whitespace-only Filename defaults to Document.pdf",
|
||||
func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFTokenWithFilename("abc", " "),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFilename, art.Filename)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run("set Filename is preserved (trimmed)", func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFTokenWithFilename("abc", " Q4-Plan.pdf "),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Q4-Plan.pdf", art.Filename)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerate_TriggerURL(t *testing.T) {
|
||||
g := pdf.New()
|
||||
|
||||
t.Run("base URL trailing slash trimmed", func(t *testing.T) {
|
||||
artA, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("tk1"),
|
||||
"https://canary.example.com",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
artB, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("tk1"),
|
||||
"https://canary.example.com/",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(
|
||||
t,
|
||||
string(artA.Content),
|
||||
"https://canary.example.com/c/tk1",
|
||||
)
|
||||
require.Contains(
|
||||
t,
|
||||
string(artB.Content),
|
||||
"https://canary.example.com/c/tk1",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("base URL subpath preserved", func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("tk2"),
|
||||
"https://example.com/canary",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Contains(
|
||||
t,
|
||||
string(art.Content),
|
||||
"https://example.com/canary/c/tk2",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run(
|
||||
"different token ids produce distinct outputs",
|
||||
func(t *testing.T) {
|
||||
artA, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("aaa"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
artB, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("bbb"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, artA.Content, artB.Content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_LengthUnchanged(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(
|
||||
t,
|
||||
art.Content,
|
||||
len(rawTemplate),
|
||||
"output byte length must equal template byte length so xref offsets remain valid",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsTriggerURL(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("token42"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(
|
||||
t,
|
||||
bytes.Contains(
|
||||
art.Content,
|
||||
[]byte("https://canary.example.com/c/token42"),
|
||||
),
|
||||
"output must contain the canary trigger URL after substitution",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_PlaceholderRemoved(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("xyz"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(
|
||||
t,
|
||||
bytes.Contains(art.Content, []byte(placeholderRootLiteral)),
|
||||
"placeholder root must be fully substituted out of the output",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_TooLongURL_ReturnsError(t *testing.T) {
|
||||
g := pdf.New()
|
||||
|
||||
overLongID := strings.Repeat("x", pdf.PlaceholderLength)
|
||||
_, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken(overLongID),
|
||||
testBaseURL,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, pdf.ErrTriggerURLTooLong)
|
||||
}
|
||||
|
||||
func TestGenerate_BoundaryURL_AtPlaceholderLength_Succeeds(t *testing.T) {
|
||||
g := pdf.New()
|
||||
|
||||
base := "http://x"
|
||||
prefix := base + "/c/"
|
||||
idLen := pdf.PlaceholderLength - len(prefix)
|
||||
require.Positive(
|
||||
t,
|
||||
idLen,
|
||||
"test base+/c/ must leave room for at least one id char",
|
||||
)
|
||||
id := strings.Repeat("a", idLen)
|
||||
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken(id),
|
||||
base,
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
err,
|
||||
"URL whose length exactly equals placeholder length must succeed (no padding needed)",
|
||||
)
|
||||
require.True(
|
||||
t,
|
||||
bytes.Contains(art.Content, []byte(prefix+id)),
|
||||
"boundary URL must be present in output",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_PdfcpuValidates(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("validate-me"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(
|
||||
t,
|
||||
api.Validate(bytes.NewReader(art.Content), nil),
|
||||
"output PDF must remain valid after placeholder substitution",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_OutputDifferentFromTemplate(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(
|
||||
t,
|
||||
rawTemplate,
|
||||
art.Content,
|
||||
"output must differ from template after substitution",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_SubstitutionAtSamePlaceholderPosition(t *testing.T) {
|
||||
g := pdf.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newPDFToken("position-check"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
placeholderOffset := bytes.Index(
|
||||
rawTemplate,
|
||||
[]byte(placeholderRootLiteral),
|
||||
)
|
||||
require.GreaterOrEqual(t, placeholderOffset, 0)
|
||||
|
||||
urlOffset := bytes.Index(
|
||||
art.Content,
|
||||
[]byte("https://canary.example.com/c/position-check"),
|
||||
)
|
||||
require.Equal(
|
||||
t,
|
||||
placeholderOffset,
|
||||
urlOffset,
|
||||
"trigger URL must occupy the same byte offset the placeholder did",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) {
|
||||
g := pdf.New()
|
||||
tok := newPDFToken("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 := pdf.New()
|
||||
tok := newPDFToken("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", "AcrobatReader/2024.001")
|
||||
r.Header.Set("Referer", "https://victim.example.com/share")
|
||||
|
||||
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, "AcrobatReader/2024.001", *evt.UserAgent)
|
||||
require.NotNil(t, evt.Referer)
|
||||
require.Equal(
|
||||
t,
|
||||
"https://victim.example.com/share",
|
||||
*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,
|
||||
"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_ResponseBodyIsIndependentCopyPerCall(t *testing.T) {
|
||||
g := pdf.New()
|
||||
tok := newPDFToken("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],
|
||||
"each Trigger call must produce an independent body slice",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) {
|
||||
g := pdf.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", "AcrobatReader/2024")
|
||||
|
||||
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.Clone(), resp.Body)
|
||||
require.Nil(
|
||||
t,
|
||||
evt,
|
||||
"nil-token path returns nil event so the handler cannot persist a row with empty TokenID (FK violation)",
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue