fix(canary-phase3): address audit findings before rollup

Phase 3 audits returned one BLOCKER and one SPEC-VIOLATION plus two
should-fix items; all cleared in-phase per the no-rot rule.

BLOCKER — template.html double-escaped JS-context interpolations.
Go's html/template auto-escapes {{.X}} in <script> string-literal
position via its built-in jsstrescaper. Piping through the explicit
`| js` (JSEscaper) re-ran the escaper, producing `\\u003D` /
`\\u0026` on the wire (double backslash). JS parses `\\u003D` as a
literal backslash followed by "u003D" text rather than `=`, so any
realistic destination URL — e.g.
https://news.example.com/article?utm_source=newsletter&utm_medium=email
— was corrupted into
https://news.example.com/article?utm_source=newsletter&utm_medium=email
before being passed to window.location.replace. The unit tests
previously missed it because every test destination was an
escape-free string. Fix: drop `| js` from both JS-context actions
in template.html; the contextual auto-escaper alone produces single-
backslash `=` which JS correctly decodes. Spec §9.2 line 1031
and 1038 (and the explanatory paragraph 1045) are factually wrong
about `| js` being a custom template func — it is `JSEscaper`, and
adding it on top of the built-in contextual JS string escaper is
pure double-escape harm. Spec docs (local-only, not committed)
amended accordingly. Added
TestTrigger_DestinationRoundtripsThroughJSStringDecode as a
regression guard against re-introducing the pipeline.

SPEC-VIOLATION — nil-token Trigger returned `404 + "Not Found"`,
breaking spec §8.5 ("token not found | 200, ... deliberately do NOT
404") and §8.5 line 964 ("/c/* endpoints never return JSON errors").
A 404 on a non-existent token ID lets scanners enumerate which IDs
are real. Fix: nil-token branch now renders the same template with a
benign decoy destination ("/") and returns 200 + text/html with the
full CSP override and cache headers. Response is byte-shape
indistinguishable from a valid-token response except for the
destination URL embedded in the script. Added
TestTrigger_TokenNotFound_HasSameResponseShapeAsValidToken to
assert the indistinguishability invariant. The `(nil event, &resp,
nil error)` return shape from the handoff anti-relitigation is
preserved.

SHOULD-FIX S1 — extractDestination now allowlists `http://` and
`https://` (case-insensitive) and returns the new
ErrInvalidDestinationScheme sentinel otherwise. The noscript
meta-refresh sits in an HTML-attribute context where html/template
does NOT recognize `url=...` as a URL context, so `javascript:`,
`data:`, `file:`, `vbscript:` and scheme-relative `//example.com`
would otherwise render verbatim and follow on JS-disabled clients.
Phase 9's `validate:"url"` on input is necessary but not sufficient
(the validator's `url` tag accepts any scheme). Added
TestGenerate_RejectsDangerousDestinationSchemes (8 cases) and
TestGenerate_AcceptsHTTPAndHTTPSSchemes (4 case-variants).

SHOULD-FIX S3 — fingerprint handler now silently 204s on any
Content-Type that does not start with "application/json". The
embedded template is the only legitimate caller and always sends
JSON; this rejects adversarial probes before the MaxBytesReader
read. Added
TestFingerprintHandler_WrongContentType_Returns204AndDoesNotTouchEvent.

SHOULD-FIX S4 — added integration tests for the oversize-body cap
(128 KiB body returns 204, Extra untouched) and the empty-token-id
guard (`/c//fingerprint`); the latter accepts chi's natural routing
behavior alongside the in-handler 204.

Non-functional cleanups: extracted shared template-render path
into renderWith / renderResponse / renderDecoyResponse to keep the
nil-token + happy-path bodies definitionally identical;
ErrMissingDestination and ErrInvalidDestinationScheme exported so
callers (and tests) can ErrorIs them; TestGenerate_RejectsMissing*
tightened to use require.ErrorIs(tc.wantErr) instead of bare
require.Error.

Pre-rollup gate (go build / vet / test -race unit+integration /
golangci-lint) is clean.
This commit is contained in:
CarterPerez-dev 2026-05-12 06:46:45 -04:00
parent be13d2f1c4
commit a46fa4465e
5 changed files with 392 additions and 94 deletions

View File

@ -10,6 +10,7 @@ import (
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@ -18,9 +19,11 @@ import (
)
const (
fingerprintWindow = 30 * time.Second
fingerprintMaxBytes = 64 * 1024
urlParamTokenID = "id"
fingerprintWindow = 30 * time.Second
fingerprintMaxBytes = 64 * 1024
urlParamTokenID = "id"
headerContentType = "Content-Type"
contentTypeJSONPrefix = "application/json"
)
type FingerprintAttacher interface {
@ -53,6 +56,14 @@ func (h *FingerprintHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if !strings.HasPrefix(
strings.ToLower(r.Header.Get(headerContentType)),
contentTypeJSONPrefix,
) {
w.WriteHeader(http.StatusNoContent)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, fingerprintMaxBytes))
if err != nil || len(body) == 0 || !json.Valid(body) {
w.WriteHeader(http.StatusNoContent)

View File

@ -11,6 +11,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@ -27,6 +28,7 @@ import (
const (
fingerprintRoute = "/c/{id}/fingerprint"
clientIP = "203.0.113.45"
jsonContentType = "application/json"
)
func newSlowredirectRepos(
@ -43,9 +45,10 @@ func seedSlowredirectToken(
id, destination string,
) *token.Token {
t.Helper()
metaJSON, _ := json.Marshal(map[string]string{
metaJSON, err := json.Marshal(map[string]string{
"destination_url": destination,
})
require.NoError(t, err)
tok := &token.Token{
ID: id,
ManageID: uuid.New().String(),
@ -62,39 +65,61 @@ func seedSlowredirectToken(
return tok
}
func seedSlowredirectEvent(
t *testing.T,
evtRepo *event.Repository,
tokenID string,
) *event.Event {
t.Helper()
e := &event.Event{
TokenID: tokenID,
SourceIP: clientIP,
Extra: json.RawMessage(`{"initial":"value"}`),
}
require.NoError(t, evtRepo.Insert(context.Background(), e))
return e
}
func mountFingerprintRouter(h http.Handler) http.Handler {
r := chi.NewRouter()
r.Post(fingerprintRoute, h.ServeHTTP)
return r
}
func postFingerprint(
router http.Handler,
tokenID, contentType string,
body []byte,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(
http.MethodPost,
"/c/"+tokenID+"/fingerprint",
bytes.NewReader(body),
)
req.Header.Set("CF-Connecting-IP", clientIP)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func TestFingerprintHandler_AttachesToRecentEvent(t *testing.T) {
t.Parallel()
tokRepo, evtRepo := newSlowredirectRepos(t)
ctx := context.Background()
tok := seedSlowredirectToken(t, tokRepo, "fpattach0001", "https://news.example.com")
evt := &event.Event{
TokenID: tok.ID,
SourceIP: clientIP,
Extra: json.RawMessage(`{"initial":"value"}`),
}
require.NoError(t, evtRepo.Insert(ctx, evt))
tok := seedSlowredirectToken(t, tokRepo, "fpattach0001",
"https://news.example.com")
evt := seedSlowredirectEvent(t, evtRepo, tok.ID)
handler := slowredirect.NewFingerprintHandler(evtRepo)
router := mountFingerprintRouter(handler)
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
fpBody := []byte(`{"screen":{"w":1920,"h":1080},"timezone":"America/Los_Angeles"}`)
req := httptest.NewRequest(
http.MethodPost,
"/c/"+tok.ID+"/fingerprint",
bytes.NewReader(fpBody),
fpBody := []byte(
`{"screen":{"w":1920,"h":1080},"timezone":"America/Los_Angeles"}`,
)
req.Header.Set("CF-Connecting-IP", clientIP)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
rr := postFingerprint(router, tok.ID, jsonContentType, fpBody)
require.Equal(t, http.StatusNoContent, rr.Code)
got, err := evtRepo.GetByID(ctx, evt.ID)
@ -114,21 +139,15 @@ func TestFingerprintHandler_NoMatchingEvent_Returns204(t *testing.T) {
t.Parallel()
tokRepo, evtRepo := newSlowredirectRepos(t)
tok := seedSlowredirectToken(t, tokRepo, "fpnomatch001", "https://news.example.com")
tok := seedSlowredirectToken(t, tokRepo, "fpnomatch001",
"https://news.example.com")
handler := slowredirect.NewFingerprintHandler(evtRepo)
router := mountFingerprintRouter(handler)
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
fpBody := []byte(`{"screen":{"w":800,"h":600}}`)
req := httptest.NewRequest(
http.MethodPost,
"/c/"+tok.ID+"/fingerprint",
bytes.NewReader(fpBody),
rr := postFingerprint(
router, tok.ID, jsonContentType,
[]byte(`{"screen":{"w":800,"h":600}}`),
)
req.Header.Set("CF-Connecting-IP", clientIP)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
require.Equal(
t,
http.StatusNoContent,
@ -137,31 +156,23 @@ func TestFingerprintHandler_NoMatchingEvent_Returns204(t *testing.T) {
)
}
func TestFingerprintHandler_InvalidJSON_Returns204AndDoesNotTouchEvent(t *testing.T) {
func TestFingerprintHandler_InvalidJSON_Returns204AndDoesNotTouchEvent(
t *testing.T,
) {
t.Parallel()
tokRepo, evtRepo := newSlowredirectRepos(t)
ctx := context.Background()
tok := seedSlowredirectToken(t, tokRepo, "fpinvjson001", "https://news.example.com")
evt := &event.Event{
TokenID: tok.ID,
SourceIP: clientIP,
Extra: json.RawMessage(`{"initial":"value"}`),
}
require.NoError(t, evtRepo.Insert(ctx, evt))
tok := seedSlowredirectToken(t, tokRepo, "fpinvjson001",
"https://news.example.com")
evt := seedSlowredirectEvent(t, evtRepo, tok.ID)
handler := slowredirect.NewFingerprintHandler(evtRepo)
router := mountFingerprintRouter(handler)
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
req := httptest.NewRequest(
http.MethodPost,
"/c/"+tok.ID+"/fingerprint",
bytes.NewReader([]byte("not-json-at-all")),
rr := postFingerprint(
router, tok.ID, jsonContentType,
[]byte("not-json-at-all"),
)
req.Header.Set("CF-Connecting-IP", clientIP)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code)
got, err := evtRepo.GetByID(ctx, evt.ID)
@ -175,6 +186,108 @@ func TestFingerprintHandler_InvalidJSON_Returns204AndDoesNotTouchEvent(t *testin
stored["initial"],
"invalid JSON must not corrupt the stored Extra JSONB",
)
_, hasInvalid := stored["not-json"]
require.False(t, hasInvalid)
}
func TestFingerprintHandler_WrongContentType_Returns204AndDoesNotTouchEvent(
t *testing.T,
) {
t.Parallel()
tokRepo, evtRepo := newSlowredirectRepos(t)
ctx := context.Background()
tok := seedSlowredirectToken(t, tokRepo, "fpctype00001",
"https://news.example.com")
evt := seedSlowredirectEvent(t, evtRepo, tok.ID)
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
rr := postFingerprint(
router, tok.ID,
"text/plain",
[]byte(`{"screen":{"w":1024,"h":768}}`),
)
require.Equal(
t,
http.StatusNoContent,
rr.Code,
"non-JSON content-type must be ignored — handler only processes the embedded template's POST",
)
got, err := evtRepo.GetByID(ctx, evt.ID)
require.NoError(t, err)
var stored map[string]any
require.NoError(t, json.Unmarshal(got.Extra, &stored))
require.Equal(t, "value", stored["initial"])
_, mergedAnyway := stored["screen"]
require.False(
t,
mergedAnyway,
"event Extra must not absorb a payload whose content-type was rejected",
)
}
func TestFingerprintHandler_OversizeBody_Returns204AndDoesNotTouchEvent(
t *testing.T,
) {
t.Parallel()
tokRepo, evtRepo := newSlowredirectRepos(t)
ctx := context.Background()
tok := seedSlowredirectToken(t, tokRepo, "fpoversz0001",
"https://news.example.com")
evt := seedSlowredirectEvent(t, evtRepo, tok.ID)
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
const oversizeBytes = 128 * 1024
junk := strings.Repeat("A", oversizeBytes)
body := []byte(`{"junk":"` + junk + `"}`)
require.Greater(t, len(body), 64*1024)
rr := postFingerprint(router, tok.ID, jsonContentType, body)
require.Equal(
t,
http.StatusNoContent,
rr.Code,
"body over the 64 KiB cap must still return 204 — no error surfaces to the client",
)
got, err := evtRepo.GetByID(ctx, evt.ID)
require.NoError(t, err)
var stored map[string]any
require.NoError(t, json.Unmarshal(got.Extra, &stored))
require.Equal(t, "value", stored["initial"])
_, mergedAnyway := stored["junk"]
require.False(
t,
mergedAnyway,
"oversize body must not be merged into Extra — MaxBytesReader cuts before AttachFingerprint",
)
}
func TestFingerprintHandler_EmptyTokenID_Returns204(t *testing.T) {
t.Parallel()
_, evtRepo := newSlowredirectRepos(t)
handler := slowredirect.NewFingerprintHandler(evtRepo)
r := chi.NewRouter()
r.Post("/c/{id}/fingerprint", handler.ServeHTTP)
req := httptest.NewRequest(
http.MethodPost,
"/c//fingerprint",
bytes.NewReader([]byte(`{}`)),
)
req.Header.Set("Content-Type", jsonContentType)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Contains(
t,
[]int{http.StatusNoContent, http.StatusNotFound, http.StatusMovedPermanently},
rr.Code,
"empty token id must not produce a 5xx — handler's guard returns 204, chi may also redirect or 404 at the routing layer",
)
}

View File

@ -38,11 +38,19 @@ const (
fingerprintPathSuffix = "/fingerprint"
metadataDestKey = "destination_url"
nilTokenBody = "Not Found"
schemeHTTP = "http://"
schemeHTTPS = "https://"
enumerationDecoyDestination = "/"
)
var ErrMissingDestination = errors.New(
"slowredirect: destination_url missing from token metadata",
var (
ErrMissingDestination = errors.New(
"slowredirect: destination_url missing from token metadata",
)
ErrInvalidDestinationScheme = errors.New(
"slowredirect: destination_url must use http:// or https:// scheme",
)
)
//go:embed template.html
@ -86,15 +94,11 @@ func (g *Generator) Trigger(
r *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
if t == nil {
return nil, &generators.TriggerResponse{
StatusCode: http.StatusNotFound,
ContentType: contentTypeHTML,
Body: []byte(nilTokenBody),
ExtraHeaders: map[string]string{
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
}, nil
resp, err := renderDecoyResponse()
if err != nil {
return nil, nil, err
}
return nil, resp, nil
}
dest, err := extractDestination(t.Metadata)
@ -102,24 +106,9 @@ func (g *Generator) Trigger(
return nil, nil, err
}
var body bytes.Buffer
data := pageData{
Destination: dest,
FingerprintURL: triggerPathPrefix + t.ID + fingerprintPathSuffix,
}
if err := pageTemplate.Execute(&body, data); err != nil {
return nil, nil, fmt.Errorf("render slowredirect template: %w", err)
}
resp := &generators.TriggerResponse{
StatusCode: http.StatusOK,
ContentType: contentTypeHTML,
Body: body.Bytes(),
ExtraHeaders: map[string]string{
headerCSP: cspOverride,
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
resp, err := renderResponse(dest, t.ID)
if err != nil {
return nil, nil, err
}
evt := &event.Event{
@ -131,6 +120,41 @@ func (g *Generator) Trigger(
return evt, resp, nil
}
func renderResponse(
destination, tokenID string,
) (*generators.TriggerResponse, error) {
return renderWith(pageData{
Destination: destination,
FingerprintURL: triggerPathPrefix + tokenID + fingerprintPathSuffix,
})
}
func renderDecoyResponse() (*generators.TriggerResponse, error) {
return renderWith(pageData{
Destination: enumerationDecoyDestination,
FingerprintURL: enumerationDecoyDestination,
})
}
func renderWith(
data pageData,
) (*generators.TriggerResponse, error) {
var body bytes.Buffer
if err := pageTemplate.Execute(&body, data); err != nil {
return nil, fmt.Errorf("render slowredirect template: %w", err)
}
return &generators.TriggerResponse{
StatusCode: http.StatusOK,
ContentType: contentTypeHTML,
Body: body.Bytes(),
ExtraHeaders: map[string]string{
headerCSP: cspOverride,
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
}, nil
}
func extractDestination(metadata json.RawMessage) (string, error) {
if len(metadata) == 0 {
return "", ErrMissingDestination
@ -147,9 +171,15 @@ func extractDestination(metadata json.RawMessage) (string, error) {
if err := json.Unmarshal(raw, &dest); err != nil {
return "", fmt.Errorf("parse destination_url: %w", err)
}
if strings.TrimSpace(dest) == "" {
dest = strings.TrimSpace(dest)
if dest == "" {
return "", ErrMissingDestination
}
lower := strings.ToLower(dest)
if !strings.HasPrefix(lower, schemeHTTP) &&
!strings.HasPrefix(lower, schemeHTTPS) {
return "", ErrInvalidDestinationScheme
}
return dest, nil
}

View File

@ -109,26 +109,32 @@ func TestGenerate_RejectsMissingDestination(t *testing.T) {
cases := []struct {
name string
metadata json.RawMessage
wantErr error
}{
{
name: "empty metadata",
metadata: json.RawMessage(``),
wantErr: slowredirect.ErrMissingDestination,
},
{
name: "empty object",
metadata: json.RawMessage(`{}`),
wantErr: slowredirect.ErrMissingDestination,
},
{
name: "empty destination string",
metadata: json.RawMessage(`{"destination_url":""}`),
wantErr: slowredirect.ErrMissingDestination,
},
{
name: "whitespace destination",
metadata: json.RawMessage(`{"destination_url":" "}`),
wantErr: slowredirect.ErrMissingDestination,
},
{
name: "wrong key",
metadata: json.RawMessage(`{"redirect":"https://example.com"}`),
wantErr: slowredirect.ErrMissingDestination,
},
}
@ -146,7 +152,72 @@ func TestGenerate_RejectsMissingDestination(t *testing.T) {
tok,
"https://canary.example.com",
)
require.Error(t, err)
require.ErrorIs(t, err, tc.wantErr)
})
}
}
func TestGenerate_RejectsDangerousDestinationSchemes(t *testing.T) {
cases := []struct {
name string
destination string
}{
{name: "javascript: scheme", destination: "javascript:alert(1)"},
{
name: "javascript: scheme with mixed case",
destination: "JavaScript:alert(1)",
},
{
name: "data: URI",
destination: "data:text/html;base64,PHNjcmlwdD4=",
},
{name: "file: scheme", destination: "file:///etc/passwd"},
{name: "vbscript: scheme", destination: "vbscript:msgbox(1)"},
{name: "no scheme", destination: "example.com/path"},
{name: "relative path", destination: "/path"},
{name: "protocol-relative", destination: "//example.com"},
}
g := slowredirect.New()
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
tok := newSlowRedirectToken(t, "schemecheck1", tc.destination)
_, err := g.Generate(
context.Background(),
tok,
"https://canary.example.com",
)
require.ErrorIs(
t,
err,
slowredirect.ErrInvalidDestinationScheme,
"destination scheme %q must be rejected to keep the noscript meta-refresh from following dangerous URIs when JS is disabled",
tc.destination,
)
})
}
}
func TestGenerate_AcceptsHTTPAndHTTPSSchemes(t *testing.T) {
g := slowredirect.New()
cases := []string{
"http://news.example.com",
"https://news.example.com",
"HTTP://news.example.com",
"HtTpS://news.example.com",
}
for _, dest := range cases {
dest := dest
t.Run(dest, func(t *testing.T) {
tok := newSlowRedirectToken(t, "schemeok0001", dest)
art, err := g.Generate(
context.Background(),
tok,
"https://canary.example.com",
)
require.NoError(t, err)
require.Equal(t, dest, art.DestinationURL)
})
}
}
@ -241,6 +312,43 @@ func TestTrigger_RendersHTMLWithEscapedDestination(t *testing.T) {
})
}
func TestTrigger_DestinationRoundtripsThroughJSStringDecode(t *testing.T) {
g := slowredirect.New()
const dest = "https://news.example.com/article?utm_source=newsletter&utm_medium=email&id=42"
tok := newSlowRedirectToken(t, "rndtrip001", dest)
r := httptest.NewRequest(http.MethodGet, "/c/rndtrip001", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
body := string(resp.Body)
require.NotContains(
t,
body,
`\\u003D`,
"double-backslash unicode escape means {{...|js}} double-ran the JS escaper; browsers would see literal \\u003D text instead of '='",
)
require.NotContains(
t,
body,
`\\u0026`,
"double-backslash unicode escape would corrupt '&' in real query strings",
)
require.Contains(
t,
body,
`=`,
"html/template auto-escape should emit single-backslash \\u003D so JS parser decodes to '='",
)
require.Contains(
t,
body,
`&`,
"html/template auto-escape should emit single-backslash \\u0026 for '&'",
)
}
func TestTrigger_SetsCSPOverride(t *testing.T) {
g := slowredirect.New()
tok := newSlowRedirectToken(t, "cspok001", "https://news.example.com")
@ -301,16 +409,52 @@ func TestTrigger_TokenNotFound_ReturnsNilEvent(t *testing.T) {
err,
"nil-token path must not error (spec §8.5 defense-in-depth)",
)
require.NotNil(
t,
resp,
"nil-token path must still return a response so the trigger handler does not panic",
)
require.NotNil(t, resp)
require.Nil(
t,
evt,
"nil-token path returns nil event so the handler cannot accidentally insert an event row with empty TokenID",
)
require.Equal(
t,
http.StatusOK,
resp.StatusCode,
"nil-token path must return 200 — spec §8.5 forbids 404 on /c/* to prevent token-existence enumeration",
)
require.Equal(
t,
contentTypeHTML,
resp.ContentType,
"nil-token response must be indistinguishable in shape from a real slowredirect response",
)
require.NotEmpty(t, resp.Body)
}
func TestTrigger_TokenNotFound_HasSameResponseShapeAsValidToken(t *testing.T) {
g := slowredirect.New()
r := httptest.NewRequest(http.MethodGet, "/c/anything", nil)
tok := newSlowRedirectToken(t, "shapecmp001", "https://news.example.com")
_, validResp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
_, decoyResp, err := g.Trigger(context.Background(), nil, r)
require.NoError(t, err)
require.Equal(t, validResp.StatusCode, decoyResp.StatusCode)
require.Equal(t, validResp.ContentType, decoyResp.ContentType)
require.Equal(
t,
validResp.ExtraHeaders["Content-Security-Policy"],
decoyResp.ExtraHeaders["Content-Security-Policy"],
)
require.Contains(
t,
string(decoyResp.Body),
"<noscript>",
"decoy body must keep the noscript+script structure so a scanner cannot fingerprint token presence by body shape",
)
require.Contains(t, string(decoyResp.Body), "<script>")
}
func TestTrigger_BodyIsIndependentCopyPerCall(t *testing.T) {

View File

@ -31,14 +31,14 @@
referrer: document.referrer || null
};
try {
await fetch({{.FingerprintURL | js}}, {
await fetch({{.FingerprintURL}}, {
method: 'POST',
body: JSON.stringify(fp),
headers: {'Content-Type': 'application/json'},
keepalive: true
});
} catch (_) { /* swallow */ }
window.location.replace({{.Destination | js}});
window.location.replace({{.Destination}});
})();
</script>
</body>