@formo/analytics-react-native 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/lib/commonjs/lib/event/EventQueue.js +367 -57
  2. package/lib/commonjs/lib/event/EventQueue.js.map +1 -1
  3. package/lib/commonjs/utils/sanitize.js +260 -0
  4. package/lib/commonjs/utils/sanitize.js.map +1 -0
  5. package/lib/commonjs/utils/trafficSource.js +10 -1
  6. package/lib/commonjs/utils/trafficSource.js.map +1 -1
  7. package/lib/commonjs/version.js +1 -1
  8. package/lib/module/lib/event/EventQueue.js +368 -57
  9. package/lib/module/lib/event/EventQueue.js.map +1 -1
  10. package/lib/module/utils/sanitize.js +253 -0
  11. package/lib/module/utils/sanitize.js.map +1 -0
  12. package/lib/module/utils/trafficSource.js +10 -1
  13. package/lib/module/utils/trafficSource.js.map +1 -1
  14. package/lib/module/version.js +1 -1
  15. package/lib/typescript/commonjs/lib/event/EventQueue.d.ts +55 -0
  16. package/lib/typescript/commonjs/lib/event/EventQueue.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/utils/sanitize.d.ts +31 -0
  18. package/lib/typescript/commonjs/utils/sanitize.d.ts.map +1 -0
  19. package/lib/typescript/commonjs/utils/trafficSource.d.ts +3 -0
  20. package/lib/typescript/commonjs/utils/trafficSource.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/version.d.ts +1 -1
  22. package/lib/typescript/module/FormoAnalytics.d.ts +2 -2
  23. package/lib/typescript/module/FormoAnalytics.d.ts.map +1 -1
  24. package/lib/typescript/module/FormoAnalyticsProvider.d.ts +1 -1
  25. package/lib/typescript/module/FormoAnalyticsProvider.d.ts.map +1 -1
  26. package/lib/typescript/module/constants/index.d.ts +3 -3
  27. package/lib/typescript/module/constants/index.d.ts.map +1 -1
  28. package/lib/typescript/module/index.d.ts +5 -5
  29. package/lib/typescript/module/index.d.ts.map +1 -1
  30. package/lib/typescript/module/lib/event/EventFactory.d.ts +2 -2
  31. package/lib/typescript/module/lib/event/EventFactory.d.ts.map +1 -1
  32. package/lib/typescript/module/lib/event/EventManager.d.ts +2 -2
  33. package/lib/typescript/module/lib/event/EventManager.d.ts.map +1 -1
  34. package/lib/typescript/module/lib/event/EventQueue.d.ts +57 -2
  35. package/lib/typescript/module/lib/event/EventQueue.d.ts.map +1 -1
  36. package/lib/typescript/module/lib/event/index.d.ts +4 -4
  37. package/lib/typescript/module/lib/event/index.d.ts.map +1 -1
  38. package/lib/typescript/module/lib/event/types.d.ts +1 -1
  39. package/lib/typescript/module/lib/event/types.d.ts.map +1 -1
  40. package/lib/typescript/module/lib/session/index.d.ts +1 -1
  41. package/lib/typescript/module/lib/session/index.d.ts.map +1 -1
  42. package/lib/typescript/module/lib/storage/AsyncStorageAdapter.d.ts +2 -2
  43. package/lib/typescript/module/lib/storage/AsyncStorageAdapter.d.ts.map +1 -1
  44. package/lib/typescript/module/lib/storage/MemoryStorage.d.ts +1 -1
  45. package/lib/typescript/module/lib/storage/MemoryStorage.d.ts.map +1 -1
  46. package/lib/typescript/module/lib/storage/StorageBlueprint.d.ts +1 -1
  47. package/lib/typescript/module/lib/storage/StorageBlueprint.d.ts.map +1 -1
  48. package/lib/typescript/module/lib/storage/StorageManager.d.ts +1 -1
  49. package/lib/typescript/module/lib/storage/StorageManager.d.ts.map +1 -1
  50. package/lib/typescript/module/lib/storage/index.d.ts +4 -4
  51. package/lib/typescript/module/lib/storage/index.d.ts.map +1 -1
  52. package/lib/typescript/module/lib/wagmi/WagmiEventHandler.d.ts +2 -2
  53. package/lib/typescript/module/lib/wagmi/WagmiEventHandler.d.ts.map +1 -1
  54. package/lib/typescript/module/lib/wagmi/index.d.ts +2 -2
  55. package/lib/typescript/module/lib/wagmi/index.d.ts.map +1 -1
  56. package/lib/typescript/module/solana/address.d.ts +1 -1
  57. package/lib/typescript/module/solana/address.d.ts.map +1 -1
  58. package/lib/typescript/module/solana/index.d.ts +2 -2
  59. package/lib/typescript/module/solana/index.d.ts.map +1 -1
  60. package/lib/typescript/module/types/base.d.ts +3 -3
  61. package/lib/typescript/module/types/base.d.ts.map +1 -1
  62. package/lib/typescript/module/types/events.d.ts +2 -2
  63. package/lib/typescript/module/types/events.d.ts.map +1 -1
  64. package/lib/typescript/module/types/index.d.ts +2 -2
  65. package/lib/typescript/module/types/index.d.ts.map +1 -1
  66. package/lib/typescript/module/utils/index.d.ts +5 -5
  67. package/lib/typescript/module/utils/index.d.ts.map +1 -1
  68. package/lib/typescript/module/utils/sanitize.d.ts +31 -0
  69. package/lib/typescript/module/utils/sanitize.d.ts.map +1 -0
  70. package/lib/typescript/module/utils/trafficSource.d.ts +4 -1
  71. package/lib/typescript/module/utils/trafficSource.d.ts.map +1 -1
  72. package/lib/typescript/module/version.d.ts +1 -1
  73. package/package.json +2 -2
  74. package/src/lib/event/EventQueue.ts +402 -61
  75. package/src/utils/sanitize.ts +274 -0
  76. package/src/utils/trafficSource.ts +19 -1
  77. package/src/version.ts +1 -1
@@ -0,0 +1,274 @@
1
+ import type { ITrafficSource } from "../types";
2
+
3
+ /**
4
+ * Traffic-source value sanitization.
5
+ *
6
+ * Ported from the Formo web SDK, where vulnerability scanners (e.g. Acunetix)
7
+ * crawling customer sites injected XSS probes such as
8
+ * `javascript:domxssExecutionSink(1,"'\"><xsstag>()locxss")` or
9
+ * `<script>alert(1)</script>` into every query parameter. Without validation
10
+ * those payloads are captured verbatim as utm_* / ref values, persisted as
11
+ * sticky session traffic sources, and pollute attribution reporting.
12
+ *
13
+ * React Native has the same exposure through two attacker-reachable inputs:
14
+ *
15
+ * - Deep links handed to `setTrafficSourceFromUrl`. Anyone who can get a user
16
+ * to open `myapp://x?utm_source=<script>alert(1)</script>` controls the
17
+ * value verbatim, and it is persisted for the whole session.
18
+ * - The Android Play Install Referrer string, which is derived from the
19
+ * `referrer` parameter of a Play Store URL and is likewise attacker-supplied.
20
+ *
21
+ * Each field class gets the tightest rule its legitimate values allow:
22
+ *
23
+ * - Referral codes are short tokens; >99.5% of production values match the
24
+ * strict pattern and none of the remainder are legitimate (scanner
25
+ * payloads, mangled encodings, URLs glued to codes).
26
+ * - UTM values are free-form (spaces, unicode, `+` are legitimate), so they
27
+ * only reject markup/quote characters, dangerous URL schemes, control and
28
+ * zero-width characters, and absurd lengths.
29
+ * - `referrer` diverges from the web SDK, which leaves it untouched because
30
+ * there it is a browser-set `document.referrer` already handled by redactUrl.
31
+ * In React Native `referrer` holds the raw deep-link URL the attacker
32
+ * supplied, so sanitizing only the utm_ and ref fields would still let the
33
+ * payload through. It gets a URL-sized length budget and is checked both raw
34
+ * and percent-decoded — a URL encodes its payload, so a raw-only check would
35
+ * pass `?utm_source=%3Cscript%3E` straight through.
36
+ *
37
+ * Invalid values are dropped to "" — the same representation as "parameter
38
+ * absent" — rather than repaired, so a poisoned value can never be persisted
39
+ * or reported.
40
+ *
41
+ * The web SDK additionally sanitizes ad-platform click IDs (gclid, fbclid,
42
+ * ...). This SDK does not capture them, so that rule is intentionally absent;
43
+ * add it here alongside the capture if click IDs are ever supported.
44
+ */
45
+
46
+ const REF_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
47
+
48
+ const UTM_MAX_LENGTH = 255;
49
+
50
+ // URLs are legitimately much longer than a UTM value, but not unbounded —
51
+ // this is well above any real deep link and still bounds what gets persisted.
52
+ const REFERRER_MAX_LENGTH = 2_048;
53
+
54
+ // Markup/quote/backslash characters plus C0/C1 control characters and
55
+ // zero-width / bidi / BOM / replacement characters (mangled-encoding
56
+ // markers). Explicit ranges instead of \p{C} to avoid the `u`-flag
57
+ // property-escape requirement.
58
+ const FORBIDDEN_CHARS =
59
+ /[<>"'`\\\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060\ufeff\ufffd]/;
60
+
61
+ // Applied to the percent-DECODED form of a referrer. Narrower than the raw
62
+ // set on purpose: a decoded query value legitimately contains quotes and
63
+ // backslashes (`?q=%22running%20shoes%22`), and those are harmless in an
64
+ // analytics field. Markup and invisible characters are what indicate an
65
+ // injected payload rather than a real deep link.
66
+ const DECODED_FORBIDDEN_CHARS =
67
+ /[<>\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060\ufeff\ufffd]/;
68
+
69
+ // Values smuggling an executable/URL scheme, e.g. `javascript:alert(1)`.
70
+ const FORBIDDEN_SCHEME_PREFIX = /^\s*(javascript|data|vbscript):/i;
71
+
72
+ /**
73
+ * Whether a value smuggles a dangerous scheme, anywhere a URL parser would
74
+ * consider the start of a value.
75
+ *
76
+ * The pattern is anchored, so testing the string as a whole is not enough once
77
+ * decoding can reveal separators that were themselves encoded: with the `=` in
78
+ * `?utm_source%3Djavascript%253Aalert(1)` encoded, URLSearchParams reports one
79
+ * key that decodes to `utm_source=javascript:alert(1)`, which the anchored test
80
+ * never matches. Splitting on the separators after decoding puts the payload
81
+ * back at the start of a segment.
82
+ */
83
+ const hasForbiddenScheme = (value: string): boolean =>
84
+ FORBIDDEN_SCHEME_PREFIX.test(value) ||
85
+ value.split(/[?&=#]/).some((segment) => FORBIDDEN_SCHEME_PREFIX.test(segment));
86
+
87
+ /**
88
+ * Percent-decode one layer without ever throwing. `decodeURIComponent` rejects
89
+ * the entire string on a single malformed escape (a stray `%`), which would let
90
+ * `...%3Cscript%3E%` skip the decoded checks completely.
91
+ *
92
+ * Each *run* of consecutive escapes is decoded as a unit rather than byte by
93
+ * byte, because a multi-byte character spans several escapes: `%E3%80%80` is
94
+ * one ideographic space, and decoding `%E3` alone throws. Byte-wise decoding
95
+ * would therefore never produce the whitespace that lets `^\s*javascript:`
96
+ * match `%E3%80%80javascript%3Aalert(1)`. A run that is not valid UTF-8, and
97
+ * any stray `%`, is left as-is.
98
+ */
99
+ const decodeRun = (run: string): string => {
100
+ try {
101
+ return decodeURIComponent(run);
102
+ } catch {
103
+ // One invalid byte anywhere in the run would otherwise blind the whole
104
+ // run — appending `%C0` next to `%3C` is enough to hide a `<`. Decode the
105
+ // largest valid group at each position instead, longest first so multi-byte
106
+ // sequences (up to four escapes) still group correctly, and pass through
107
+ // any escape that cannot be decoded at all.
108
+ const escapes = run.match(/%[0-9A-Fa-f]{2}/g) ?? [];
109
+ let out = "";
110
+ let i = 0;
111
+ while (i < escapes.length) {
112
+ let taken = 0;
113
+ for (let len = Math.min(4, escapes.length - i); len >= 1; len--) {
114
+ try {
115
+ out += decodeURIComponent(escapes.slice(i, i + len).join(""));
116
+ taken = len;
117
+ break;
118
+ } catch {
119
+ // Try a shorter group.
120
+ }
121
+ }
122
+ if (taken === 0) {
123
+ out += escapes[i];
124
+ taken = 1;
125
+ }
126
+ i += taken;
127
+ }
128
+ return out;
129
+ }
130
+ };
131
+
132
+ const decodeOnce = (value: string): string =>
133
+ value.replace(/(?:%[0-9A-Fa-f]{2})+/g, decodeRun);
134
+
135
+ /**
136
+ * Decode to a fixed point, so a payload encoded any number of times
137
+ * (`%253Cscript%253E`, `%25252525253Cscript...`) is compared in a form the
138
+ * markup check can see.
139
+ *
140
+ * The bound is the input length rather than a fixed number of layers, because
141
+ * a fixed number is reachable: re-encoding `javascript:alert(1)` only grows the
142
+ * single `%` by two characters per layer, so 66 layers fit in 151 characters
143
+ * and any small constant can be encoded past. Every productive pass turns a
144
+ * three-character escape into one character, so a string of length n admits
145
+ * fewer than n productive passes — this bound is always sufficient and still
146
+ * guarantees termination.
147
+ */
148
+ const decodeDeep = (value: string): string => {
149
+ let current = value;
150
+ for (let i = 0; i < value.length; i++) {
151
+ const next = decodeOnce(current);
152
+ if (next === current) break;
153
+ current = next;
154
+ }
155
+ return current;
156
+ };
157
+
158
+ const sanitizeRef = (value: string): string =>
159
+ REF_PATTERN.test(value) ? value : "";
160
+
161
+ /**
162
+ * URLSearchParams has already decoded one layer by the time a UTM value gets
163
+ * here, so a payload encoded twice arrives still encoded:
164
+ * `utm_source=%253Cscript%253E` reads as the literal `%3Cscript%3E`, which
165
+ * contains no raw markup and would pass a raw-only check. Test the decoded
166
+ * form as well.
167
+ */
168
+ const sanitizeUtm = (value: string): string => {
169
+ if (value.length > UTM_MAX_LENGTH) return "";
170
+ if (FORBIDDEN_CHARS.test(value) || hasForbiddenScheme(value)) {
171
+ return "";
172
+ }
173
+ const decoded = decodeDeep(value);
174
+ if (
175
+ DECODED_FORBIDDEN_CHARS.test(decoded) ||
176
+ hasForbiddenScheme(decoded)
177
+ ) {
178
+ return "";
179
+ }
180
+ return value;
181
+ };
182
+
183
+ /**
184
+ * The decoded parts of a referrer's query string — keys as well as values.
185
+ * Handles both a full URL ("myapp://x?utm_source=...") and the bare query
186
+ * string the Android install referrer supplies ("utm_source=...&utm_medium=").
187
+ * Returns nothing when there is no query to read; the caller still checks the
188
+ * whole string.
189
+ *
190
+ * Keys matter because a parameter with no "=" parses entirely as a key with an
191
+ * empty value: "?%6Aavascript:alert(1)" would otherwise contribute nothing to
192
+ * inspect.
193
+ */
194
+ const referrerParamParts = (value: string): string[] => {
195
+ const queryStart = value.indexOf("?");
196
+ const query = queryStart === -1 ? value : value.slice(queryStart + 1);
197
+ if (!query || (queryStart === -1 && !query.includes("="))) return [];
198
+ try {
199
+ // URLSearchParams decodes leniently and does not throw on a stray "%".
200
+ const parts: string[] = [];
201
+ for (const [key, paramValue] of new URLSearchParams(query)) {
202
+ parts.push(key, paramValue);
203
+ }
204
+ return parts;
205
+ } catch {
206
+ return [];
207
+ }
208
+ };
209
+
210
+ /**
211
+ * A URL carries its payload percent-encoded — `Linking` and the browser both
212
+ * encode `<` and `>` — so a raw-only check would pass
213
+ * `?utm_source=%3Cscript%3E` straight through.
214
+ *
215
+ * Checked three ways, because pattern-matching the URL as one opaque string
216
+ * misses what a structural read catches: the whole value raw, the whole value
217
+ * decoded, and each decoded query part — keys included — on its own. The last
218
+ * is what catches a smuggled scheme, since FORBIDDEN_SCHEME_PREFIX is anchored
219
+ * and `?utm_source=javascript:alert(1)` only matches once that parameter is
220
+ * read apart from the `myapp://` URL containing it.
221
+ */
222
+ const sanitizeReferrer = (value: string): string => {
223
+ if (value.length > REFERRER_MAX_LENGTH) return "";
224
+ if (FORBIDDEN_CHARS.test(value) || hasForbiddenScheme(value)) {
225
+ return "";
226
+ }
227
+
228
+ const decodedWhole = decodeDeep(value);
229
+ if (
230
+ DECODED_FORBIDDEN_CHARS.test(decodedWhole) ||
231
+ hasForbiddenScheme(decodedWhole)
232
+ ) {
233
+ return "";
234
+ }
235
+
236
+ for (const part of referrerParamParts(value)) {
237
+ const decoded = decodeDeep(part);
238
+ if (
239
+ DECODED_FORBIDDEN_CHARS.test(decoded) ||
240
+ hasForbiddenScheme(decoded)
241
+ ) {
242
+ return "";
243
+ }
244
+ }
245
+
246
+ return value;
247
+ };
248
+
249
+ /**
250
+ * Sanitize every traffic-source field of a (possibly sparse) traffic-source
251
+ * object. Unknown keys fall through to the UTM rule, the most permissive of
252
+ * the value rules.
253
+ */
254
+ export const sanitizeTrafficSources = <T extends Partial<ITrafficSource>>(
255
+ trafficSources: T
256
+ ): T => {
257
+ const result: Record<string, unknown> = { ...trafficSources };
258
+ for (const key of Object.keys(result)) {
259
+ const value = result[key];
260
+ if (typeof value !== "string" || value === "") {
261
+ continue;
262
+ }
263
+ if (key === "ref") {
264
+ result[key] = sanitizeRef(value);
265
+ } else if (key === "referrer") {
266
+ result[key] = sanitizeReferrer(value);
267
+ } else {
268
+ result[key] = sanitizeUtm(value);
269
+ }
270
+ }
271
+ return result as T;
272
+ };
273
+
274
+ export { sanitizeRef, sanitizeUtm, sanitizeReferrer };
@@ -6,16 +6,30 @@
6
6
  import { logger } from "../lib/logger";
7
7
  import { storage } from "../lib/storage";
8
8
  import { SESSION_TRAFFIC_SOURCE_KEY } from "../constants";
9
+ import { sanitizeTrafficSources } from "./sanitize";
9
10
  import type { ITrafficSource } from "../types";
10
11
 
11
12
  /**
12
13
  * Parse UTM parameters and referral info from URL
13
14
  * Supports both web URLs (https://) and deep link URLs (myapp://)
15
+ *
16
+ * Every return path is sanitized (see ./sanitize) so scanner-injected or
17
+ * hand-crafted payloads in a deep link can never be persisted or reported.
14
18
  */
15
19
  export function parseTrafficSource(
16
20
  url: string,
17
21
  customRefParams?: string[],
18
22
  pathPattern?: string
23
+ ): Partial<ITrafficSource> {
24
+ return sanitizeTrafficSources(
25
+ extractTrafficSource(url, customRefParams, pathPattern)
26
+ );
27
+ }
28
+
29
+ function extractTrafficSource(
30
+ url: string,
31
+ customRefParams?: string[],
32
+ pathPattern?: string
19
33
  ): Partial<ITrafficSource> {
20
34
  try {
21
35
  // Handle deep link URLs that may not have standard URL format
@@ -112,7 +126,11 @@ export function getStoredTrafficSource(): Partial<ITrafficSource> | undefined {
112
126
  try {
113
127
  const stored = storage().get(SESSION_TRAFFIC_SOURCE_KEY);
114
128
  if (stored && typeof stored === "string") {
115
- return JSON.parse(stored) as Partial<ITrafficSource>;
129
+ // Sanitize on the way out too, so values persisted by a pre-sanitization
130
+ // SDK version are flushed rather than replayed onto every event.
131
+ return sanitizeTrafficSources(
132
+ JSON.parse(stored) as Partial<ITrafficSource>
133
+ );
116
134
  }
117
135
  } catch (error) {
118
136
  logger.debug("Failed to get stored traffic source:", error);
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // This file is auto-generated by scripts/update-version.js during npm version
2
2
  // Do not edit manually - it will be overwritten
3
- export const version = '1.0.2';
3
+ export const version = '1.1.0';