diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator.go new file mode 100644 index 00000000..931e3a79 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator.go @@ -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 "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator_test.go new file mode 100644 index 00000000..78ceb0a3 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator_test.go @@ -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/" />` + 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, + "", + "raw script injection must not appear unescaped in any context", + ) + require.NotContains( + t, + body, + `/>"), + "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/` + 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, + " + + + +Loading… + + + + + + +