From 8efd5d7279cb655795fc02086ca548224ba43190 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 3 Aug 2026 23:03:52 -0700 Subject: [PATCH] Avoid saving same-domain referrers and resolve path-only referrers correctly --- src/app/api/send/route.test.ts | 102 +++++++++++++++++++++++++++++++++ src/app/api/send/route.ts | 20 ++++++- src/tracker/index.d.ts | 6 +- src/tracker/index.ts | 9 ++- 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/app/api/send/route.test.ts b/src/app/api/send/route.test.ts index b9971904d..1b1d3b75a 100644 --- a/src/app/api/send/route.test.ts +++ b/src/app/api/send/route.test.ts @@ -369,6 +369,108 @@ describe('event url and referrer parsing', () => { }); }); + test('does not save referrer domain when it matches the hostname', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + hostname: 'example.com', + url: '/products', + referrer: 'https://www.example.com/prev?a=1', + }, + }); + + const arg = saveEventMock.mock.calls[0][0] as Record; + expect(arg).toMatchObject({ + referrerPath: '/prev', + referrerQuery: 'a=1', + }); + expect(arg.referrerDomain).toBeUndefined(); + }); + + test('does not save referrer domain for a path-only referrer', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + hostname: 'example.com', + url: '/products', + referrer: '/prev?a=1', + }, + }); + + const arg = saveEventMock.mock.calls[0][0] as Record; + expect(arg).toMatchObject({ + referrerPath: '/prev', + referrerQuery: 'a=1', + }); + expect(arg.referrerDomain).toBeUndefined(); + }); + + test('saves referrer domain for a lookalike domain that only shares a prefix', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + hostname: 'example.com', + url: '/products', + referrer: 'https://example.com.br/page', + }, + }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ + referrerDomain: 'example.com.br', + referrerPath: '/page', + }); + }); + + test('resolves a path-only referrer against the url domain when hostname is missing', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + url: 'https://example.com/products', + referrer: '/prev', + }, + }); + + const arg = saveEventMock.mock.calls[0][0] as Record; + expect(arg).toMatchObject({ referrerPath: '/prev' }); + expect(arg.referrerDomain).toBeUndefined(); + }); + + test('does not save referrer domain when hostname differs only by case', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + hostname: 'EXAMPLE.com', + url: '/products', + referrer: 'https://example.com/prev', + }, + }); + + const arg = saveEventMock.mock.calls[0][0] as Record; + expect(arg).toMatchObject({ referrerPath: '/prev' }); + expect(arg.referrerDomain).toBeUndefined(); + }); + + test('does not save referrer domain when hostname includes a port', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + hostname: 'example.com:8443', + url: '/products', + referrer: 'https://example.com:8443/prev', + }, + }); + + const arg = saveEventMock.mock.calls[0][0] as Record; + expect(arg).toMatchObject({ referrerPath: '/prev' }); + expect(arg.referrerDomain).toBeUndefined(); + }); + test('REMOVE_TRAILING_SLASH strips a trailing slash from the url path', async () => { process.env.REMOVE_TRAILING_SLASH = '1'; diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index cf0c30ffd..6da12076c 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -192,7 +192,7 @@ export async function POST(request: Request) { let urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash; const urlQuery = currentUrl.search.substring(1); - const urlDomain = currentUrl.hostname.replace(/^www./, ''); + const urlDomain = currentUrl.hostname.replace(/^www\./, ''); let referrerPath: string; let referrerQuery: string; @@ -218,11 +218,27 @@ export async function POST(request: Request) { } if (referrer) { - const referrerUrl = new URL(referrer, base); + // Canonicalize the event domain (lowercase, punycode, no port) so it + // compares correctly against the parsed referrer hostname + let eventDomain = urlDomain; + if (hostname) { + try { + eventDomain = new URL(`https://${hostname}`).hostname.replace(/^www\./, ''); + } catch { + eventDomain = hostname.replace(/^www\./, ''); + } + } + // Resolve path-only referrers against the event's domain, not the localhost fallback + const referrerUrl = new URL(referrer, eventDomain ? `https://${eventDomain}` : base); referrerPath = referrerUrl.pathname; referrerQuery = referrerUrl.search.substring(1); referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); + + // Never save the referrer domain for self-referrals + if (referrerDomain === eventDomain) { + referrerDomain = undefined; + } } const eventType = linkId diff --git a/src/tracker/index.d.ts b/src/tracker/index.d.ts index 925e075cd..ab909a0e6 100644 --- a/src/tracker/index.d.ts +++ b/src/tracker/index.d.ts @@ -19,8 +19,10 @@ export type TrackedProperties = { /** * Page referrer * - * @description extracted from `document.referrer` - * @example 'https://analytics.umami.is/docs/getting-started' + * @description extracted from `document.referrer` on page load, or the previous + * in-app URL on client-side navigation. Same-origin referrers are sent as + * root-relative paths so the site's own domain is never saved as a referrer. + * @example 'https://analytics.umami.is/docs/getting-started', '/docs/getting-started' */ referrer?: string; /** diff --git a/src/tracker/index.ts b/src/tracker/index.ts index ab5d868e1..1789d3363 100644 --- a/src/tracker/index.ts +++ b/src/tracker/index.ts @@ -274,6 +274,11 @@ type MetricEntry = PerformanceEntry & { } }; + // Strip the origin from same-origin referrers so the referrer domain + // is never saved when it matches the current hostname + const stripOrigin = (url: string): string => + url === origin || url?.startsWith(origin + '/') ? url.slice(origin.length) : url; + const getPayload = () => ({ website, screen, @@ -281,7 +286,7 @@ type MetricEntry = PerformanceEntry & { title: document.title, hostname, url: currentUrl, - referrer: currentRef, + referrer: stripOrigin(currentRef), tag, id: identity ? identity : undefined, }); @@ -631,7 +636,7 @@ type MetricEntry = PerformanceEntry & { } let currentUrl = normalize(href); - let currentRef = normalize(referrer.startsWith(origin) ? '' : referrer); + let currentRef = normalize(referrer); let initialized = false; let disabled = false;