Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions src/lib/helpers/attribution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* attribution.js
* ==============
* Write-once first-touch attribution capture (issue #4520).
*
* Captures referrer / landing path / UTM params from the very first page a
* visitor lands on, so signup() can send them along with the payload. Uses
* sessionStorage ONLY — never cookies, never localStorage, no persistent
* identifier. First-touch wins: once a record exists for the session, later
* navigations never overwrite it.
*/

/** sessionStorage key used to persist the first-touch attribution record. */
export const ATTRIBUTION_SS_KEY = 'attribution_v1';

/** Max length enforced on `referrer` / `landingPath`. */
const URL_FIELD_MAX_LENGTH = 2048;
/** Max length enforced on each individual UTM field. */
const UTM_FIELD_MAX_LENGTH = 256;

/** Maps URL query param names to their camelCase wire field name. */
const UTM_PARAM_MAP = {
utm_source: 'utmSource',
utm_medium: 'utmMedium',
utm_campaign: 'utmCampaign',
utm_term: 'utmTerm',
utm_content: 'utmContent',
};

/** Whitelist of the 7 known wire keys, mapped to their max length. Any other key is dropped. */
const KNOWN_FIELDS = {
referrer: URL_FIELD_MAX_LENGTH,
landingPath: URL_FIELD_MAX_LENGTH,
utmSource: UTM_FIELD_MAX_LENGTH,
utmMedium: UTM_FIELD_MAX_LENGTH,
utmCampaign: UTM_FIELD_MAX_LENGTH,
utmTerm: UTM_FIELD_MAX_LENGTH,
utmContent: UTM_FIELD_MAX_LENGTH,
};

/**
* @desc Returns true when running in a browser environment with sessionStorage available.
* Guards against a throwing `sessionStorage` getter (e.g. sandboxed iframes / storage
* partitioning policies) so callers never see an uncaught exception before their own try/catch.
* @returns {boolean}
*/
function isBrowser() {
if (typeof window === 'undefined') return false;
try {
return typeof sessionStorage !== 'undefined';
} catch {
return false;
}
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* @desc Trim a value and cap it to maxLength. Non-string / empty-after-trim input yields undefined.
* @param {*} value - Raw value to normalise.
* @param {number} maxLength - Maximum length to keep.
* @returns {string|undefined}
*/
function trimAndCap(value, maxLength) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.slice(0, maxLength);
}

/**
* @desc Determine whether a referrer URL shares the current page's origin.
* @param {string} referrer - `document.referrer` value.
* @returns {boolean}
*/
function isSameOrigin(referrer) {
try {
return new URL(referrer).origin === window.location.origin;
} catch {
return false;
}
}

/** Query keys whose values are credentials/single-use tokens — never persisted in landingPath. */
const SENSITIVE_QUERY_KEY_PATTERN = /token|secret|password|code|key/i;

/**
* @desc Rebuild a search string with credential-carrying params removed, so a landing
* URL like `/signup?inviteToken=...` never duplicates the token into stored attribution.
* @param {string} search - `window.location.search` value.
* @returns {string} Sanitized search string ('' or '?...').
*/
function stripSensitiveParams(search) {
const params = new URLSearchParams(search);
[...params.keys()].forEach((key) => {
if (SENSITIVE_QUERY_KEY_PATTERN.test(key)) params.delete(key);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const rebuilt = params.toString();
return rebuilt ? `?${rebuilt}` : '';
}

/**
* @desc Sanitize a cross-origin referrer for storage: parse it, strip credential-carrying
* query params (same rule as landingPath), and cap the result. A cross-origin referrer's
* own query string can carry `token`/`code`/reset values just like the landing URL does, so
* it needs the same filtering. Malformed referrers are dropped rather than stored raw.
* @param {string} referrer - `document.referrer` value (already confirmed cross-origin).
* @returns {string|undefined}
*/
function sanitizeReferrer(referrer) {
try {
const url = new URL(referrer);
return trimAndCap(`${url.origin}${url.pathname}${stripSensitiveParams(url.search)}`, URL_FIELD_MAX_LENGTH);
} catch {
return undefined;
}
}

/**
* @desc Build the first-touch attribution record from the current document/location.
* @returns {object|null} The record, or null when there is nothing to capture.
*/
function buildAttribution() {
const record = {};

const referrer = document.referrer;
if (referrer && !isSameOrigin(referrer)) {
const capped = sanitizeReferrer(referrer);
if (capped) record.referrer = capped;
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.

const landingPath = trimAndCap(`${window.location.pathname}${stripSensitiveParams(window.location.search)}`, URL_FIELD_MAX_LENGTH);
if (landingPath) record.landingPath = landingPath;

const params = new URLSearchParams(window.location.search);
Object.entries(UTM_PARAM_MAP).forEach(([queryKey, field]) => {
const capped = trimAndCap(params.get(queryKey), UTM_FIELD_MAX_LENGTH);
if (capped) record[field] = capped;
});

return Object.keys(record).length > 0 ? record : null;
}

/**
* @desc Sanitize a raw parsed attribution record read back from sessionStorage: whitelist
* to the 7 known wire keys, drop any other key, drop non-string values, re-apply trim +
* length caps. Guards against a tampered/extension-injected key reaching the strict Zod
* signup endpoint (an unexpected key would 422 the whole payload).
* @param {object} parsed - Raw parsed JSON object.
* @returns {object|null} Sanitized record, or null when nothing valid remains.
*/
function sanitizeAttribution(parsed) {
const record = {};
Object.entries(KNOWN_FIELDS).forEach(([key, maxLength]) => {
const capped = trimAndCap(parsed[key], maxLength);
if (capped) record[key] = capped;
});
return Object.keys(record).length > 0 ? record : null;
}

/**
* @desc Capture first-touch attribution (referrer, landing path, UTM params) into
* sessionStorage. Write-once: if a record already exists for this session, does
* nothing. Never uses cookies or localStorage, never stores a persistent identifier.
* Safe no-op when storage/window is unavailable (private mode, SSR, unit tests).
* @returns {void}
*/
export function captureFirstTouch() {
if (!isBrowser()) return;
try {
if (sessionStorage.getItem(ATTRIBUTION_SS_KEY) !== null) return;
const record = buildAttribution();
if (record) sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify(record));
} catch {
// sessionStorage unavailable (private mode / sandboxed) — silent no-op
}
}

/**
* @desc Returns the stored first-touch attribution record.
* @returns {object|null} The record, or null when absent, unavailable, or malformed.
*/
export function getAttribution() {
if (!isBrowser()) return null;
try {
const raw = sessionStorage.getItem(ATTRIBUTION_SS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
return sanitizeAttribution(parsed);
} catch {
return null;
}
}

/**
* Exports.
*/
export default { captureFirstTouch, getAttribution };
Loading
Loading