From 5b0d3554172e5580529a5e6d34ddb7a4f66a1303 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 17 Sep 2026 01:46:21 -0400 Subject: [PATCH] feat(privacy): strip tracking parameters from links on paste, send and render (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid, YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every recipient's click back to the person who shared the link. New src/app/utils/urlTracking.ts is a pure, local stripper: a global list + utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only removed on youtube/spotify. matrix.to and non-http(s) schemes are never rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc` variant selectors are deliberately kept. 13 unit tests. Wired at three points, all behind a new Settings → Privacy toggle (`stripTrackingParams`, default on): - paste: plain-text pastes are cleaned and re-inserted through Slate's own insertData so multi-line pastes still split into paragraphs; - send: RoomInput submit + schedule paths and MessageEditor saves clean both `body` and `formatted_body` (the HTML variant unescapes `&` around each URL and re-escapes it so the markup is untouched); - render: linkify `formatHref`/`format` and explicit `` in formatted_body are cleaned, so links sent from other clients are safe to click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so the toggle is a module flag kept current by ClientNonUIFeatures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/RoomInput.tsx | 34 ++- .../features/room/message/MessageEditor.tsx | 10 +- src/app/features/settings/general/General.tsx | 8 + src/app/pages/client/ClientNonUIFeatures.tsx | 6 + src/app/plugins/react-custom-html-parser.tsx | 26 ++ src/app/state/settings.ts | 6 + src/app/utils/urlTracking.test.ts | 111 +++++++++ src/app/utils/urlTracking.ts | 233 ++++++++++++++++++ 8 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 src/app/utils/urlTracking.test.ts create mode 100644 src/app/utils/urlTracking.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6e4f36403..c3562a955 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -140,6 +140,7 @@ import { DraftIndicator } from './DraftIndicator'; import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { getThreadDraftKey } from '../../state/room/thread'; +import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking'; const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })), @@ -258,6 +259,8 @@ export const RoomInput = forwardRef( // [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the // toolbar button entirely when it's off so it never opens an empty popover. const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled'); + // [Gitea #103] Privacy: drop tracking params from links on paste and on send. + const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled; const showLocation = composerToolbarButtons?.showLocation ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true; @@ -389,7 +392,25 @@ export const RoomInput = forwardRef( [setSelectedFiles, room], ); const pickFile = useFilePicker(handleFiles, true); - const handlePaste = useFilePasteHandler(handleFiles); + const handleFilePaste = useFilePasteHandler(handleFiles); + const handlePaste = useCallback( + (evt) => { + handleFilePaste(evt); + if (evt.defaultPrevented || !stripTracking) return; + const text = evt.clipboardData?.getData('text/plain'); + if (!text) return; + const cleaned = stripTrackingParamsInText(text); + if (cleaned === text) return; + // Re-run Slate's own plain-text insertion with the cleaned string so + // multi-line pastes still split into paragraphs exactly as before. + if (typeof DataTransfer === 'undefined') return; + evt.preventDefault(); + const dt = new DataTransfer(); + dt.setData('text/plain', cleaned); + ReactEditor.insertData(editor, dt); + }, + [handleFilePaste, stripTracking, editor], + ); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const { gifApiKey } = useClientConfig(); const gifBtnRef = useRef(null); @@ -623,6 +644,10 @@ export const RoomInput = forwardRef( plainText = trimCommand(commandName, plainText); customHtml = trimCommand(commandName, customHtml); } + if (stripTracking) { + plainText = stripTrackingParamsInText(plainText); + customHtml = stripTrackingParamsInHtml(customHtml); + } if (commandName === Command.Me) { msgType = MsgType.Emote; } else if (commandName === Command.Notice) { @@ -718,6 +743,7 @@ export const RoomInput = forwardRef( isMarkdown, commands, setToast, + stripTracking, ]); /** @@ -740,8 +766,8 @@ export const RoomInput = forwardRef( ); if (plainText === '') return null; - const body = plainText; - const formattedBody = customHtml; + const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText; + const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml; const mentionData = getMentions(mx, roomId, editor); const content: IContent = { @@ -769,7 +795,7 @@ export const RoomInput = forwardRef( } } return content; - }, [editor, isMarkdown, mx, roomId, replyDraft]); + }, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]); const handleScheduleClick = useCallback(() => { // Defense in depth: scheduling sends an unencrypted m.room.message, so never diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 3281cab95..c7a22cdba 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -58,6 +58,7 @@ import { trimReplyFromFormattedBody, } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; +import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../../utils/urlTracking'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; type MessageEditorProps = { @@ -87,6 +88,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + // [Gitea #103] Same paste/send stripping as RoomInput, for edits. + const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); const [toolbar, setToolbar] = useState(globalToolbar); const isComposing = useComposingCheck(); @@ -117,8 +120,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( const [saveState, save] = useAsyncCallback( useCallback(async () => { - const plainText = toPlainText(editor.children, isMarkdown).trim(); - const customHtml = trimCustomHtml( + const rawPlainText = toPlainText(editor.children, isMarkdown).trim(); + const rawCustomHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, @@ -126,6 +129,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( allowMath: true, }), ); + const plainText = stripTracking ? stripTrackingParamsInText(rawPlainText) : rawPlainText; + const customHtml = stripTracking ? stripTrackingParamsInHtml(rawCustomHtml) : rawCustomHtml; // Media caption edit: preserve the media, change only body/formatted_body. // An empty caption is valid (it removes the caption → body falls back to @@ -239,6 +244,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( // eslint-disable-next-line @typescript-eslint/no-explicit-any return mx.sendMessage(roomId, content as any); }, [ + stripTracking, mx, editor, roomId, diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 32b439332..ab77c937d 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1439,10 +1439,18 @@ function Privacy() { settingsAtom, 'warnOnUnverifiedDevices', ); + const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); return ( Privacy + + } + /> + { + setStripTrackingOnRender(stripTracking); + }, [stripTracking]); if (pageZoom === 100) { document.documentElement.style.removeProperty('font-size'); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 6890e72e3..82c98e361 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -22,6 +22,7 @@ import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyj import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; +import { stripTrackingParams } from '../utils/urlTracking'; import * as css from '../styles/CustomHtml.css'; import { getMxIdLocalPart, @@ -106,11 +107,30 @@ const renderMath = ( const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g'); +// [Gitea #103] Render-time tracking-param stripping. LINKIFY_OPTS is spread +// into memoised per-timeline option objects, so the toggle lives in a module +// flag that the format callbacks read lazily on every render; the setting's +// subscriber (ClientNonUIFeatures) keeps it current. +let stripTrackingOnRender = true; +export const setStripTrackingOnRender = (enabled: boolean): void => { + stripTrackingOnRender = enabled; +}; +const cleanHref = (href: string): string => + stripTrackingOnRender ? stripTrackingParams(href) : href; + export const LINKIFY_OPTS: LinkifyOpts = { attributes: { target: '_blank', rel: 'noreferrer noopener', }, + formatHref: { + url: cleanHref, + }, + // Only the visible text of bare URLs is a URL, so cleaning it keeps the + // label honest about where the link goes. + format: { + url: cleanHref, + }, validate: { url: (value) => /^(https?|ftp|mailto|magnet):/.test(value), }, @@ -393,6 +413,12 @@ export const getReactCustomHtmlParser = ( replace: (domNode) => { if (domNode instanceof Element && 'name' in domNode) { const { name, attribs, children, parent } = domNode; + // [Gitea #103] Clean explicit targets from formatted_body + // before any branch below (mention detection, default render) sees them. + if (name === 'a' && stripTrackingOnRender && typeof attribs.href === 'string') { + const cleaned = stripTrackingParams(attribs.href); + if (cleaned !== attribs.href) attribs.href = cleaned; + } const props = attributesToProps(attribs); if (name === 'h1') { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 78c2b46cb..4430e6c13 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -263,6 +263,10 @@ export interface Settings { warnOnUnverifiedDevices: boolean; + // [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or + // send, and from links rendered in the timeline. Local only. + stripTrackingParams: boolean; + pauseAnimations: boolean; composerToolbarButtons: ComposerToolbarSettings; @@ -372,6 +376,8 @@ const defaultSettings: Settings = { warnOnUnverifiedDevices: false, + stripTrackingParams: true, + pauseAnimations: false, composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR, diff --git a/src/app/utils/urlTracking.test.ts b/src/app/utils/urlTracking.test.ts new file mode 100644 index 000000000..944ee7352 --- /dev/null +++ b/src/app/utils/urlTracking.test.ts @@ -0,0 +1,111 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + stripTrackingParams, + stripTrackingParamsInHtml, + stripTrackingParamsInText, +} from './urlTracking'; + +describe('stripTrackingParams', () => { + it('removes utm_* and known global params, keeps the rest', () => { + const out = stripTrackingParams( + 'https://example.com/a?utm_source=x&utm_medium=y&fbclid=abc&page=2&gclid=1', + ); + assert.equal(out, 'https://example.com/a?page=2'); + }); + + it('drops the dangling ? when every param was tracking', () => { + assert.equal( + stripTrackingParams('https://example.com/a?utm_source=x'), + 'https://example.com/a', + ); + }); + + it('returns the identical string when nothing changed', () => { + const url = 'https://example.com/a?page=2#frag'; + assert.equal(stripTrackingParams(url), url); + }); + + it('preserves the fragment', () => { + assert.equal( + stripTrackingParams('https://example.com/a?utm_source=x#section-3'), + 'https://example.com/a#section-3', + ); + }); + + it('applies host rules only on that host', () => { + assert.equal( + stripTrackingParams('https://youtu.be/dQw4w9WgXcQ?si=abc123&t=42'), + 'https://youtu.be/dQw4w9WgXcQ?t=42', + ); + assert.equal( + stripTrackingParams('https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=share&si=x'), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ); + // `si` is not tracking elsewhere. + const other = 'https://example.com/?si=keep'; + assert.equal(stripTrackingParams(other), other); + }); + + it('strips amazon affiliate/telemetry params and the /ref= path segment but keeps variant selectors', () => { + assert.equal( + stripTrackingParams( + 'https://www.amazon.com/dp/B000000000/ref=sr_1_3?crid=ABC&keywords=x&qid=1&sr=8-3&tag=aff-20&th=1&psc=1&pd_rd_r=zz', + ), + 'https://www.amazon.com/dp/B000000000?keywords=x&th=1&psc=1', + ); + }); + + it('never rewrites matrix.to permalinks or non-http schemes', () => { + const permalink = 'https://matrix.to/#/!room:example.org/$event?via=example.org&utm_source=x'; + assert.equal(stripTrackingParams(permalink), permalink); + assert.equal( + stripTrackingParams('mxc://example.org/abc?utm_source=x'), + 'mxc://example.org/abc?utm_source=x', + ); + assert.equal(stripTrackingParams('mailto:a@b.c?utm_source=x'), 'mailto:a@b.c?utm_source=x'); + }); + + it('returns unparseable input unchanged', () => { + assert.equal(stripTrackingParams('https://'), 'https://'); + assert.equal(stripTrackingParams('not a url'), 'not a url'); + }); + + it('is idempotent', () => { + const once = stripTrackingParams('https://x.com/user/status/1?s=20&t=abc'); + assert.equal(once, 'https://x.com/user/status/1'); + assert.equal(stripTrackingParams(once), once); + }); +}); + +describe('stripTrackingParamsInText', () => { + it('cleans every URL in a block of text and keeps trailing punctuation', () => { + const text = + 'see https://a.example/p?utm_source=x. and (https://b.example/?id=1&fbclid=2) plus https://c.example/?keep=1'; + assert.equal( + stripTrackingParamsInText(text), + 'see https://a.example/p. and (https://b.example/?id=1) plus https://c.example/?keep=1', + ); + }); + + it('returns the identical string when no URL changed', () => { + const text = 'nothing https://c.example/?keep=1 here'; + assert.equal(stripTrackingParamsInText(text), text); + }); +}); + +describe('stripTrackingParamsInHtml', () => { + it('unescapes & for parsing and re-escapes in the output', () => { + const html = + 'https://a.example/p?id=1&utm_source=x&page=2'; + assert.equal( + stripTrackingParamsInHtml(html), + 'https://a.example/p?id=1&page=2', + ); + }); + + it('leaves markup untouched when nothing is stripped', () => { + const html = '

hi link

'; + assert.equal(stripTrackingParamsInHtml(html), html); + }); +}); diff --git a/src/app/utils/urlTracking.ts b/src/app/utils/urlTracking.ts new file mode 100644 index 000000000..c2d01f794 --- /dev/null +++ b/src/app/utils/urlTracking.ts @@ -0,0 +1,233 @@ +/** + * [Gitea #103] Tracking-parameter stripping. + * + * Shared links routinely carry ad/analytics identifiers (`utm_*`, `fbclid`, + * YouTube `si=`, Amazon `ref=`…) that tie every recipient's click back to the + * person who shared the link. Everything here is pure and local: a URL goes in, + * the same URL minus known tracking params comes out. Unknown params are never + * touched, and anything that fails to parse is returned unchanged. + */ + +// Exact query-parameter names stripped on every host. +const GLOBAL_PARAMS = new Set([ + // Google Ads / Analytics + 'gclid', + 'gclsrc', + 'dclid', + 'gbraid', + 'wbraid', + 'srsltid', + '_ga', + '_gl', + // Meta / Instagram + 'fbclid', + 'igshid', + 'igsh', + // Microsoft / Bing + 'msclkid', + // X / Twitter + 'twclid', + // TikTok + 'ttclid', + // Yandex + 'yclid', + // Mailchimp + 'mc_cid', + 'mc_eid', + // HubSpot + '_hsenc', + '_hsmi', + 'hsCtaTracking', + // Marketo / Adobe + 'mkt_tok', + 's_kwcid', + 'ef_id', + // Vero / Wicked Reports / Omeda + 'vero_id', + 'vero_conv', + 'wickedid', + 'oly_anon_id', + 'oly_enc_id', + // Generic referrer tags + 'ref_src', + 'ref_url', + 'spm', +]); + +// Query-parameter name prefixes stripped on every host. +const GLOBAL_PREFIXES = ['utm_', 'pk_', 'piwik_', 'matomo_']; + +// Host-specific params that only act as tracking on that site. Matched by +// hostname suffix (so `www.youtube.com` and `m.youtube.com` both hit `youtube.com`). +const AMAZON_HOSTS = [ + 'amazon.com', + 'amazon.co.uk', + 'amazon.de', + 'amazon.ca', + 'amazon.fr', + 'amazon.co.jp', +]; + +const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[] }> = [ + { + hosts: ['youtube.com', 'youtu.be'], + params: ['si', 'feature', 'pp'], + }, + { + hosts: AMAZON_HOSTS, + // Affiliate + search-result telemetry only. `th`/`psc` pick the product + // variant that loads and are deliberately NOT stripped. + params: [ + 'ref', + 'ref_', + 'tag', + 'linkCode', + 'linkId', + 'crid', + 'sprefix', + 'qid', + 'sr', + 'dib', + 'dib_tag', + 'content-id', + ], + prefixes: ['pd_rd_', 'pf_rd_'], + }, + { + hosts: ['x.com', 'twitter.com'], + params: ['s', 't', 'ref_src'], + }, + { + hosts: ['spotify.com'], + params: ['si', 'nd'], + }, + { + hosts: ['reddit.com'], + params: ['share_id', 'rdt', 'context'], + }, + { + hosts: ['tiktok.com'], + params: ['_r', '_t', 'is_from_webapp', 'sender_device', 'web_id'], + }, + { + hosts: ['instagram.com'], + params: ['igsh', 'img_index'], + }, + { + hosts: ['threads.net'], + params: ['igshid'], + }, + { + hosts: ['bilibili.com'], + params: ['spm_id_from', 'vd_source'], + }, + { + hosts: ['ebay.com', 'ebay.co.uk', 'ebay.de'], + params: ['_trkparms', '_trksid', 'mkcid', 'mkevt', 'mkrid', 'campid', 'toolid', 'customid'], + }, +]; + +// Hosts whose URLs are never rewritten: Matrix permalinks encode room/event +// ids after a fragment and have no tracking, but a false positive would break +// navigation, so leave the whole family alone. +const SKIP_HOSTS = ['matrix.to']; + +const hostMatches = (hostname: string, suffixes: string[]): boolean => + suffixes.some((s) => hostname === s || hostname.endsWith(`.${s}`)); + +const isTrackingParam = (name: string, hostname: string): boolean => { + if (GLOBAL_PARAMS.has(name)) return true; + const lower = name.toLowerCase(); + if (GLOBAL_PREFIXES.some((p) => lower.startsWith(p))) return true; + return HOST_PARAMS.some( + (rule) => + hostMatches(hostname, rule.hosts) && + (rule.params.includes(name) || (rule.prefixes ?? []).some((p) => name.startsWith(p))), + ); +}; + +/** + * Remove known tracking query parameters from an `http(s)` URL. Returns the + * input unchanged when it is not an http(s) URL, cannot be parsed, or has + * nothing to strip — so callers can compare by identity to know whether + * anything happened. Never touches the fragment or the path (except Amazon's + * `/ref=…` path segment, which is a pure referrer tag). + */ +export const stripTrackingParams = (url: string): string => { + if (!/^https?:\/\//i.test(url)) return url; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + const hostname = parsed.hostname.toLowerCase(); + if (hostMatches(hostname, SKIP_HOSTS)) return url; + + let changed = false; + const params = parsed.searchParams; + Array.from(params.keys()).forEach((name) => { + if (isTrackingParam(name, hostname)) { + params.delete(name); + changed = true; + } + }); + + if (hostMatches(hostname, AMAZON_HOSTS)) { + const cleanedPath = parsed.pathname.replace(/\/ref=[^/]*/i, ''); + if (cleanedPath !== parsed.pathname) { + parsed.pathname = cleanedPath; + changed = true; + } + } + + if (!changed) return url; + // URLSearchParams re-serialises the remaining query; when it is now empty, + // drop the dangling `?` entirely. + if (params.toString() === '') parsed.search = ''; + return parsed.toString(); +}; + +// Matches an http(s) URL inside free text, stopping before whitespace, quotes, +// angle brackets or a closing paren/bracket. Trailing sentence punctuation is +// trimmed separately so "see https://a.b/?utm_source=x." keeps its full stop. +const URL_IN_TEXT = /https?:\/\/[^\s<>"'()[\]]+/gi; +const TRAILING_PUNCT = /[.,;:!?]+$/; + +/** + * Strip tracking params from every http(s) URL found in a block of text (the + * plaintext `body`, or a pasted clipboard string). Returns the input unchanged + * if nothing was stripped. + */ +export const stripTrackingParamsInText = (text: string): string => { + let changed = false; + const out = text.replace(URL_IN_TEXT, (match) => { + const punct = match.match(TRAILING_PUNCT)?.[0] ?? ''; + const url = punct ? match.slice(0, -punct.length) : match; + const cleaned = stripTrackingParams(url); + if (cleaned === url) return match; + changed = true; + return cleaned + punct; + }); + return changed ? out : text; +}; + +/** + * Same as `stripTrackingParamsInText` for an HTML string (`formatted_body`), + * where `&` inside attribute values and text is escaped as `&`. Each URL is + * unescaped before stripping and re-escaped after, so the surrounding markup is + * left byte-for-byte intact. + */ +export const stripTrackingParamsInHtml = (html: string): string => { + let changed = false; + const out = html.replace(URL_IN_TEXT, (match) => { + const punct = match.match(TRAILING_PUNCT)?.[0] ?? ''; + const raw = punct ? match.slice(0, -punct.length) : match; + const url = raw.replace(/&/g, '&'); + const cleaned = stripTrackingParams(url); + if (cleaned === url) return match; + changed = true; + return cleaned.replace(/&/g, '&') + punct; + }); + return changed ? out : html; +};