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:
CarterPerez-dev 2026-05-13 03:14:12 -04:00
parent f36cce9a01
commit 82bb3a9f14
3 changed files with 787 additions and 9 deletions

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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)",
)
}