-
Notifications
You must be signed in to change notification settings - Fork 25
/
contentscript.js
619 lines (586 loc) · 23.5 KB
/
contentscript.js
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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
document.addEventListener('mousedown', handlePointerPress, true);
document.addEventListener('touchstart', handlePointerPress, true);
document.addEventListener('click', handleClick, true);
var scriptCspNonce;
var needsCspNonce = typeof browser !== 'undefined'; // Firefox.
var preferenceObservers = [];
setupAggresiveUglyLinkPreventer();
var forceNoReferrer = true;
var noping = true;
if (typeof chrome == 'object' && chrome.storage) {
(chrome.storage.sync || chrome.storage.local).get({
forceNoReferrer: true,
// From version 4.7 until 4.11, the preference was the literal value of
// the referrer policy.
referrerPolicy: 'no-referrer',
noping: true,
}, function(items) {
if (items) {
// Migration code (to be removed in the future).
if (items.referrerPolicy === '') {
// User explicitly allowed referrers to be sent, respect that.
items.forceNoReferrer = false;
}
forceNoReferrer = items.forceNoReferrer;
noping = items.noping;
callPreferenceObservers();
}
});
chrome.storage.onChanged.addListener(function(changes) {
if (changes.forceNoReferrer) {
forceNoReferrer = changes.forceNoReferrer.newValue;
}
if (changes.noping) {
noping = changes.noping.newValue;
}
callPreferenceObservers();
});
}
function callImmediatelyAndOnPreferenceUpdate(callback) {
callback();
preferenceObservers.push(callback);
}
function callPreferenceObservers() {
// This method is usually once, and occasionally more than once if the user
// changes a preference. For simplicity we don't check whether a pref was
// changed before calling a callback - these are cheap anyway.
preferenceObservers.forEach(function(callback) {
callback();
});
}
function getReferrerPolicy() {
return forceNoReferrer ? 'origin' : '';
}
function updateReferrerPolicy(a) {
if (a.referrerPolicy === 'no-referrer') {
// "no-referrer" is more privacy-friendly than "origin".
return;
}
var referrerPolicy = getReferrerPolicy();
if (referrerPolicy) {
a.referrerPolicy = referrerPolicy;
}
}
function handlePointerPress(e) {
var a = e.target;
while (a && !a.href) {
a = a.parentElement;
}
if (!a) {
return;
}
var inlineMousedown = a.getAttribute('onmousedown');
// return rwt(....); // E.g Google search results.
// return google.rwt(...); // E.g. sponsored search results
// return google.arwt(this); // E.g. sponsored search results (dec 2016).
if (inlineMousedown && /\ba?rwt\(/.test(inlineMousedown)) {
a.removeAttribute('onmousedown');
// Just in case:
a.removeAttribute('ping');
// In Chrome, removing onmousedown during event dispatch does not
// prevent the inline listener from running... So we have to cancel
// event propagation just in case.
e.stopImmediatePropagation();
}
if (noping) {
a.removeAttribute('ping');
}
var realLink = getRealLinkFromGoogleUrl(a);
if (realLink) {
a.href = realLink;
// Sometimes, two fixups are needed, on old mobile user agents:
// /url?q=https://googleweblight.com/fp?u=... -> ...
realLink = getRealLinkFromGoogleUrl(a);
if (realLink) {
a.href = realLink;
}
}
updateReferrerPolicy(a);
if (e.eventPhase === Event.CAPTURING_PHASE) {
// Our event listener runs first, to sanitize the link.
// But the page may have an event handler that modifies the link again.
// We can append a listener to the bubbling phase of the (current)
// event dispatch to fix the link up again, provided that the page did
// not call stopPropagation() or stopImmediatePropagation().
var eventOptions = { capture: false, once: true };
a.addEventListener(e.type, handlePointerPress, eventOptions);
document.addEventListener(e.type, handlePointerPress, eventOptions);
}
}
// This is specifically designed for catching clicks in Gmail.
// Gmail binds a click handler to a <div> and cancels the event after opening
// a window with an ugly URL. It uses a blank window + meta refresh in Firefox,
// which is too crazy to patch. So we just make sure that the browser's default
// click handler is activated (=open link in new tab).
// The entry point for this crazy stuff is shown in my comment at
// https://github.com/Rob--W/dont-track-me-google/issues/2
function handleClick(e) {
if (e.button !== 0) {
return;
}
var a = e.target;
while (a && !a.href) {
a = a.parentElement;
}
if (!a) {
return;
}
if (a.dataset && a.dataset.url) {
var realLink = getSanitizedIntentUrl(a.dataset.url);
if (realLink) {
a.dataset.url = realLink;
}
}
if (!location.hostname.startsWith('mail.')) {
// This hack was designed for Gmail, but broke other Google sites:
// - https://github.com/Rob--W/dont-track-me-google/issues/6
// - https://github.com/Rob--W/dont-track-me-google/issues/19
// So let's disable it for every domain except Gmail.
return;
}
// TODO: Consider using a.baseURI instead of location in case Gmail ever
// starts using <base href>?
if (a.origin === location.origin) {
// Same-origin link.
// E.g. an in-page navigation at Google Docs (#...)
// or an attachment at Gmail (https://mail.google.com/mail/u/0?ui=2&...)
return;
}
if (a.protocol !== 'http:' &&
a.protocol !== 'https:' &&
a.protocol !== 'ftp:') {
// Be conservative and don't block too much. E.g. Gmail has special
// handling for mailto:-URLs, and using stopPropagation now would
// cause mailto:-links to be opened by the platform's default mailto
// handler instead of Gmail's handler (=open in new window).
return;
}
if (a.target === '_blank') {
e.stopPropagation();
updateReferrerPolicy(a);
}
}
/**
* @param {URL|HTMLHyperlinkElementUtils} a
* @returns {String} the real URL if the given link is a Google redirect URL.
*/
function getRealLinkFromGoogleUrl(a) {
if (a.protocol !== 'https:' && a.protocol !== 'http:') {
return;
}
var url;
if ((a.hostname === location.hostname || a.hostname === 'www.google.com') &&
(a.pathname === '/url' || a.pathname === '/local_url' ||
a.pathname === '/searchurl/rr.html' ||
a.pathname === '/linkredirect')) {
// Google Maps / Dito (/local_url?q=<url>)
// Mobile (/url?q=<url>)
// Google Meet's chat (/linkredirect?authuser=0&dest=<url>)
url = /[?&](?:q|url|dest)=((?:https?|ftp)[%:][^&]+)/.exec(a.search);
if (url) {
return decodeURIComponent(url[1]);
}
// Help pages, e.g. safe browsing (/url?...&q=%2Fsupport%2Fanswer...)
url = /[?&](?:q|url)=((?:%2[Ff]|\/)[^&]+)/.exec(a.search);
if (url) {
return a.origin + decodeURIComponent(url[1]);
}
// Redirect pages for Android intents (/searchurl/rr.html#...&url=...)
// rr.html only supports http(s). So restrict to http(s) only.
url = /[#&]url=(https?[:%][^&]+)/.exec(a.hash);
if (url) {
return decodeURIComponent(url[1]);
}
}
// Google Search with old mobile UA (e.g. Firefox 41).
if (a.hostname === 'googleweblight.com' && a.pathname === '/fp') {
url = /[?&]u=((?:https?|ftp)[%:][^&]+)/.exec(a.search);
if (url) {
return decodeURIComponent(url[1]);
}
}
}
/**
* @param {string} intentUrl
* @returns {string|undefined} The sanitized intent:-URL if it was an intent URL
* with embedded tracking link.
*/
function getSanitizedIntentUrl(intentUrl) {
if (!intentUrl.startsWith('intent:')) {
return;
}
// https://developer.chrome.com/multidevice/android/intents#syntax
var BROWSER_FALLBACK_URL = ';S.browser_fallback_url=';
var indexStart = intentUrl.indexOf(BROWSER_FALLBACK_URL);
if (indexStart === -1) {
return;
}
indexStart += BROWSER_FALLBACK_URL.length;
var indexEnd = intentUrl.indexOf(';', indexStart);
indexEnd = indexEnd === -1 ? intentUrl.length : indexEnd;
var url = decodeURIComponent(intentUrl.substring(indexStart, indexEnd));
var realUrl = getRealLinkFromGoogleUrl(newURL(url));
if (!realUrl) {
return;
}
return intentUrl.substring(0, indexStart) +
encodeURIComponent(realUrl) +
intentUrl.substring(indexEnd);
}
/**
* Intercept the .href setter in the page so that the page can never change the
* URL to a tracking URL. Just intercepting mousedown/touchstart is not enough
* because e.g. on Google Maps, the page rewrites the URL in the contextmenu
* event at the bubbling event stage and then stops the event propagation. So
* there is no event-driven way to fix the URL. The DOMAttrModified event could
* be used, but the event is deprecated, so not a viable long-term solution.
*/
function setupAggresiveUglyLinkPreventer() {
// This content script runs as document_start, so we can have some assurance
// that the methods in the page are reliable.
var s = document.createElement('script');
if (getScriptCspNonce()) {
s.setAttribute('nonce', scriptCspNonce);
} else if (document.readyState !== 'complete' && needsCspNonce) {
// In Firefox, a page's CSP is enforced for content scripts, so we need
// to wait for the document to be loaded (we may be at document_start)
// and find a fitting CSP nonce.
findScriptCspNonce(setupAggresiveUglyLinkPreventer);
return;
}
s.textContent = '(' + function(getRealLinkFromGoogleUrl) {
var proto = HTMLAnchorElement.prototype;
// The link target can be changed in many ways, but let's only consider
// the .href attribute since it's probably the only used setter.
var hrefProp = Object.getOwnPropertyDescriptor(proto, 'href');
var hrefGet = Function.prototype.call.bind(hrefProp.get);
var hrefSet = Function.prototype.call.bind(hrefProp.set);
Object.defineProperty(proto, 'href', {
configurable: true,
enumerable: true,
get() {
return hrefGet(this);
},
set(v) {
hrefSet(this, v);
try {
v = getRealLinkFromGoogleUrl(this);
if (v) {
hrefSet(this, v);
}
} catch (e) {
// Not expected to happen, but don't break the setter if for
// some reason the (hostile) page broke the link APIs.
}
updateReferrerPolicy(this);
},
});
function replaceAMethod(methodName, methodFunc) {
// Overwrite the methods without triggering setters, because that
// may inadvertently overwrite the prototype, as observed in
// https://github.com/Rob--W/dont-track-me-google/issues/52#issuecomment-1596207655
Object.defineProperty(proto, methodName, {
configurable: true,
// All methods that we are overriding are not part of
// HTMLAnchorElement.prototype, but inherit.
enumerable: false,
writable: true,
value: methodFunc,
});
}
// proto inherits Element.prototype.setAttribute:
var setAttribute = Function.prototype.call.bind(proto.setAttribute);
replaceAMethod('setAttribute', function(name, value) {
// Attribute names are not case-sensitive, but weird capitalizations
// are unlikely, so only check all-lowercase and all-uppercase.
if (name === 'href' || name === 'HREF') {
this.href = value;
} else {
setAttribute(this, name, value);
}
});
// proto inherits EventTarget.prototype.dispatchEvent:
var aDispatchEvent = Function.prototype.apply.bind(proto.dispatchEvent);
replaceAMethod('dispatchEvent', function() {
updateReferrerPolicy(this);
return aDispatchEvent(this, arguments);
});
// proto inherits HTMLElement.prototype.click:
var aClick = Function.prototype.apply.bind(proto.click);
replaceAMethod('click', function() {
updateReferrerPolicy(this);
return aClick(this, arguments);
});
var rpProp = Object.getOwnPropertyDescriptor(proto, 'referrerPolicy');
var rpGet = Function.prototype.call.bind(rpProp.get);
var rpSet = Function.prototype.call.bind(rpProp.set);
var currentScript = document.currentScript;
var getReferrerPolicy = Object.getOwnPropertyDescriptor(
HTMLScriptElement.prototype,
'referrerPolicy'
).get.bind(currentScript);
function updateReferrerPolicy(a) {
try {
if (rpGet(a) === 'no-referrer') {
// "no-referrer" is more privacy-friendly than "origin".
return;
}
var referrerPolicy = getReferrerPolicy();
if (referrerPolicy) {
rpSet(a, referrerPolicy);
}
} catch (e) {
// Not expected to happen, but don't break callers if it happens
// anyway.
}
}
currentScript.dataset.jsEnabled = 1;
} + ')(' + getRealLinkFromGoogleUrl + ');';
callImmediatelyAndOnPreferenceUpdate(function forceNoReferrerChanged() {
// Send the desired referrerPolicy value to the injected script.
s.referrerPolicy = getReferrerPolicy();
});
(document.head || document.documentElement).appendChild(s);
s.remove();
if (!s.dataset.jsEnabled) {
cleanLinksWhenJsIsDisabled();
if (!needsCspNonce) {
needsCspNonce = true;
// This is not Firefox, but the script was blocked. Perhaps a CSP
// nonce is needed anyway.
findScriptCspNonce(function() {
if (scriptCspNonce) {
setupAggresiveUglyLinkPreventer();
}
});
}
} else {
// Scripts enabled (not blocked by CSP), run other inline scripts.
blockTrackingBeacons();
overwriteWindowOpen();
if (location.hostname === 'docs.google.com') {
// Google Docs have simple non-JS interfaces where the ugly links
// are hard-coded in the HTML. Remove them (#51).
// https://docs.google.com/document/d/.../mobilebasic
// https://docs.google.com/spreadsheets/d/.../htmlview
cleanLinksWhenJsIsDisabled();
}
}
}
// Block sendBeacon requests with destination /gen_204, because Google
// asynchronously sends beacon requests in response to mouse events on links:
// https://github.com/Rob--W/dont-track-me-google/issues/20
//
// This implementation also blocks other forms of tracking via gen_204 as a side
// effect. That is not fully intentional, but given the lack of obvious ways to
// discern such link-tracking events from others, I will block all of them.
function blockTrackingBeacons() {
var s = document.createElement('script');
if (getScriptCspNonce()) {
s.setAttribute('nonce', scriptCspNonce);
}
s.textContent = '(' + function() {
var navProto = window.Navigator.prototype;
var navProtoSendBeacon = navProto.sendBeacon;
if (!navProtoSendBeacon) {
return;
}
var sendBeacon = Function.prototype.apply.bind(navProtoSendBeacon);
// Blocks the following:
// gen_204
// /gen_204
// https://www.google.com/gen_204
var isTrackingUrl = RegExp.prototype.test.bind(
/^(?:(?:https?:\/\/[^\/]+)?\/)?gen_204(?:[?#]|$)/);
navProto.sendBeacon = function(url, data) {
if (isTrackingUrl(url) && isNoPingEnabled()) {
// Lie that the data has been transmitted to avoid fallbacks.
return true;
}
return sendBeacon(this, arguments);
};
var currentScript = document.currentScript;
var getElementId = Object.getOwnPropertyDescriptor(
Element.prototype,
'id'
).get.bind(currentScript);
function isNoPingEnabled() {
try {
return getElementId() !== '_dtmg_do_not_touch_ping';
} catch (e) {
return true;
}
}
} + ')();';
callImmediatelyAndOnPreferenceUpdate(function nopingChanged() {
// Send the noping value to the injected script. The "id" property is
// mirrored and can have an arbitrary (string) value, so we use that:
s.id = noping ? '' : '_dtmg_do_not_touch_ping';
});
(document.head || document.documentElement).appendChild(s);
s.remove();
}
// Google sometimes uses window.open() to open ugly links.
// https://github.com/Rob--W/dont-track-me-google/issues/18
// https://github.com/Rob--W/dont-track-me-google/issues/41
function overwriteWindowOpen() {
var s = document.createElement('script');
if (getScriptCspNonce()) {
s.setAttribute('nonce', scriptCspNonce);
}
s.textContent = '(' + function() {
var open = window.open;
window.open = function(url, windowName, windowFeatures) {
var isBlankUrl = !url || url === "about:blank";
try {
if (!isBlankUrl) {
var a = document.createElement('a');
// Triggers getRealLinkFromGoogleUrl via the href setter in
// setupAggresiveUglyLinkPreventer.
a.href = url;
url = a.href;
// The origin check exists to avoid adding "noreferrer" to
// same-origin popups. That implies noopener and causes
// https://github.com/Rob--W/dont-track-me-google/issues/43
// And allow any Google domain to support auth popups:
// https://github.com/Rob--W/dont-track-me-google/issues/45
// And don't bother editing the list if it already contains
// "opener" (it would be disabled by "noreferrer").
if (a.referrerPolicy && a.origin !== location.origin &&
!/\.google\.([a-z]+)$/.test(a.hostname) &&
!/\bopener|noreferrer/.test(windowFeatures)) {
if (windowFeatures) {
windowFeatures += ',';
} else {
windowFeatures = '';
}
windowFeatures += 'noreferrer';
}
}
} catch (e) {
// Not expected to happen, but don't break callers if it does.
}
var win = open(url, windowName, windowFeatures);
try {
if (isBlankUrl && win) {
// In Google Docs, sometimes a blank document is opened,
// and document.write is used to insert a redirector.
// https://github.com/Rob--W/dont-track-me-google/issues/41
var doc = win.document;
var docWrite = win.Function.prototype.call.bind(doc.write);
doc.write = function(markup) {
try {
markup = fixupDocMarkup(markup);
} catch (e) {
// Not expected, but don't break callers otherwise.
}
return docWrite(this, markup);
};
}
} catch (e) {
// Not expected to happen, but don't break callers if it does.
}
return win;
};
function fixupDocMarkup(html) {
html = html || '';
html += '';
return html.replace(
/<meta [^>]*http-equiv=(["']?)refresh\1[^>]*>/i,
function(m) {
var doc = new DOMParser().parseFromString(m, 'text/html');
var meta = doc.querySelector('meta[http-equiv=refresh]');
return meta && fixupMetaUrl(meta) || m;
});
}
function fixupMetaUrl(meta) {
var parts = /^(\d*;\s*url=)(.+)$/i.exec(meta.content);
if (!parts) {
return;
}
var metaPrefix = parts[1];
var url = parts[2];
var a = document.createElement('a');
// Triggers getRealLinkFromGoogleUrl via the href setter in
// setupAggresiveUglyLinkPreventer.
a.href = url;
url = a.href;
meta.content = metaPrefix + url;
var html = meta.outerHTML;
if (a.referrerPolicy) {
// Google appears to already append the no-referrer
// meta tag, but add one just in case it doesn't.
html = '<meta name="referrer" content="no-referrer">' + html;
}
return html;
}
} + ')();';
(document.head || document.documentElement).appendChild(s);
s.remove();
}
function cleanLinksWhenJsIsDisabled() {
// When JavaScript is disabled, Google sets the "href" attribute's value to
// an ugly URL. Although the link is rewritten on click, we still need to
// rewrite the link even earlier because otherwise the ugly URL is shown in
// the tooltip upon hover.
if (document.readyState == 'complete') {
cleanAllLinks();
return;
}
// When JS is disabled, the links won't change after the document finishes
// loading. Until the DOM has finished loading, use the mouseover event to
// beautify links (the DOMContentLoaded may be delayed on slow networks).
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('DOMContentLoaded', function() {
document.removeEventListener('mouseover', handleMouseOver);
cleanAllLinks();
}, {once: true});
function cleanAllLinks() {
var as = document.querySelectorAll('a[href]');
for (var i = 0; i < as.length; ++i) {
var href = getRealLinkFromGoogleUrl(as[i]);
if (href) {
as[i].href = href;
}
}
}
function handleMouseOver(e) {
var a = e.target;
var href = a.href && getRealLinkFromGoogleUrl(a);
if (href) {
a.href = href;
}
}
}
function getScriptCspNonce() {
var scripts = document.querySelectorAll('script[nonce]');
for (var i = 0; i < scripts.length && !scriptCspNonce; ++i) {
scriptCspNonce = scripts[i].nonce;
}
return scriptCspNonce;
}
function findScriptCspNonce(callback) {
var timer;
function checkDOM() {
if (getScriptCspNonce() || document.readyState === 'complete') {
document.removeEventListener('DOMContentLoaded', checkDOM, true);
if (timer) {
clearTimeout(timer);
}
callback();
return;
}
timer = setTimeout(checkDOM, 50);
}
document.addEventListener('DOMContentLoaded', checkDOM, true);
checkDOM();
}
function newURL(href) {
try {
return new URL(href);
} catch (e) {
var a = document.createElement('a');
a.href = href;
return a;
}
}