feat(canary): docx generator (zip-patch INCLUDEPICTURE)
Phase 4 Task 4.3+4.4 — docx Generator that produces a tracked Word
document by embedding template.docx and runtime-substituting the
HONEY_TRACK_URL placeholder in word/footer2.xml with the per-token
trigger URL. Preserves per-entry zip Method (STORE/DEFLATE) so
Word/LibreOffice still opens the result; non-footer entry bodies are
byte-identical to the template.
Generate returns {Kind: KindFile, Filename: t.Filename ?? "Document.docx",
Content: <patched zip>, ContentType: wordprocessingml MIME}. The
spec §9.3 reference snippet uses `t.Filename == ""` which won't
compile against the real schema — Token.Filename is *string. The
generator dereferences safely via resolveFilename, trimming and
falling back to the default for nil/empty/whitespace pointers.
Trigger mirrors webbug exactly per spec §9.3 line 1118: 200 + 43-byte
transparent GIF via pixel.Clone() + cache-control no-store + pragma
no-cache. Nil-token returns the same response with nil event
(spec §8.5 — no token-existence enumeration). realIP /
lastNonEmptyXFF / optionalHeader helpers are copied from webbug per
the standing rule (sanctioned duplication until Phase 9 middleware
extraction).
24 test cases including the six prescribed by implementation plan
§4.3 (OutputIsValidZip, FooterContainsTriggerURL,
FooterDoesNotContainPlaceholder, OtherEntriesUnchanged,
PreservesCompressionMethods, ReturnsGIFLikeWebbug) plus surrounding
correctness/regression coverage (type, kind, content type, filename
defaulting incl. whitespace, trigger-URL trailing-slash + subpath +
uniqueness, source-IP precedence with 9 subtests mirroring webbug,
GIF body independence per Trigger call, nil-token defense).
Also fixes pre-existing lint debt in cmd/builddocxtemplate from
commit f36cce9a: errcheck on the deferred f.Close (now propagates
via named return) and gosec G304 on os.OpenFile (now filepath.Clean
on the operator-supplied -out path). Rebuilding template.docx after
this change produces byte-identical output (verified via sha256sum).
This commit is contained in:
parent
f36cce9a01
commit
82bb3a9f14
|
|
@ -98,16 +98,25 @@ func main() {
|
|||
fmt.Printf("wrote %s\n", *out)
|
||||
}
|
||||
|
||||
func buildTemplate(out string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(out), dirPerm); err != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", err)
|
||||
func buildTemplate(out string) (err error) {
|
||||
cleaned := filepath.Clean(out)
|
||||
if mkErr := os.MkdirAll(filepath.Dir(cleaned), dirPerm); mkErr != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", mkErr)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, filePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output: %w", err)
|
||||
f, oErr := os.OpenFile(
|
||||
cleaned,
|
||||
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
|
||||
filePerm,
|
||||
)
|
||||
if oErr != nil {
|
||||
return fmt.Errorf("open output: %w", oErr)
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("close output: %w", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
for _, e := range entries() {
|
||||
|
|
@ -120,8 +129,8 @@ func buildTemplate(out string) error {
|
|||
return fmt.Errorf("write %s: %w", e.name, wErr)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("close zip writer: %w", err)
|
||||
if zErr := w.Close(); zErr != nil {
|
||||
return fmt.Errorf("close zip writer: %w", zErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
// ©AngelaMos | 2026
|
||||
// generator.go
|
||||
|
||||
package docx
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"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/"
|
||||
|
||||
placeholder = "HONEY_TRACK_URL"
|
||||
footerEntry = "word/footer2.xml"
|
||||
|
||||
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
defaultFilename = "Document.docx"
|
||||
)
|
||||
|
||||
//go:embed template/template.docx
|
||||
var docxTemplate []byte
|
||||
|
||||
type Generator struct{}
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
|
||||
func (g *Generator) Type() token.Type { return token.TypeDocx }
|
||||
|
||||
func (g *Generator) Generate(
|
||||
_ context.Context,
|
||||
t *token.Token,
|
||||
baseURL string,
|
||||
) (generators.Artifact, error) {
|
||||
triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID
|
||||
|
||||
patched, err := patchTemplate(docxTemplate, triggerURL)
|
||||
if err != nil {
|
||||
return generators.Artifact{}, err
|
||||
}
|
||||
|
||||
return generators.Artifact{
|
||||
Kind: generators.KindFile,
|
||||
Filename: resolveFilename(t.Filename),
|
||||
Content: patched,
|
||||
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 patchTemplate(template []byte, triggerURL string) ([]byte, error) {
|
||||
in, err := zip.NewReader(bytes.NewReader(template), int64(len(template)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docx: parse template: %w", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
w := zip.NewWriter(&out)
|
||||
for _, f := range in.File {
|
||||
rc, oErr := f.Open()
|
||||
if oErr != nil {
|
||||
return nil, fmt.Errorf("docx: open %s: %w", f.Name, oErr)
|
||||
}
|
||||
body, rErr := io.ReadAll(rc)
|
||||
cErr := rc.Close()
|
||||
if rErr != nil {
|
||||
return nil, fmt.Errorf("docx: read %s: %w", f.Name, rErr)
|
||||
}
|
||||
if cErr != nil {
|
||||
return nil, fmt.Errorf("docx: close %s: %w", f.Name, cErr)
|
||||
}
|
||||
|
||||
if f.Name == footerEntry {
|
||||
body = bytes.Replace(
|
||||
body,
|
||||
[]byte(placeholder),
|
||||
[]byte(triggerURL),
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
hdr := &zip.FileHeader{Name: f.Name, Method: f.Method}
|
||||
fw, cErr := w.CreateHeader(hdr)
|
||||
if cErr != nil {
|
||||
return nil, fmt.Errorf("docx: create %s: %w", f.Name, cErr)
|
||||
}
|
||||
if _, wErr := fw.Write(body); wErr != nil {
|
||||
return nil, fmt.Errorf("docx: write %s: %w", f.Name, wErr)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("docx: close zip writer: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
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,577 @@
|
|||
// ©AngelaMos | 2026
|
||||
// generator_test.go
|
||||
|
||||
package docx_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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/docx"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
|
||||
)
|
||||
|
||||
//go:embed template/template.docx
|
||||
var rawTemplate []byte
|
||||
|
||||
const (
|
||||
testBaseURL = "https://canary.example.com"
|
||||
placeholderLiteral = "HONEY_TRACK_URL"
|
||||
footerEntryName = "word/footer2.xml"
|
||||
docxContentTypeMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
defaultFilename = "Document.docx"
|
||||
cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
pragmaNoCacheValue = "no-cache"
|
||||
gifByteLength = 43
|
||||
)
|
||||
|
||||
func newDocxToken(id string) *token.Token {
|
||||
return &token.Token{
|
||||
ID: id,
|
||||
ManageID: "manage-" + id,
|
||||
Type: token.TypeDocx,
|
||||
Memo: "unit test docx",
|
||||
AlertChannel: token.ChannelWebhook,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newDocxTokenWithFilename(id, filename string) *token.Token {
|
||||
tok := newDocxToken(id)
|
||||
tok.Filename = &filename
|
||||
return tok
|
||||
}
|
||||
|
||||
func readFooterXML(t *testing.T, archive []byte) []byte {
|
||||
t.Helper()
|
||||
r, err := zip.NewReader(
|
||||
bytes.NewReader(archive),
|
||||
int64(len(archive)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
for _, f := range r.File {
|
||||
if f.Name != footerEntryName {
|
||||
continue
|
||||
}
|
||||
rc, oErr := f.Open()
|
||||
require.NoError(t, oErr)
|
||||
body, rErr := io.ReadAll(rc)
|
||||
require.NoError(t, rc.Close())
|
||||
require.NoError(t, rErr)
|
||||
return body
|
||||
}
|
||||
t.Fatalf("entry %q not found in zip", footerEntryName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func zipEntryBodies(t *testing.T, archive []byte) map[string][]byte {
|
||||
t.Helper()
|
||||
r, err := zip.NewReader(
|
||||
bytes.NewReader(archive),
|
||||
int64(len(archive)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
out := make(map[string][]byte, len(r.File))
|
||||
for _, f := range r.File {
|
||||
rc, oErr := f.Open()
|
||||
require.NoError(t, oErr)
|
||||
body, rErr := io.ReadAll(rc)
|
||||
require.NoError(t, rc.Close())
|
||||
require.NoError(t, rErr)
|
||||
out[f.Name] = body
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func zipEntryMethods(t *testing.T, archive []byte) map[string]uint16 {
|
||||
t.Helper()
|
||||
r, err := zip.NewReader(
|
||||
bytes.NewReader(archive),
|
||||
int64(len(archive)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
out := make(map[string]uint16, len(r.File))
|
||||
for _, f := range r.File {
|
||||
out[f.Name] = f.Method
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestGenerator_TypeIsDocx(t *testing.T) {
|
||||
g := docx.New()
|
||||
require.Equal(t, token.TypeDocx, g.Type())
|
||||
}
|
||||
|
||||
func TestGenerate_ArtifactKindIsFile(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, generators.KindFile, art.Kind)
|
||||
}
|
||||
|
||||
func TestGenerate_ContentTypeIsDocxMIME(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, docxContentTypeMIME, art.ContentType)
|
||||
}
|
||||
|
||||
func TestGenerate_Filename(t *testing.T) {
|
||||
g := docx.New()
|
||||
|
||||
t.Run("nil Filename defaults to Document.docx", func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFilename, art.Filename)
|
||||
})
|
||||
|
||||
t.Run(
|
||||
"empty Filename pointer defaults to Document.docx",
|
||||
func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxTokenWithFilename("abc", ""),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFilename, art.Filename)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"whitespace-only Filename defaults to Document.docx",
|
||||
func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxTokenWithFilename("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(),
|
||||
newDocxTokenWithFilename("abc", " Q4-Plan.docx "),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Q4-Plan.docx", art.Filename)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerate_TriggerURL(t *testing.T) {
|
||||
g := docx.New()
|
||||
|
||||
t.Run("base URL trailing slash trimmed", func(t *testing.T) {
|
||||
artA, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("tk1"),
|
||||
"https://canary.example.com",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
artB, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("tk1"),
|
||||
"https://canary.example.com/",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
bodyA := readFooterXML(t, artA.Content)
|
||||
bodyB := readFooterXML(t, artB.Content)
|
||||
require.Contains(t, string(bodyA), "https://canary.example.com/c/tk1")
|
||||
require.Contains(t, string(bodyB), "https://canary.example.com/c/tk1")
|
||||
})
|
||||
|
||||
t.Run("base URL subpath preserved", func(t *testing.T) {
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("tk2"),
|
||||
"https://example.com/canary",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
body := readFooterXML(t, art.Content)
|
||||
require.Contains(t, string(body), "https://example.com/canary/c/tk2")
|
||||
})
|
||||
|
||||
t.Run("different token ids produce distinct outputs", func(t *testing.T) {
|
||||
artA, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("aaa"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
artB, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("bbb"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, artA.Content, artB.Content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerate_OutputIsValidZip(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
r, zErr := zip.NewReader(
|
||||
bytes.NewReader(art.Content),
|
||||
int64(len(art.Content)),
|
||||
)
|
||||
require.NoError(t, zErr, "generated docx must parse as a zip archive")
|
||||
require.NotEmpty(t, r.File, "zip must contain at least one entry")
|
||||
}
|
||||
|
||||
func TestGenerate_FooterContainsTriggerURL(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("token42"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := readFooterXML(t, art.Content)
|
||||
require.Contains(
|
||||
t,
|
||||
string(body),
|
||||
"https://canary.example.com/c/token42",
|
||||
"footer must reference the canary trigger URL after substitution",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_FooterDoesNotContainPlaceholder(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("xyz"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := readFooterXML(t, art.Content)
|
||||
require.NotContains(
|
||||
t,
|
||||
string(body),
|
||||
placeholderLiteral,
|
||||
"placeholder must be fully substituted in footer2.xml",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerate_OtherEntriesUnchanged(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
templateBodies := zipEntryBodies(t, rawTemplate)
|
||||
outputBodies := zipEntryBodies(t, art.Content)
|
||||
require.Len(
|
||||
t,
|
||||
outputBodies,
|
||||
len(templateBodies),
|
||||
"output must have the same entry count as the template",
|
||||
)
|
||||
|
||||
for name, tmplBody := range templateBodies {
|
||||
outBody, ok := outputBodies[name]
|
||||
require.True(t, ok, "output missing template entry %q", name)
|
||||
if name == footerEntryName {
|
||||
require.NotEqual(
|
||||
t,
|
||||
tmplBody,
|
||||
outBody,
|
||||
"footer2.xml must change after substitution",
|
||||
)
|
||||
continue
|
||||
}
|
||||
require.Equal(
|
||||
t,
|
||||
tmplBody,
|
||||
outBody,
|
||||
"non-footer entry %q must be byte-identical to template",
|
||||
name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_PreservesCompressionMethods(t *testing.T) {
|
||||
g := docx.New()
|
||||
art, err := g.Generate(
|
||||
context.Background(),
|
||||
newDocxToken("abc"),
|
||||
testBaseURL,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
templateMethods := zipEntryMethods(t, rawTemplate)
|
||||
outputMethods := zipEntryMethods(t, art.Content)
|
||||
|
||||
hasStore, hasDeflate := false, false
|
||||
for _, m := range templateMethods {
|
||||
switch m {
|
||||
case zip.Store:
|
||||
hasStore = true
|
||||
case zip.Deflate:
|
||||
hasDeflate = true
|
||||
}
|
||||
}
|
||||
require.True(
|
||||
t,
|
||||
hasStore && hasDeflate,
|
||||
"template must mix STORE and DEFLATE for this test to be a real regression guard",
|
||||
)
|
||||
|
||||
for name, tmplMethod := range templateMethods {
|
||||
outMethod, ok := outputMethods[name]
|
||||
require.True(t, ok, "output missing template entry %q", name)
|
||||
require.Equal(
|
||||
t,
|
||||
tmplMethod,
|
||||
outMethod,
|
||||
"entry %q method mismatch (template=%d, output=%d)",
|
||||
name,
|
||||
tmplMethod,
|
||||
outMethod,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) {
|
||||
g := docx.New()
|
||||
tok := newDocxToken("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 := docx.New()
|
||||
tok := newDocxToken("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", "LibreOffice/24.2")
|
||||
r.Header.Set("Referer", "https://victim.example.com/inbox")
|
||||
|
||||
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, "LibreOffice/24.2", *evt.UserAgent)
|
||||
require.NotNil(t, evt.Referer)
|
||||
require.Equal(t, "https://victim.example.com/inbox", *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 without port falls back to raw value",
|
||||
headers: nil,
|
||||
remote: "127.0.0.1",
|
||||
wantIP: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
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 := docx.New()
|
||||
tok := newDocxToken("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 := docx.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,
|
||||
"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