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:
parent
be13d2f1c4
commit
a46fa4465e
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue