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"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
@ -18,9 +19,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fingerprintWindow = 30 * time.Second
|
fingerprintWindow = 30 * time.Second
|
||||||
fingerprintMaxBytes = 64 * 1024
|
fingerprintMaxBytes = 64 * 1024
|
||||||
urlParamTokenID = "id"
|
urlParamTokenID = "id"
|
||||||
|
headerContentType = "Content-Type"
|
||||||
|
contentTypeJSONPrefix = "application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FingerprintAttacher interface {
|
type FingerprintAttacher interface {
|
||||||
|
|
@ -53,6 +56,14 @@ func (h *FingerprintHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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))
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, fingerprintMaxBytes))
|
||||||
if err != nil || len(body) == 0 || !json.Valid(body) {
|
if err != nil || len(body) == 0 || !json.Valid(body) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
@ -27,6 +28,7 @@ import (
|
||||||
const (
|
const (
|
||||||
fingerprintRoute = "/c/{id}/fingerprint"
|
fingerprintRoute = "/c/{id}/fingerprint"
|
||||||
clientIP = "203.0.113.45"
|
clientIP = "203.0.113.45"
|
||||||
|
jsonContentType = "application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSlowredirectRepos(
|
func newSlowredirectRepos(
|
||||||
|
|
@ -43,9 +45,10 @@ func seedSlowredirectToken(
|
||||||
id, destination string,
|
id, destination string,
|
||||||
) *token.Token {
|
) *token.Token {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
metaJSON, _ := json.Marshal(map[string]string{
|
metaJSON, err := json.Marshal(map[string]string{
|
||||||
"destination_url": destination,
|
"destination_url": destination,
|
||||||
})
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
tok := &token.Token{
|
tok := &token.Token{
|
||||||
ID: id,
|
ID: id,
|
||||||
ManageID: uuid.New().String(),
|
ManageID: uuid.New().String(),
|
||||||
|
|
@ -62,39 +65,61 @@ func seedSlowredirectToken(
|
||||||
return tok
|
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 {
|
func mountFingerprintRouter(h http.Handler) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Post(fingerprintRoute, h.ServeHTTP)
|
r.Post(fingerprintRoute, h.ServeHTTP)
|
||||||
return r
|
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) {
|
func TestFingerprintHandler_AttachesToRecentEvent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tokRepo, evtRepo := newSlowredirectRepos(t)
|
tokRepo, evtRepo := newSlowredirectRepos(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tok := seedSlowredirectToken(t, tokRepo, "fpattach0001", "https://news.example.com")
|
tok := seedSlowredirectToken(t, tokRepo, "fpattach0001",
|
||||||
evt := &event.Event{
|
"https://news.example.com")
|
||||||
TokenID: tok.ID,
|
evt := seedSlowredirectEvent(t, evtRepo, tok.ID)
|
||||||
SourceIP: clientIP,
|
|
||||||
Extra: json.RawMessage(`{"initial":"value"}`),
|
|
||||||
}
|
|
||||||
require.NoError(t, evtRepo.Insert(ctx, evt))
|
|
||||||
|
|
||||||
handler := slowredirect.NewFingerprintHandler(evtRepo)
|
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
|
||||||
router := mountFingerprintRouter(handler)
|
|
||||||
|
|
||||||
fpBody := []byte(`{"screen":{"w":1920,"h":1080},"timezone":"America/Los_Angeles"}`)
|
fpBody := []byte(
|
||||||
req := httptest.NewRequest(
|
`{"screen":{"w":1920,"h":1080},"timezone":"America/Los_Angeles"}`,
|
||||||
http.MethodPost,
|
|
||||||
"/c/"+tok.ID+"/fingerprint",
|
|
||||||
bytes.NewReader(fpBody),
|
|
||||||
)
|
)
|
||||||
req.Header.Set("CF-Connecting-IP", clientIP)
|
rr := postFingerprint(router, tok.ID, jsonContentType, fpBody)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusNoContent, rr.Code)
|
require.Equal(t, http.StatusNoContent, rr.Code)
|
||||||
|
|
||||||
got, err := evtRepo.GetByID(ctx, evt.ID)
|
got, err := evtRepo.GetByID(ctx, evt.ID)
|
||||||
|
|
@ -114,21 +139,15 @@ func TestFingerprintHandler_NoMatchingEvent_Returns204(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tokRepo, evtRepo := newSlowredirectRepos(t)
|
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(slowredirect.NewFingerprintHandler(evtRepo))
|
||||||
router := mountFingerprintRouter(handler)
|
|
||||||
|
|
||||||
fpBody := []byte(`{"screen":{"w":800,"h":600}}`)
|
rr := postFingerprint(
|
||||||
req := httptest.NewRequest(
|
router, tok.ID, jsonContentType,
|
||||||
http.MethodPost,
|
[]byte(`{"screen":{"w":800,"h":600}}`),
|
||||||
"/c/"+tok.ID+"/fingerprint",
|
|
||||||
bytes.NewReader(fpBody),
|
|
||||||
)
|
)
|
||||||
req.Header.Set("CF-Connecting-IP", clientIP)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
require.Equal(
|
require.Equal(
|
||||||
t,
|
t,
|
||||||
http.StatusNoContent,
|
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()
|
t.Parallel()
|
||||||
tokRepo, evtRepo := newSlowredirectRepos(t)
|
tokRepo, evtRepo := newSlowredirectRepos(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tok := seedSlowredirectToken(t, tokRepo, "fpinvjson001", "https://news.example.com")
|
tok := seedSlowredirectToken(t, tokRepo, "fpinvjson001",
|
||||||
evt := &event.Event{
|
"https://news.example.com")
|
||||||
TokenID: tok.ID,
|
evt := seedSlowredirectEvent(t, evtRepo, tok.ID)
|
||||||
SourceIP: clientIP,
|
|
||||||
Extra: json.RawMessage(`{"initial":"value"}`),
|
|
||||||
}
|
|
||||||
require.NoError(t, evtRepo.Insert(ctx, evt))
|
|
||||||
|
|
||||||
handler := slowredirect.NewFingerprintHandler(evtRepo)
|
router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo))
|
||||||
router := mountFingerprintRouter(handler)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(
|
rr := postFingerprint(
|
||||||
http.MethodPost,
|
router, tok.ID, jsonContentType,
|
||||||
"/c/"+tok.ID+"/fingerprint",
|
[]byte("not-json-at-all"),
|
||||||
bytes.NewReader([]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)
|
require.Equal(t, http.StatusNoContent, rr.Code)
|
||||||
|
|
||||||
got, err := evtRepo.GetByID(ctx, evt.ID)
|
got, err := evtRepo.GetByID(ctx, evt.ID)
|
||||||
|
|
@ -175,6 +186,108 @@ func TestFingerprintHandler_InvalidJSON_Returns204AndDoesNotTouchEvent(t *testin
|
||||||
stored["initial"],
|
stored["initial"],
|
||||||
"invalid JSON must not corrupt the stored Extra JSONB",
|
"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"
|
fingerprintPathSuffix = "/fingerprint"
|
||||||
metadataDestKey = "destination_url"
|
metadataDestKey = "destination_url"
|
||||||
|
|
||||||
nilTokenBody = "Not Found"
|
schemeHTTP = "http://"
|
||||||
|
schemeHTTPS = "https://"
|
||||||
|
|
||||||
|
enumerationDecoyDestination = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrMissingDestination = errors.New(
|
var (
|
||||||
"slowredirect: destination_url missing from token metadata",
|
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
|
//go:embed template.html
|
||||||
|
|
@ -86,15 +94,11 @@ func (g *Generator) Trigger(
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (*event.Event, *generators.TriggerResponse, error) {
|
) (*event.Event, *generators.TriggerResponse, error) {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return nil, &generators.TriggerResponse{
|
resp, err := renderDecoyResponse()
|
||||||
StatusCode: http.StatusNotFound,
|
if err != nil {
|
||||||
ContentType: contentTypeHTML,
|
return nil, nil, err
|
||||||
Body: []byte(nilTokenBody),
|
}
|
||||||
ExtraHeaders: map[string]string{
|
return nil, resp, nil
|
||||||
headerCacheControl: cacheControlNoStore,
|
|
||||||
headerPragma: pragmaNoCache,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dest, err := extractDestination(t.Metadata)
|
dest, err := extractDestination(t.Metadata)
|
||||||
|
|
@ -102,24 +106,9 @@ func (g *Generator) Trigger(
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var body bytes.Buffer
|
resp, err := renderResponse(dest, t.ID)
|
||||||
data := pageData{
|
if err != nil {
|
||||||
Destination: dest,
|
return nil, nil, err
|
||||||
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{
|
evt := &event.Event{
|
||||||
|
|
@ -131,6 +120,41 @@ func (g *Generator) Trigger(
|
||||||
return evt, resp, nil
|
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) {
|
func extractDestination(metadata json.RawMessage) (string, error) {
|
||||||
if len(metadata) == 0 {
|
if len(metadata) == 0 {
|
||||||
return "", ErrMissingDestination
|
return "", ErrMissingDestination
|
||||||
|
|
@ -147,9 +171,15 @@ func extractDestination(metadata json.RawMessage) (string, error) {
|
||||||
if err := json.Unmarshal(raw, &dest); err != nil {
|
if err := json.Unmarshal(raw, &dest); err != nil {
|
||||||
return "", fmt.Errorf("parse destination_url: %w", err)
|
return "", fmt.Errorf("parse destination_url: %w", err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(dest) == "" {
|
dest = strings.TrimSpace(dest)
|
||||||
|
if dest == "" {
|
||||||
return "", ErrMissingDestination
|
return "", ErrMissingDestination
|
||||||
}
|
}
|
||||||
|
lower := strings.ToLower(dest)
|
||||||
|
if !strings.HasPrefix(lower, schemeHTTP) &&
|
||||||
|
!strings.HasPrefix(lower, schemeHTTPS) {
|
||||||
|
return "", ErrInvalidDestinationScheme
|
||||||
|
}
|
||||||
return dest, nil
|
return dest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,26 +109,32 @@ func TestGenerate_RejectsMissingDestination(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
metadata json.RawMessage
|
metadata json.RawMessage
|
||||||
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty metadata",
|
name: "empty metadata",
|
||||||
metadata: json.RawMessage(``),
|
metadata: json.RawMessage(``),
|
||||||
|
wantErr: slowredirect.ErrMissingDestination,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty object",
|
name: "empty object",
|
||||||
metadata: json.RawMessage(`{}`),
|
metadata: json.RawMessage(`{}`),
|
||||||
|
wantErr: slowredirect.ErrMissingDestination,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty destination string",
|
name: "empty destination string",
|
||||||
metadata: json.RawMessage(`{"destination_url":""}`),
|
metadata: json.RawMessage(`{"destination_url":""}`),
|
||||||
|
wantErr: slowredirect.ErrMissingDestination,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whitespace destination",
|
name: "whitespace destination",
|
||||||
metadata: json.RawMessage(`{"destination_url":" "}`),
|
metadata: json.RawMessage(`{"destination_url":" "}`),
|
||||||
|
wantErr: slowredirect.ErrMissingDestination,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wrong key",
|
name: "wrong key",
|
||||||
metadata: json.RawMessage(`{"redirect":"https://example.com"}`),
|
metadata: json.RawMessage(`{"redirect":"https://example.com"}`),
|
||||||
|
wantErr: slowredirect.ErrMissingDestination,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,7 +152,72 @@ func TestGenerate_RejectsMissingDestination(t *testing.T) {
|
||||||
tok,
|
tok,
|
||||||
"https://canary.example.com",
|
"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) {
|
func TestTrigger_SetsCSPOverride(t *testing.T) {
|
||||||
g := slowredirect.New()
|
g := slowredirect.New()
|
||||||
tok := newSlowRedirectToken(t, "cspok001", "https://news.example.com")
|
tok := newSlowRedirectToken(t, "cspok001", "https://news.example.com")
|
||||||
|
|
@ -301,16 +409,52 @@ func TestTrigger_TokenNotFound_ReturnsNilEvent(t *testing.T) {
|
||||||
err,
|
err,
|
||||||
"nil-token path must not error (spec §8.5 defense-in-depth)",
|
"nil-token path must not error (spec §8.5 defense-in-depth)",
|
||||||
)
|
)
|
||||||
require.NotNil(
|
require.NotNil(t, resp)
|
||||||
t,
|
|
||||||
resp,
|
|
||||||
"nil-token path must still return a response so the trigger handler does not panic",
|
|
||||||
)
|
|
||||||
require.Nil(
|
require.Nil(
|
||||||
t,
|
t,
|
||||||
evt,
|
evt,
|
||||||
"nil-token path returns nil event so the handler cannot accidentally insert an event row with empty TokenID",
|
"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) {
|
func TestTrigger_BodyIsIndependentCopyPerCall(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,14 @@
|
||||||
referrer: document.referrer || null
|
referrer: document.referrer || null
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await fetch({{.FingerprintURL | js}}, {
|
await fetch({{.FingerprintURL}}, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(fp),
|
body: JSON.stringify(fp),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
keepalive: true
|
keepalive: true
|
||||||
});
|
});
|
||||||
} catch (_) { /* swallow */ }
|
} catch (_) { /* swallow */ }
|
||||||
window.location.replace({{.Destination | js}});
|
window.location.replace({{.Destination}});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue