feat(canary): slowredirect generator (Generate + Trigger + template)
HTML+JS browser-fingerprint redirect page rendered via html/template: Generate reads metadata.destination_url off the token row and returns KindURL with the trigger URL + persisted destination; Trigger renders the embedded template (noscript meta-refresh + inline fetch posting the fingerprint, then window.location.replace), returns the page with Content-Security-Policy override allowing inline script + connect-src 'self', and emits the event capturing token id / source IP / UA / Referer. Nil-token Trigger returns nil event (FK guard) plus a 404 HTML body, matching the established defensive-rendering pattern from webbug. Destination_url is extracted defensively (missing/empty/whitespace all surface ErrMissingDestination). realIP precedence copied from webbug pending the Phase 9 middleware promotion. 19 unit-test assertions cover Type, Generate's URL+destination output, five missing-destination edge cases, XSS neutralization in two injection contexts (attribute escape + script-closing escape), CSP override + cache headers, event-recording metadata, nil-token contract, and per-call response independence.
This commit is contained in:
parent
8207b2747b
commit
71823f5de2
|
|
@ -0,0 +1,191 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// generator.go
|
||||||
|
|
||||||
|
package slowredirect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
headerCFConnectingIP = "CF-Connecting-IP"
|
||||||
|
headerXForwardedFor = "X-Forwarded-For"
|
||||||
|
headerXRealIP = "X-Real-IP"
|
||||||
|
headerReferer = "Referer"
|
||||||
|
headerCSP = "Content-Security-Policy"
|
||||||
|
headerCacheControl = "Cache-Control"
|
||||||
|
headerPragma = "Pragma"
|
||||||
|
|
||||||
|
cspOverride = "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'"
|
||||||
|
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
pragmaNoCache = "no-cache"
|
||||||
|
contentTypeHTML = "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
triggerPathPrefix = "/c/"
|
||||||
|
fingerprintPathSuffix = "/fingerprint"
|
||||||
|
metadataDestKey = "destination_url"
|
||||||
|
|
||||||
|
nilTokenBody = "Not Found"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrMissingDestination = errors.New(
|
||||||
|
"slowredirect: destination_url missing from token metadata",
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed template.html
|
||||||
|
var templateHTML string
|
||||||
|
|
||||||
|
var pageTemplate = template.Must(
|
||||||
|
template.New("slowredirect").Parse(templateHTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Destination string
|
||||||
|
FingerprintURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Generator struct{}
|
||||||
|
|
||||||
|
func New() *Generator { return &Generator{} }
|
||||||
|
|
||||||
|
func (g *Generator) Type() token.Type { return token.TypeSlowRedirect }
|
||||||
|
|
||||||
|
func (g *Generator) Generate(
|
||||||
|
_ context.Context,
|
||||||
|
t *token.Token,
|
||||||
|
baseURL string,
|
||||||
|
) (generators.Artifact, error) {
|
||||||
|
dest, err := extractDestination(t.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return generators.Artifact{}, err
|
||||||
|
}
|
||||||
|
url := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID
|
||||||
|
return generators.Artifact{
|
||||||
|
Kind: generators.KindURL,
|
||||||
|
URL: url,
|
||||||
|
DestinationURL: dest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Generator) Trigger(
|
||||||
|
_ context.Context,
|
||||||
|
t *token.Token,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, err := extractDestination(t.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := &event.Event{
|
||||||
|
TokenID: t.ID,
|
||||||
|
SourceIP: realIP(r),
|
||||||
|
UserAgent: optionalHeader(r.UserAgent()),
|
||||||
|
Referer: optionalHeader(r.Header.Get(headerReferer)),
|
||||||
|
}
|
||||||
|
return evt, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDestination(metadata json.RawMessage) (string, error) {
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return "", ErrMissingDestination
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(metadata, &m); err != nil {
|
||||||
|
return "", fmt.Errorf("parse token metadata: %w", err)
|
||||||
|
}
|
||||||
|
raw, ok := m[metadataDestKey]
|
||||||
|
if !ok {
|
||||||
|
return "", ErrMissingDestination
|
||||||
|
}
|
||||||
|
var dest string
|
||||||
|
if err := json.Unmarshal(raw, &dest); err != nil {
|
||||||
|
return "", fmt.Errorf("parse destination_url: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(dest) == "" {
|
||||||
|
return "", ErrMissingDestination
|
||||||
|
}
|
||||||
|
return dest, 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,333 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// generator_test.go
|
||||||
|
|
||||||
|
package slowredirect_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cspOverride = "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'"
|
||||||
|
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
pragmaNoCache = "no-cache"
|
||||||
|
contentTypeHTML = "text/html; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSlowRedirectToken(t testing.TB, id, destination string) *token.Token {
|
||||||
|
t.Helper()
|
||||||
|
metadata := json.RawMessage(`{}`)
|
||||||
|
if destination != "" {
|
||||||
|
raw, err := json.Marshal(map[string]string{
|
||||||
|
"destination_url": destination,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
metadata = raw
|
||||||
|
}
|
||||||
|
return &token.Token{
|
||||||
|
ID: id,
|
||||||
|
ManageID: "manage-" + id,
|
||||||
|
Type: token.TypeSlowRedirect,
|
||||||
|
Memo: "unit test slowredirect",
|
||||||
|
AlertChannel: token.ChannelWebhook,
|
||||||
|
Enabled: true,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerator_TypeIsSlowRedirect(t *testing.T) {
|
||||||
|
g := slowredirect.New()
|
||||||
|
require.Equal(t, token.TypeSlowRedirect, g.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_PersistsDestinationOnToken(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
baseURL string
|
||||||
|
id string
|
||||||
|
destination string
|
||||||
|
wantURL string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "plain destination",
|
||||||
|
baseURL: "https://canary.example.com",
|
||||||
|
id: "abc123",
|
||||||
|
destination: "https://news.example.com/article/42",
|
||||||
|
wantURL: "https://canary.example.com/c/abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing slash trimmed on base URL",
|
||||||
|
baseURL: "https://canary.example.com/",
|
||||||
|
id: "xyz999",
|
||||||
|
destination: "https://news.example.com",
|
||||||
|
wantURL: "https://canary.example.com/c/xyz999",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subpath base URL preserved",
|
||||||
|
baseURL: "https://example.com/canary",
|
||||||
|
id: "p7",
|
||||||
|
destination: "https://other.example.com",
|
||||||
|
wantURL: "https://example.com/canary/c/p7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
g := slowredirect.New()
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tok := newSlowRedirectToken(t, tc.id, tc.destination)
|
||||||
|
art, err := g.Generate(
|
||||||
|
context.Background(),
|
||||||
|
tok,
|
||||||
|
tc.baseURL,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, generators.KindURL, art.Kind)
|
||||||
|
require.Equal(t, tc.wantURL, art.URL)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
tc.destination,
|
||||||
|
art.DestinationURL,
|
||||||
|
"artifact must surface destination_url that was persisted on the token metadata",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_RejectsMissingDestination(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
metadata json.RawMessage
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty metadata",
|
||||||
|
metadata: json.RawMessage(``),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty object",
|
||||||
|
metadata: json.RawMessage(`{}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty destination string",
|
||||||
|
metadata: json.RawMessage(`{"destination_url":""}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace destination",
|
||||||
|
metadata: json.RawMessage(`{"destination_url":" "}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong key",
|
||||||
|
metadata: json.RawMessage(`{"redirect":"https://example.com"}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
g := slowredirect.New()
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tok := &token.Token{
|
||||||
|
ID: "missingdest",
|
||||||
|
Type: token.TypeSlowRedirect,
|
||||||
|
Metadata: tc.metadata,
|
||||||
|
}
|
||||||
|
_, err := g.Generate(
|
||||||
|
context.Background(),
|
||||||
|
tok,
|
||||||
|
"https://canary.example.com",
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrigger_RendersHTMLWithEscapedDestination(t *testing.T) {
|
||||||
|
g := slowredirect.New()
|
||||||
|
|
||||||
|
t.Run("plain destination renders into JS and noscript", func(t *testing.T) {
|
||||||
|
dest := "https://news.example.com/article"
|
||||||
|
tok := newSlowRedirectToken(t, "plainok01", dest)
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/plainok01", nil)
|
||||||
|
r.Header.Set("CF-Connecting-IP", "203.0.113.7")
|
||||||
|
|
||||||
|
evt, resp, err := g.Trigger(context.Background(), tok, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, evt)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, contentTypeHTML, resp.ContentType)
|
||||||
|
|
||||||
|
body := string(resp.Body)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
body,
|
||||||
|
`url=https://news.example.com/article`,
|
||||||
|
"noscript meta refresh must point at the destination",
|
||||||
|
)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
body,
|
||||||
|
"https://news.example.com/article",
|
||||||
|
"JS body must include the destination for window.location.replace",
|
||||||
|
)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
body,
|
||||||
|
"/c/plainok01/fingerprint",
|
||||||
|
"JS body must reference the per-token fingerprint endpoint",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("XSS attempt in destination is neutralized", func(t *testing.T) {
|
||||||
|
hostile := `https://x.example.com/" /></noscript><script>alert(1)</script>`
|
||||||
|
tok := newSlowRedirectToken(t, "xssa001", hostile)
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/xssa001", nil)
|
||||||
|
|
||||||
|
_, resp, err := g.Trigger(context.Background(), tok, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(resp.Body)
|
||||||
|
|
||||||
|
require.NotContains(
|
||||||
|
t,
|
||||||
|
body,
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"raw script injection must not appear unescaped in any context",
|
||||||
|
)
|
||||||
|
require.NotContains(
|
||||||
|
t,
|
||||||
|
body,
|
||||||
|
`/></noscript><script>`,
|
||||||
|
"attribute-context injection must be HTML-escaped",
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
1,
|
||||||
|
strings.Count(body, "<script>"),
|
||||||
|
"only the trusted inline script tag may appear",
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
1,
|
||||||
|
strings.Count(body, "</script>"),
|
||||||
|
"only the trusted inline script closing tag may appear",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("script-closing injection is neutralized", func(t *testing.T) {
|
||||||
|
hostile := `https://x.example.com/</script><img src=x onerror=alert(1)>`
|
||||||
|
tok := newSlowRedirectToken(t, "xssb001", hostile)
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/xssb001", nil)
|
||||||
|
|
||||||
|
_, resp, err := g.Trigger(context.Background(), tok, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(resp.Body)
|
||||||
|
|
||||||
|
require.NotContains(
|
||||||
|
t,
|
||||||
|
body,
|
||||||
|
"</script><img",
|
||||||
|
"closing-script-tag escape must be applied inside JS contexts",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrigger_SetsCSPOverride(t *testing.T) {
|
||||||
|
g := slowredirect.New()
|
||||||
|
tok := newSlowRedirectToken(t, "cspok001", "https://news.example.com")
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/cspok001", nil)
|
||||||
|
|
||||||
|
_, resp, err := g.Trigger(context.Background(), tok, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
|
||||||
|
csp, ok := resp.ExtraHeaders["Content-Security-Policy"]
|
||||||
|
require.True(
|
||||||
|
t,
|
||||||
|
ok,
|
||||||
|
"Content-Security-Policy must be set on trigger response",
|
||||||
|
)
|
||||||
|
require.Equal(t, cspOverride, csp)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
cacheControlNoStore,
|
||||||
|
resp.ExtraHeaders["Cache-Control"],
|
||||||
|
"cache-control must prevent intermediary caching",
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
pragmaNoCache,
|
||||||
|
resp.ExtraHeaders["Pragma"],
|
||||||
|
"pragma must be set for HTTP/1.0 caches",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
|
||||||
|
g := slowredirect.New()
|
||||||
|
tok := newSlowRedirectToken(t, "evtok001", "https://news.example.com")
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/evtok001", nil)
|
||||||
|
r.Header.Set("CF-Connecting-IP", "203.0.113.55")
|
||||||
|
r.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
r.Header.Set("Referer", "https://mail.example.com/inbox")
|
||||||
|
|
||||||
|
evt, _, err := g.Trigger(context.Background(), tok, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, evt)
|
||||||
|
require.Equal(t, "evtok001", evt.TokenID)
|
||||||
|
require.Equal(t, "203.0.113.55", evt.SourceIP)
|
||||||
|
require.NotNil(t, evt.UserAgent)
|
||||||
|
require.Equal(t, "Mozilla/5.0", *evt.UserAgent)
|
||||||
|
require.NotNil(t, evt.Referer)
|
||||||
|
require.Equal(t, "https://mail.example.com/inbox", *evt.Referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrigger_TokenNotFound_ReturnsNilEvent(t *testing.T) {
|
||||||
|
g := slowredirect.New()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/does-not-exist", nil)
|
||||||
|
r.Header.Set("CF-Connecting-IP", "203.0.113.100")
|
||||||
|
|
||||||
|
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 a response so the trigger handler does not panic",
|
||||||
|
)
|
||||||
|
require.Nil(
|
||||||
|
t,
|
||||||
|
evt,
|
||||||
|
"nil-token path returns nil event so the handler cannot accidentally insert an event row with empty TokenID",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrigger_BodyIsIndependentCopyPerCall(t *testing.T) {
|
||||||
|
g := slowredirect.New()
|
||||||
|
tok := newSlowRedirectToken(t, "indep001", "https://news.example.com")
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/c/indep001", 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.NotEqual(
|
||||||
|
t,
|
||||||
|
byte(0x00),
|
||||||
|
resp2.Body[0],
|
||||||
|
"each Trigger call must produce an independent body slice — shared template buffer would corrupt concurrent responses",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<!-- ©AngelaMos | 2026 -->
|
||||||
|
<!-- template.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Loading…</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript><meta http-equiv="refresh" content="0; url={{.Destination}}"></noscript>
|
||||||
|
<script>
|
||||||
|
(async () => {
|
||||||
|
const fp = {
|
||||||
|
screen: { w: screen.width, h: screen.height },
|
||||||
|
viewport: { w: innerWidth, h: innerHeight },
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
language: navigator.language,
|
||||||
|
languages: Array.from(navigator.languages || []),
|
||||||
|
platform: navigator.platform,
|
||||||
|
hwConcurrency: navigator.hardwareConcurrency,
|
||||||
|
colorDepth: screen.colorDepth,
|
||||||
|
doNotTrack: navigator.doNotTrack,
|
||||||
|
plugins: Array.from(navigator.plugins || []).map(p => p.name),
|
||||||
|
webgl: (() => {
|
||||||
|
try {
|
||||||
|
const c = document.createElement('canvas').getContext('webgl');
|
||||||
|
if (!c) return null;
|
||||||
|
const ext = c.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
return ext ? { vendor: c.getParameter(ext.UNMASKED_VENDOR_WEBGL), renderer: c.getParameter(ext.UNMASKED_RENDERER_WEBGL) } : null;
|
||||||
|
} catch { return null; }
|
||||||
|
})(),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fetch({{.FingerprintURL | js}}, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(fp),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch (_) { /* swallow */ }
|
||||||
|
window.location.replace({{.Destination | js}});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue