Skip to content

Commit 7280217

Browse files
yosriadyclaude
andauthored
fix(security): sanitize traffic-source values against scanner-injected payloads (#307)
* fix(security): sanitize traffic-source values against scanner-injected payloads Vulnerability scanners (Acunetix and friends) crawl customer sites injecting XSS probes like javascript:domxssExecutionSink(...) and <script>alert(1)</script> into every query parameter. The SDK captured those verbatim as utm_*/click-id/ref values, persisted them as sticky session traffic sources, and polluted attribution reporting. Validate each field class with the tightest rule its legitimate production values allow: - click IDs: strict token allowlist ^[A-Za-z0-9._-]{1,255}$ - ref: strict token allowlist ^[A-Za-z0-9._-]{1,64}$ (>99.5% of production values conform; the rest are scanner payloads, mangled encodings, or URLs glued to codes) - utm_*: free-form, but reject markup/quote chars, dangerous scheme prefixes, control/zero-width/replacement chars, and >255 chars Sanitization runs on both the fresh URL extraction and the stored session replay, so values poisoned by a pre-fix SDK are flushed too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: expand traffic-source sanitization coverage Adds empty-input and whitespace cases per sanitizer, non-ASCII ref rejection, tab/newline/RTL-override/BOM UTM cases, and integration coverage for referral.pathPattern / custom referral.queryParams extraction plus the stored-value fallback when a fresh value is poisoned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e109171 commit 7280217

3 files changed

Lines changed: 517 additions & 5 deletions

File tree

src/event/EventFactory.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
VERSION,
3636
} from "./constants";
3737
import { IEventFactory } from "./type";
38+
import { sanitizeTrafficSources } from "./sanitize";
3839
import { generateAnonymousId } from "./utils";
3940
import { detectBrowser } from "../browser/browsers";
4041

@@ -268,18 +269,24 @@ class EventFactory implements IEventFactory {
268269

269270
private getTrafficSources = (url: string): ITrafficSource => {
270271
const urlObj = new URL(url);
271-
const contextTrafficSources: ITrafficSource = {
272+
// Sanitize at the source so scanner-injected garbage (XSS probes in
273+
// utm_*/click-id/ref query params) never wins the context-over-stored
274+
// merge below, never gets persisted, and never reaches an event.
275+
const contextTrafficSources: ITrafficSource = sanitizeTrafficSources({
272276
...this.extractUTMParameters(url),
273277
...this.extractClickIdParameters(urlObj),
274278
ref: this.extractReferralParameter(urlObj),
275279
referrer: this.getExternalReferrer(),
276-
};
280+
});
277281
// Sticky traffic sources may have been persisted by an older SDK version or
278282
// a looser config, before the current excludeQueryParams was in effect.
279283
// Honor the current denylist on the way out so excluded values can never
280-
// resurface from session storage (or get re-persisted below).
281-
const storedTrafficSources = this.redactStoredTrafficSources(
282-
(session().get(SESSION_TRAFFIC_SOURCE_KEY) as ITrafficSource) || {}
284+
// resurface from session storage (or get re-persisted below). Sanitizing
285+
// here too flushes poisoned values persisted by a pre-sanitization SDK.
286+
const storedTrafficSources = sanitizeTrafficSources(
287+
this.redactStoredTrafficSources(
288+
(session().get(SESSION_TRAFFIC_SOURCE_KEY) as ITrafficSource) || {}
289+
)
283290
);
284291

285292
const mergedClickIds = {} as ClickIdParameters;

src/event/sanitize.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { ITrafficSource } from "../types";
2+
import { CLICK_ID_PARAMS } from "./constants";
3+
4+
/**
5+
* Traffic-source value sanitization.
6+
*
7+
* Vulnerability scanners (e.g. Acunetix) crawl customer sites injecting XSS
8+
* probes such as `javascript:domxssExecutionSink(1,"'\"><xsstag>()locxss")`
9+
* or `<script>alert(1)</script>` into every query parameter. Without
10+
* validation those payloads are captured verbatim as utm_* / click-id / ref
11+
* values, persisted as sticky session traffic sources, and pollute the
12+
* customer's attribution reporting. Each field class gets the tightest rule
13+
* its legitimate values allow (verified against production data):
14+
*
15+
* - Click IDs are opaque platform-generated tokens (base64url-ish); every
16+
* legitimate production value matches the strict token pattern.
17+
* - Referral codes are short tokens; >99.5% of production values match the
18+
* strict pattern and none of the remainder are legitimate (scanner
19+
* payloads, mangled encodings, URLs glued to codes).
20+
* - UTM values are free-form (spaces, unicode, `+` are legitimate), so they
21+
* only reject markup/quote characters, dangerous URL schemes, control and
22+
* zero-width characters, and absurd lengths.
23+
*
24+
* Invalid values are dropped to "" — the same representation as "parameter
25+
* absent" — rather than repaired, so a poisoned value can never be persisted
26+
* or reported.
27+
*/
28+
29+
const CLICK_ID_PATTERN = /^[A-Za-z0-9._-]{1,255}$/;
30+
31+
const REF_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
32+
33+
const UTM_MAX_LENGTH = 255;
34+
35+
// Markup/quote/backslash characters plus C0/C1 control characters and
36+
// zero-width / bidi / BOM / replacement characters (mangled-encoding
37+
// markers). Explicit ranges instead of \p{C} to avoid the `u`-flag
38+
// property-escape requirement.
39+
const UTM_FORBIDDEN_CHARS =
40+
/[<>"'`\\\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060\ufeff\ufffd]/;
41+
42+
// Values smuggling an executable/URL scheme, e.g. `javascript:alert(1)`.
43+
const FORBIDDEN_SCHEME_PREFIX = /^\s*(javascript|data|vbscript):/i;
44+
45+
const sanitizeClickId = (value: string): string =>
46+
CLICK_ID_PATTERN.test(value) ? value : "";
47+
48+
const sanitizeRef = (value: string): string =>
49+
REF_PATTERN.test(value) ? value : "";
50+
51+
const sanitizeUtm = (value: string): string =>
52+
value.length <= UTM_MAX_LENGTH &&
53+
!UTM_FORBIDDEN_CHARS.test(value) &&
54+
!FORBIDDEN_SCHEME_PREFIX.test(value)
55+
? value
56+
: "";
57+
58+
const CLICK_ID_KEYS: ReadonlySet<string> = new Set(CLICK_ID_PARAMS);
59+
60+
/**
61+
* Sanitize every traffic-source field of a (possibly sparse) traffic-source
62+
* object. `referrer` is left untouched: it is a browser-set URL already
63+
* handled by redactUrl, not an attacker-controlled query parameter. Unknown
64+
* keys fall through to the UTM rule, the most permissive one.
65+
*/
66+
const sanitizeTrafficSources = <T extends Partial<ITrafficSource>>(
67+
trafficSources: T
68+
): T => {
69+
const result: Record<string, unknown> = { ...trafficSources };
70+
for (const key of Object.keys(result)) {
71+
const value = result[key];
72+
if (typeof value !== "string" || value === "" || key === "referrer") {
73+
continue;
74+
}
75+
if (CLICK_ID_KEYS.has(key)) {
76+
result[key] = sanitizeClickId(value);
77+
} else if (key === "ref") {
78+
result[key] = sanitizeRef(value);
79+
} else {
80+
result[key] = sanitizeUtm(value);
81+
}
82+
}
83+
return result as T;
84+
};
85+
86+
export { sanitizeClickId, sanitizeRef, sanitizeUtm, sanitizeTrafficSources };

0 commit comments

Comments
 (0)