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