diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go index df36075c..afc44cd0 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go @@ -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) diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go index 33899173..95e154ef 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go @@ -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", + ) } 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 index 931e3a79..df41807a 100644 --- 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 @@ -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 } 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 index 78ceb0a3..0dfcad35 100644 --- 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 @@ -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), + "