Repository navigation
Expand file tree
/
Copy pathmail-shield.js
More file actions
286 lines (260 loc) · 11.9 KB
/
Copy pathmail-shield.js
File metadata and controls
286 lines (260 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/* WardenOne — Copyright (C) 2026 iri
Licensed under the GNU General Public License v3 or later. See LICENSE.
Official source: https://github.com/iri-dev/WardenOne
Upstream filter-list attribution: CREDITS.md
Redistributing a modified copy? GPLv3 section 5(a) requires you to mark it as changed,
with the date, and to keep these notices intact. */
/* Mail Shield checks webmail pixels before loading. Gmail proxy URLs can preserve
the original tracker URL in a fragment that network rules never see. Replace
declared 1x1 pixels and lazy sources with transparent images; an in-flight
request may show ERR_ABORTED rather than be prevented. Provider-side proxy
fetches remain outside the browser's control. */
(() => {
'use strict';
const MAIL_SHIELD_VERSION = '1.0.2';
if (window.__wardenOneMailShieldReadyVersion === MAIL_SHIELD_VERSION) return;
if (window.__wardenOneMailShieldReadyVersion) {
try { window.__wardenOneMailShieldDispose?.(); } catch (_) {}
}
window.__wardenOneMailShieldVersion = MAIL_SHIELD_VERSION;
const PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const MARK = 'data-wo-mail-shield';
/* Hosts that proxy remote email images, and how to get back to the original.
Gmail appends the source after a '#'; the others are matched on shape alone
because they keep nothing recoverable. */
const PROXY_HOSTS = /(^|\.)(googleusercontent\.com|mail\.protonmail\.com|proton\.me)$/i;
/* Senders of open-tracking pixels, by the host that serves them. Deliberately
short and specific: a long list of "marketing" domains would start eating
the images people actually want to see. */
const TRACKER_HOSTS = new RegExp('(^|\\.)(' + [
'list-manage\\.com', 'mailchimp\\.com', 'mcusercontent\\.com',
'sendgrid\\.net', 'sendgrid\\.com', 'sparkpostmail\\.com', 'mailgun\\.org',
'hubspotemail\\.net', 'hs-sites\\.com', 'hubspotlinks\\.com',
'klaviyomail\\.com', 'iterable\\.com', 'braze\\.com', 'customeriomail\\.com',
'exct\\.net', 'exacttarget\\.com', 'mailjet\\.com', 'sailthru\\.com',
'omnisend\\.com', 'convertkit-mail\\.com', 'substackcdn\\.com',
'mailtrack\\.io', 'bananatag\\.com', 'yesware\\.com', 'streak\\.com',
'hubspot\\.com', 'getnotify\\.com', 'didtheyreadit\\.com',
'emltrk\\.com', 'openrateapp\\.com', 'pixel\\.app',
].join('|') + ')$', 'i');
/* Paths that only an open-beacon has. Each needs a query string as well: a
bare /open on a CDN is a page, not a pixel. */
const TRACKER_PATH = /(^|\/)(open|o|op|pixel|px|beacon|track|trk|wf\/open|imp|impression|read|seen)(\.(gif|png|jpg|jpeg|webp|aspx|php|ashx))?$/i;
/* A per-recipient token: this is what makes the pixel identify YOU rather than
just count opens. */
const RECIPIENT_TOKEN = /[A-Za-z0-9+/=_-]{24,}/;
const seen = new WeakSet();
let removed = 0;
let proxied = 0;
let reportTimer = 0;
let active = false;
let enabled = false;
let disposed = false;
let configGeneration = 0;
function attrNum(el, name) {
const raw = el.getAttribute(name);
if (raw == null) return NaN;
const n = parseInt(String(raw).replace(/px$/i, ''), 10);
return Number.isFinite(n) ? n : NaN;
}
/* The declared size, not the rendered one. */
function declaredTiny(img) {
const w = attrNum(img, 'width');
const h = attrNum(img, 'height');
if (Number.isFinite(w) && Number.isFinite(h) && w <= 3 && h <= 3) return true;
const style = String(img.getAttribute('style') || '');
const sw = style.match(/(?:^|;)\s*width\s*:\s*(\d+(?:\.\d+)?)px/i);
const sh = style.match(/(?:^|;)\s*height\s*:\s*(\d+(?:\.\d+)?)px/i);
if (sw && sh && parseFloat(sw[1]) <= 3 && parseFloat(sh[1]) <= 3) return true;
return false;
}
function declaredHidden(img) {
const style = String(img.getAttribute('style') || '').toLowerCase();
return /display\s*:\s*none/.test(style)
|| /visibility\s*:\s*hidden/.test(style)
|| /opacity\s*:\s*0(\s*;|\s*$|\.0)/.test(style);
}
/* Gmail keeps the source after a '#'. Everything before it is Google's. */
function originalUrl(raw) {
const url = String(raw || '');
const hash = url.indexOf('#');
if (hash > 0) {
const tail = url.slice(hash + 1);
if (/^https?:\/\/\S+$/i.test(tail)) return tail;
}
return url;
}
function parse(raw) {
try { return new URL(String(raw), location.href); } catch (_) { return null; }
}
function classify(img) {
/* Lazy attributes first: the real URL often lives there until the client
decides to load it, and cleaning only src would be undone a moment later. */
const currentSrc = img.getAttribute('src') || '';
const raw = (currentSrc !== PIXEL ? currentSrc : '')
|| img.getAttribute('data-src')
|| img.getAttribute('data-original-src')
|| img.getAttribute('data-lazy-src')
|| currentSrc;
if (!raw) return null;
/* Never touch what the page made itself, an attachment, or an inline image:
none of those reach a third party. Strictly this is already covered by the
http(s)-only check below -- it is kept because it states the intent at the
point someone would look for it, and because widening that check later
should not silently start rewriting attachments. */
if (/^(data:|blob:|cid:|about:)/i.test(raw)) return null;
const source = originalUrl(raw);
const u = parse(source);
if (!u || !/^https?:$/i.test(u.protocol)) return null;
const wasProxied = source !== raw;
if (declaredTiny(img)) return { why: 'a 1x1 image, which no one can see', u, wasProxied };
if (declaredHidden(img)) return { why: 'an image deliberately hidden from view', u, wasProxied };
if (TRACKER_HOSTS.test(u.hostname)) return { why: 'served by a known open-tracking service', u, wasProxied };
const path = u.pathname.replace(/\.[a-z0-9]+$/i, '');
if (TRACKER_PATH.test(u.pathname) && u.search && RECIPIENT_TOKEN.test(u.search + path)) {
return { why: 'an open-beacon carrying an identifier for you', u, wasProxied };
}
return null;
}
function neutralise(img, verdict) {
if (img.getAttribute(MARK) && img.getAttribute('src') === PIXEL
&& ['data-src', 'data-original-src', 'data-lazy-src'].every((attr) =>
!img.hasAttribute(attr) || img.getAttribute(attr) === PIXEL)) return false;
try {
img.setAttribute(MARK, verdict.wasProxied ? 'proxied' : 'direct');
/* Order matters: clear the lazy attributes first, or the client swaps the
real URL back in after we have cleaned src. */
for (const attr of ['srcset', 'data-src', 'data-original-src', 'data-lazy-src', 'data-srcset']) {
if (img.hasAttribute(attr)) img.setAttribute(attr, PIXEL);
}
img.setAttribute('src', PIXEL);
/* Same declared size, so a spacer GIF keeps spacing exactly as before. */
img.setAttribute('alt', img.getAttribute('alt') || '');
img.title = 'Tracking pixel neutralised by WardenOne — ' + verdict.why;
} catch (_) { return false; }
removed++;
if (verdict.wasProxied) proxied++;
scheduleReport(verdict);
return true;
}
let lastWhy = '';
function scheduleReport(verdict) {
lastWhy = verdict.why;
if (reportTimer) return;
reportTimer = setTimeout(() => {
reportTimer = 0;
try {
chrome.runtime.sendMessage({
kind: 'rg-block',
type: 'blocked_beacon',
detail: {
matched: removed + ' email tracking pixel' + (removed === 1 ? '' : 's'),
why: 'stopped ' + removed + ' tracking pixel' + (removed === 1 ? '' : 's')
+ ' in webmail — ' + lastWhy
+ (proxied ? ' (' + proxied + ' recovered from behind the provider proxy)' : ''),
},
}, () => { void chrome.runtime.lastError; });
} catch (_) {}
}, 900);
}
function sweep(root) {
let node = root;
if (!node || node.nodeType !== 1) return;
const images = node.tagName === 'IMG' ? [node] : node.querySelectorAll('img');
for (const img of images) {
if (seen.has(img) && img.getAttribute('src') === PIXEL
&& ['data-src', 'data-original-src', 'data-lazy-src'].every((attr) =>
!img.hasAttribute(attr) || img.getAttribute(attr) === PIXEL)) continue;
seen.add(img);
const verdict = classify(img);
if (verdict) neutralise(img, verdict);
}
}
const observer = new MutationObserver((records) => {
for (const r of records) {
if (r.type === 'attributes' && r.target && r.target.tagName === 'IMG') {
/* The client swapping a lazy URL into src. seen-ness does not apply:
this is a new URL on an element already looked at. */
const verdict = classify(r.target);
if (verdict) neutralise(r.target, verdict);
continue;
}
for (const added of (r.addedNodes || [])) sweep(added);
}
});
function start() {
if (disposed || !enabled || active) return;
if (document.documentElement) {
active = true;
sweep(document.documentElement);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'data-src', 'data-original-src', 'data-lazy-src', 'srcset'],
});
}
}
function setEnabled(wantEnabled) {
if (disposed) return;
enabled = wantEnabled === true;
if (!enabled) {
active = false;
observer.disconnect();
if (reportTimer) { clearTimeout(reportTimer); reportTimer = 0; }
return;
}
if (document.documentElement) start();
else document.addEventListener('DOMContentLoaded', start, { once: true });
}
function refreshConfig() {
const generation = ++configGeneration;
askContentConfig(['overrides'], (res) => {
void chrome.runtime.lastError;
if (disposed || generation !== configGeneration) return;
const cfg = (res && res.overrides) || {};
setEnabled(cfg.enabled !== false && cfg.mailTrackingShield !== false);
});
}
const onMessage = (msg) => {
if (!msg || disposed) return;
if (msg.kind === 'mail-shield-state') {
configGeneration++;
setEnabled(msg.enabled === true);
} else if (msg.kind === 'config-update' && msg.overrides) {
configGeneration++;
setEnabled(msg.overrides.enabled !== false && msg.overrides.mailTrackingShield !== false);
} else if (msg.kind === 'content-config-refresh') {
setTimeout(refreshConfig, 0);
}
};
try { chrome.runtime.onMessage.addListener(onMessage); } catch (_) {}
window.__wardenOneMailShieldDispose = () => {
if (disposed) return;
disposed = true;
active = false;
configGeneration++;
observer.disconnect();
if (reportTimer) { clearTimeout(reportTimer); reportTimer = 0; }
try { chrome.runtime.onMessage.removeListener(onMessage); } catch (_) {}
window.__wardenOneMailShieldReadyVersion = '';
};
/* The settings snapshot, asked for through the bridge when it has run here (MV3-04). The
isolated bridge owns acquisition: it retries when the worker dies mid-reply and answers from
the snapshot it already holds when only the switches are needed, so this makes a one-shot
request of its own only when no bridge is present in this frame. */
function askContentConfig(need, cb) {
try {
const viaBridge = window.__wardenOneContentConfigRequest;
if (typeof viaBridge === 'function') { viaBridge(need, cb); return; }
} catch (_) {}
try { chrome.runtime.sendMessage({ kind: 'content-config-get', need: need }, cb); } catch (_) { try { cb(null); } catch (__) {} }
}
/* Off unless the toggle says otherwise, asked until answered (MV3-04). A mail client is the
last place to act on a stale assumption about what the reader wanted. */
refreshConfig();
/* For the tests, and for anyone reading the page in a console. */
window.__wardenOneMailShieldApi = { classify, originalUrl, declaredTiny, declaredHidden,
count: () => removed, active: () => active };
window.__wardenOneMailShieldReadyVersion = MAIL_SHIELD_VERSION;
})();