From f0918c4ac4ca7b122c5d54121468cd836dd02a29 Mon Sep 17 00:00:00 2001 From: feugy Date: Fri, 2 Oct 2026 14:25:43 +0200 Subject: [PATCH] feat(server): add url option to report another page URL The server senders report the URL of the incoming request in the `o` field, taken from the request context or the referer. Callers that sanitize that URL (credentials, emails in callback queries) had no way to replace it. `track`, `identify`, `group` and `trackExposure` now accept `options.url`, which wins over both. An invalid value fails the send instead of falling back to the URL the caller meant to replace. --- packages/web/src/server/attribution.test.ts | 15 +++++++ packages/web/src/server/attribution.ts | 6 ++- packages/web/src/server/experiments.ts | 1 + packages/web/src/server/index.test.ts | 49 +++++++++++++++++++++ packages/web/src/server/index.ts | 1 + packages/web/src/server/request.ts | 49 +++++++++++++++++++-- 6 files changed, 115 insertions(+), 6 deletions(-) diff --git a/packages/web/src/server/attribution.test.ts b/packages/web/src/server/attribution.test.ts index 49d352d..cd64716 100644 --- a/packages/web/src/server/attribution.test.ts +++ b/packages/web/src/server/attribution.test.ts @@ -262,6 +262,21 @@ describe.each([ expect(fetchMock).toHaveBeenCalledWith(override, expect.anything()); }); + it('reports the url option instead of the referer', async () => { + const url = 'https://acme.org/welcome'; + + await fn(id, undefined, { + headers: { ...headers, referer: `${url}?email=jo%40acme.org` }, + url, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body.o).toBe(url); + }); + it('reports an error when no headers are available', async () => { await fn(id); diff --git a/packages/web/src/server/attribution.ts b/packages/web/src/server/attribution.ts index 8108174..cd89075 100644 --- a/packages/web/src/server/attribution.ts +++ b/packages/web/src/server/attribution.ts @@ -106,7 +106,8 @@ async function trackProfile( * @param userId - The identifier of the user. * @param [traits] - Additional traits of the user. Nested objects are not supported. Allowed values are `string`, `number`, `boolean`, and `null`. * @param [options] - Pass `request` or `headers` when the function runs - * outside of a Vercel Function, where no request context is available. + * outside of a Vercel Function, where no request context is available, and + * `url` to report another page URL than the one of the current request. */ export async function identify( userId: string, @@ -128,7 +129,8 @@ export async function identify( * @param groupId - The identifier of the group. * @param [traits] - Additional traits of the group. Nested objects are not supported. Allowed values are `string`, `number`, `boolean`, and `null`. * @param [options] - Pass `request` or `headers` when the function runs - * outside of a Vercel Function, where no request context is available. + * outside of a Vercel Function, where no request context is available, and + * `url` to report another page URL than the one of the current request. */ export async function group( groupId: string, diff --git a/packages/web/src/server/experiments.ts b/packages/web/src/server/experiments.ts index 890e13c..5f70f93 100644 --- a/packages/web/src/server/experiments.ts +++ b/packages/web/src/server/experiments.ts @@ -27,6 +27,7 @@ export interface ExposureOptions extends Omit, Attribution {} * @param [options.props] - Traits of the user and group, like `plan` or `role`. * @param [options.request] / [options.headers] - Pass them when the function runs * outside of a Vercel Function, where no request context is available. + * @param [options.url] - The page URL the exposure is reported for. Defaults to the URL of the current request. */ export async function trackExposure( input: ServerExposureInput, diff --git a/packages/web/src/server/index.test.ts b/packages/web/src/server/index.test.ts index e235469..454a3d5 100644 --- a/packages/web/src/server/index.test.ts +++ b/packages/web/src/server/index.test.ts @@ -364,6 +364,38 @@ describe('server track', () => { ); }); + describe('given a url option', () => { + it('reports it instead of the referer', async () => { + const url = 'https://acme.org/callback'; + + await track('login', undefined, { + headers: { ...headers, referer: `${url}?token=secret` }, + url, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body.o).toBe(url); + }); + + it.each([ + '/relative/path', + 'not a url', + 'ftp://acme.org/file', + '', + ])('does not send when the url is invalid (%j)', async (url) => { + await track('login', undefined, { headers, url }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError.mock.calls[0]?.[0]).toMatchObject({ + message: expect.stringContaining('Invalid `url` option'), + }); + }); + }); + it('inclues the provided bypass secret', async () => { const bypassSecret = 'secretXYZ'; process.env.VERCEL_AUTOMATION_BYPASS_SECRET = bypassSecret; @@ -686,6 +718,23 @@ describe('server track', () => { ); }); + it('reports the url option instead of the request url', async () => { + const url = 'https://acme.org/callback'; + requestContext = { + headers: { ...headers, referer: `${url}?token=from-referer` }, + url: `${url}?token=from-context&code=123`, + }; + + await track('login', undefined, { url }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body.o).toBe(url); + expect(JSON.stringify(body)).not.toContain('token='); + }); + it('uses waitUntil', async () => { const waitUntil = vi.fn(); requestContext = { headers, waitUntil }; diff --git a/packages/web/src/server/index.ts b/packages/web/src/server/index.ts index c4ed33e..d7a94fa 100644 --- a/packages/web/src/server/index.ts +++ b/packages/web/src/server/index.ts @@ -29,6 +29,7 @@ export interface TrackOptions extends Options, Attribution {} * @param [options.groupId] - The group the event is attributed to. * @param [options.props] - Traits of the user and group, like `plan` or `role`. Same constraints as `properties`. * @param [options.request] / [options.headers] - Pass them when the function runs outside of a Vercel Function, where no request context is available. + * @param [options.url] - The page URL the event is reported for. Defaults to the URL of the current request. */ export async function track( eventName: string, diff --git a/packages/web/src/server/request.ts b/packages/web/src/server/request.ts index 1de41ab..07963c3 100644 --- a/packages/web/src/server/request.ts +++ b/packages/web/src/server/request.ts @@ -16,6 +16,12 @@ export interface Options { flags?: FlagsDataInput; headers?: AllowedHeaders; request?: { headers: AllowedHeaders }; + /** + * The page URL the event is reported for. Overrides the URL of the current + * request, for example to strip a token from its query. Must be an absolute + * `http(s)` URL; the event is not sent otherwise. + */ + url?: string; } export interface RequestContext { @@ -125,6 +131,44 @@ export function resolveHeaders( return { requestHeaders, hasHeaders: Boolean(headers) }; } +/** + * The URL reported in the `o` field of a payload. An explicit `url` wins over + * the URL of the incoming request, then its referer, then the endpoint origin. + * + * @throws when an explicit `url` is not an absolute `http(s)` URL: falling back + * to the request URL would send the value the caller wanted to replace. + */ +export function resolveUrl( + url: string | undefined, + requestContext: ResolvedRequestContext, + requestHeaders: HeadersObject, + endpoint: string, +): string { + if (url !== undefined) { + let parsed: URL | undefined; + try { + parsed = new URL(url); + } catch { + /* reported below */ + } + if ( + !parsed || + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + ) { + throw new Error( + `Invalid \`url\` option: expected an absolute http(s) URL, got "${url}".`, + ); + } + return url; + } + + return ( + requestContext?.url || + (requestHeaders.referer as string) || + new URL(endpoint).origin + ); +} + /** Ingestion routes a server sender can post to. */ export type EndpointKind = 'event' | 'exposure' | 'identify' | 'group'; @@ -202,10 +246,7 @@ export async function dispatch({ } const body = { - o: - requestContext?.url || - (requestHeaders.referer as string) || - new URL(endpoint).origin, + o: resolveUrl(options?.url, requestContext, requestHeaders, endpoint), ts: Date.now(), sdkn: `${packageName}/server`, sdkv: version,