You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
548 lines
18 KiB
548 lines
18 KiB
/*******************************************************************************
|
|
|
|
uMatrix - a browser extension to black/white list requests.
|
|
Copyright (C) 2013-present Raymond Hill
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see {http://www.gnu.org/licenses/}.
|
|
|
|
Home: https://github.com/gorhill/uMatrix
|
|
*/
|
|
|
|
// rhill 2013-12-14: the whole cookie management has been rewritten so as
|
|
// to avoid having to call chrome API whenever a single cookie changes, and
|
|
// to record cookie for a web page *only* when its value changes.
|
|
// https://github.com/gorhill/httpswitchboard/issues/79
|
|
|
|
"use strict";
|
|
|
|
/******************************************************************************/
|
|
|
|
// Isolate from global namespace
|
|
|
|
// Use cached-context approach rather than object-based approach, as details
|
|
// of the implementation do not need to be visible
|
|
|
|
µMatrix.cookieHunter = (( ) => {
|
|
|
|
/******************************************************************************/
|
|
|
|
const µm = µMatrix;
|
|
|
|
const recordPageCookiesQueue = new Map();
|
|
const removeCookieQueue = new Set();
|
|
const cookieDict = new Map();
|
|
const cookieEntryJunkyard = [];
|
|
const processRemoveQueuePeriod = 2 * 60 * 1000;
|
|
const processCleanPeriod = 10 * 60 * 1000;
|
|
let processPageRecordQueueTimer = null;
|
|
|
|
/******************************************************************************/
|
|
|
|
const CookieEntry = class {
|
|
constructor(cookie) {
|
|
this.usedOn = new Set();
|
|
this.init(cookie);
|
|
}
|
|
|
|
init(cookie) {
|
|
this.secure = cookie.secure;
|
|
this.session = cookie.session;
|
|
this.anySubdomain = cookie.domain.charAt(0) === '.';
|
|
this.hostname = this.anySubdomain ? cookie.domain.slice(1) : cookie.domain;
|
|
this.domain = µm.URI.domainFromHostname(this.hostname) || this.hostname;
|
|
this.path = cookie.path;
|
|
this.name = cookie.name;
|
|
this.value = cookie.value;
|
|
this.tstamp = Date.now();
|
|
this.usedOn.clear();
|
|
return this;
|
|
}
|
|
|
|
// Reset any property which indirectly consumes memory
|
|
dispose() {
|
|
this.hostname = '';
|
|
this.domain = '';
|
|
this.path = '';
|
|
this.name = '';
|
|
this.value = '';
|
|
this.usedOn.clear();
|
|
return this;
|
|
}
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const addCookieToDict = function(cookie) {
|
|
const cookieKey = cookieKeyFromCookie(cookie);
|
|
let cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) {
|
|
cookieEntry = cookieEntryJunkyard.pop();
|
|
if ( cookieEntry ) {
|
|
cookieEntry.init(cookie);
|
|
} else {
|
|
cookieEntry = new CookieEntry(cookie);
|
|
}
|
|
cookieDict.set(cookieKey, cookieEntry);
|
|
}
|
|
return cookieEntry;
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const removeCookieFromDict = function(cookieKey) {
|
|
const cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) { return false; }
|
|
cookieDict.delete(cookieKey);
|
|
if ( cookieEntryJunkyard.length < 25 ) {
|
|
cookieEntryJunkyard.push(cookieEntry.dispose());
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const cookieKeyBuilder = [
|
|
'', // 0 = scheme
|
|
'://',
|
|
'', // 2 = domain
|
|
'', // 3 = path
|
|
'{',
|
|
'', // 5 = persistent or session
|
|
'-cookie:',
|
|
'', // 7 = name
|
|
'}'
|
|
];
|
|
|
|
const cookieKeyFromCookie = function(cookie) {
|
|
const cb = cookieKeyBuilder;
|
|
cb[0] = cookie.secure ? 'https' : 'http';
|
|
cb[2] = cookie.domain.charAt(0) === '.' ? cookie.domain.slice(1) : cookie.domain;
|
|
cb[3] = cookie.path;
|
|
cb[5] = cookie.session ? 'session' : 'persistent';
|
|
cb[7] = cookie.name;
|
|
return cb.join('');
|
|
};
|
|
|
|
const cookieKeyFromCookieURL = function(url, type, name) {
|
|
const µmuri = µm.URI.set(url);
|
|
const cb = cookieKeyBuilder;
|
|
cb[0] = µmuri.scheme;
|
|
cb[2] = µmuri.hostname;
|
|
cb[3] = µmuri.path;
|
|
cb[5] = type;
|
|
cb[7] = name;
|
|
return cb.join('');
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const cookieURLFromCookieEntry = function(entry) {
|
|
if ( !entry ) {
|
|
return '';
|
|
}
|
|
return (entry.secure ? 'https://' : 'http://') + entry.hostname + entry.path;
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const cookieMatchDomains = function(cookieKey, allHostnamesString) {
|
|
const cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) { return false; }
|
|
if ( allHostnamesString.indexOf(' ' + cookieEntry.hostname + ' ') < 0 ) {
|
|
if ( !cookieEntry.anySubdomain ) { return false; }
|
|
if ( allHostnamesString.indexOf('.' + cookieEntry.hostname + ' ') < 0 ) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
// Look for cookies to record for a specific web page
|
|
|
|
const recordPageCookiesAsync = function(pageStore) {
|
|
// Store the page stats objects so that it doesn't go away
|
|
// before we handle the job.
|
|
// rhill 2013-10-19: pageStore could be nil, for example, this can
|
|
// happens if a file:// ... makes an xmlHttpRequest
|
|
if ( !pageStore ) { return; }
|
|
recordPageCookiesQueue.set(pageStore.pageUrl, pageStore);
|
|
if ( processPageRecordQueueTimer !== null ) { return; }
|
|
processPageRecordQueueTimer = vAPI.setTimeout(processPageRecordQueue, 1000);
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const recordPageCookie = (( ) => {
|
|
const queue = new Map();
|
|
const cookieLogEntryBuilder = [ '', '{', '', '-cookie:', '', '}' ];
|
|
let queueTimer;
|
|
|
|
const process = function() {
|
|
queueTimer = undefined;
|
|
for ( const qentry of queue ) {
|
|
const pageStore = qentry[0];
|
|
if ( pageStore.tabId === '' ) { continue; }
|
|
for ( const cookieKey of qentry[1] ) {
|
|
let cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) { continue; }
|
|
let blocked = µm.mustBlock(
|
|
pageStore.pageHostname,
|
|
cookieEntry.hostname,
|
|
'cookie'
|
|
);
|
|
// https://github.com/gorhill/httpswitchboard/issues/60
|
|
// Need to URL-encode cookie name
|
|
cookieLogEntryBuilder[0] =
|
|
cookieURLFromCookieEntry(cookieEntry);
|
|
cookieLogEntryBuilder[2] =
|
|
cookieEntry.session ? 'session' : 'persistent';
|
|
cookieLogEntryBuilder[4] =
|
|
encodeURIComponent(cookieEntry.name);
|
|
const cookieURL = cookieLogEntryBuilder.join('');
|
|
pageStore.recordRequest('cookie', cookieURL, blocked);
|
|
if ( µm.logger.enabled ) {
|
|
µm.filteringContext
|
|
.duplicate()
|
|
.fromTabId(pageStore.tabId)
|
|
.setType('cookie')
|
|
.setURL(cookieURL)
|
|
.setFilter(blocked)
|
|
.setRealm('network')
|
|
.toLogger();
|
|
}
|
|
cookieEntry.usedOn.add(pageStore.pageHostname);
|
|
if ( !blocked ) { continue; }
|
|
if ( µm.userSettings.deleteCookies ) {
|
|
removeCookieAsync(cookieKey);
|
|
}
|
|
µm.updateToolbarIcon(pageStore.tabId);
|
|
}
|
|
}
|
|
queue.clear();
|
|
};
|
|
|
|
return function(pageStore, cookieKey) {
|
|
if ( vAPI.isBehindTheSceneTabId(pageStore.tabId) ) { return; }
|
|
let entry = queue.get(pageStore);
|
|
if ( entry === undefined ) {
|
|
queue.set(pageStore, (entry = new Set()));
|
|
}
|
|
if ( entry.has(cookieKey) ) { return; }
|
|
entry.add(cookieKey);
|
|
if ( queueTimer === undefined ) {
|
|
queueTimer = vAPI.setTimeout(process, 277);
|
|
}
|
|
};
|
|
})();
|
|
|
|
/******************************************************************************/
|
|
|
|
// Candidate for removal
|
|
|
|
const removeCookieAsync = function(cookieKey) {
|
|
removeCookieQueue.add(cookieKey);
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const browserCookieRemove = function(cookieEntry, name) {
|
|
const url = cookieURLFromCookieEntry(cookieEntry);
|
|
if ( url === '' ) { return; }
|
|
|
|
const sessionCookieKey = cookieKeyFromCookieURL(url, 'session', name);
|
|
const persistCookieKey = cookieKeyFromCookieURL(url, 'persistent', name);
|
|
|
|
vAPI.cookies.remove({ url, name }).then(details => {
|
|
const success = !!details;
|
|
const template = success ? i18nCookieDeleteSuccess : i18nCookieDeleteFailure;
|
|
if ( removeCookieFromDict(sessionCookieKey) ) {
|
|
if ( success ) {
|
|
µm.cookieRemovedCounter += 1;
|
|
}
|
|
µm.logger.writeOne({
|
|
realm: 'message',
|
|
text: template.replace('{{value}}', sessionCookieKey)
|
|
});
|
|
}
|
|
if ( removeCookieFromDict(persistCookieKey) ) {
|
|
if ( success ) {
|
|
µm.cookieRemovedCounter += 1;
|
|
}
|
|
µm.logger.writeOne({
|
|
realm: 'message',
|
|
text: template.replace('{{value}}', persistCookieKey)
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const i18nCookieDeleteSuccess = vAPI.i18n('loggerEntryCookieDeleted');
|
|
const i18nCookieDeleteFailure = vAPI.i18n('loggerEntryDeleteCookieError');
|
|
|
|
/******************************************************************************/
|
|
|
|
const processPageRecordQueue = function() {
|
|
processPageRecordQueueTimer = null;
|
|
|
|
for ( const pageStore of recordPageCookiesQueue.values() ) {
|
|
findAndRecordPageCookies(pageStore);
|
|
}
|
|
recordPageCookiesQueue.clear();
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
// Effectively remove cookies.
|
|
|
|
const processRemoveQueue = function() {
|
|
const userSettings = µm.userSettings;
|
|
const deleteCookies = userSettings.deleteCookies;
|
|
|
|
// Session cookies which timestamp is *after* tstampObsolete will
|
|
// be left untouched
|
|
// https://github.com/gorhill/httpswitchboard/issues/257
|
|
const tstampObsolete = userSettings.deleteUnusedSessionCookies ?
|
|
Date.now() - userSettings.deleteUnusedSessionCookiesAfter * 60 * 1000 :
|
|
0;
|
|
|
|
let srcHostnames;
|
|
|
|
for ( const cookieKey of removeCookieQueue ) {
|
|
// rhill 2014-05-12: Apparently this can happen. I have to
|
|
// investigate how (A session cookie has same name as a
|
|
// persistent cookie?)
|
|
const cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) { continue; }
|
|
|
|
// Delete obsolete session cookies: enabled.
|
|
if ( tstampObsolete !== 0 && cookieEntry.session ) {
|
|
if ( cookieEntry.tstamp < tstampObsolete ) {
|
|
browserCookieRemove(cookieEntry, cookieEntry.name);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Delete all blocked cookies: disabled.
|
|
if ( deleteCookies === false ) { continue; }
|
|
|
|
// Query scopes only if we are going to use them
|
|
if ( srcHostnames === undefined ) {
|
|
srcHostnames = µm.tMatrix.extractAllSourceHostnames();
|
|
}
|
|
|
|
// Ensure cookie is not allowed on ALL current web pages: It can
|
|
// happen that a cookie is blacklisted on one web page while
|
|
// being whitelisted on another (because of per-page permissions).
|
|
if ( canRemoveCookie(cookieKey, srcHostnames) ) {
|
|
browserCookieRemove(cookieEntry, cookieEntry.name);
|
|
}
|
|
}
|
|
|
|
removeCookieQueue.clear();
|
|
|
|
vAPI.setTimeout(processRemoveQueue, processRemoveQueuePeriod);
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
// Once in a while, we go ahead and clean everything that might have been
|
|
// left behind.
|
|
|
|
// Remove only some of the cookies which are candidate for removal: who knows,
|
|
// maybe a user has 1000s of cookies sitting in his browser...
|
|
|
|
const processClean = function() {
|
|
const us = µm.userSettings;
|
|
if ( us.deleteCookies || us.deleteUnusedSessionCookies ) {
|
|
const cookieKeys = Array.from(cookieDict.keys());
|
|
const len = cookieKeys.length;
|
|
let step, offset, n;
|
|
if ( len > 25 ) {
|
|
step = len / 25;
|
|
offset = Math.floor(Math.random() * len);
|
|
n = 25;
|
|
} else {
|
|
step = 1;
|
|
offset = 0;
|
|
n = len;
|
|
}
|
|
let i = offset;
|
|
while ( n-- ) {
|
|
removeCookieAsync(cookieKeys[Math.floor(i % len)]);
|
|
i += step;
|
|
}
|
|
}
|
|
|
|
vAPI.setTimeout(processClean, processCleanPeriod);
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const findAndRecordPageCookies = function(pageStore) {
|
|
for ( const cookieKey of cookieDict.keys() ) {
|
|
if ( cookieMatchDomains(cookieKey, pageStore.allHostnamesString) ) {
|
|
recordPageCookie(pageStore, cookieKey);
|
|
}
|
|
}
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
const canRemoveCookie = function(cookieKey, srcHostnames) {
|
|
const cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) { return false; }
|
|
|
|
const cookieHostname = cookieEntry.hostname;
|
|
|
|
for ( const srcHostname of cookieEntry.usedOn ) {
|
|
if ( µm.mustAllow(srcHostname, cookieHostname, 'cookie') ) {
|
|
return false;
|
|
}
|
|
}
|
|
// Maybe there is a scope in which the cookie is 1st-party-allowed.
|
|
// For example, if I am logged in into `github.com`, I do not want to be
|
|
// logged out just because I did not yet open a `github.com` page after
|
|
// re-starting the browser.
|
|
let srcHostname = cookieHostname;
|
|
for (;;) {
|
|
if (
|
|
srcHostnames.has(srcHostname) &&
|
|
µm.mustAllow(srcHostname, cookieHostname, 'cookie')
|
|
) {
|
|
return false;
|
|
}
|
|
if ( srcHostname === cookieEntry.domain ) { break; }
|
|
const pos = srcHostname.indexOf('.');
|
|
if ( pos === -1 ) { break; }
|
|
srcHostname = srcHostname.slice(pos + 1);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
// Listen to any change in cookieland, we will update page stats accordingly.
|
|
//
|
|
// https://github.com/gorhill/httpswitchboard/issues/79
|
|
// If cookie value didn't change, no need to record.
|
|
|
|
vAPI.cookies.onChanged = (( ) => {
|
|
const queue = new Map();
|
|
let queueTimer;
|
|
|
|
// Go through all pages and update if needed, as one cookie can be used
|
|
// by many web pages, so they need to be recorded for all these pages.
|
|
|
|
const process = function() {
|
|
queueTimer = undefined;
|
|
const now = Date.now();
|
|
const cookieKeys = [];
|
|
for ( const qentry of queue ) {
|
|
if ( qentry[1] > now ) { continue; }
|
|
if ( cookieDict.has(qentry[0]) === false ) { continue; }
|
|
cookieKeys.push(qentry[0]);
|
|
queue.delete(qentry[0]);
|
|
}
|
|
if ( cookieKeys.length !== 0 ) {
|
|
for ( const pageStore of µm.pageStores.values() ) {
|
|
const allHostnamesString = pageStore.allHostnamesString;
|
|
for ( const cookieKey of cookieKeys ) {
|
|
if ( cookieMatchDomains(cookieKey, allHostnamesString) ) {
|
|
recordPageCookie(pageStore, cookieKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ( queue.size !== 0 ) {
|
|
queueTimer = vAPI.setTimeout(process, 797);
|
|
}
|
|
};
|
|
|
|
return function(cookie) {
|
|
const cookieKey = cookieKeyFromCookie(cookie);
|
|
let cookieEntry = cookieDict.get(cookieKey);
|
|
if ( cookieEntry === undefined ) {
|
|
cookieEntry = addCookieToDict(cookie);
|
|
} else {
|
|
cookieEntry.tstamp = Date.now();
|
|
if ( cookie.value === cookieEntry.value ) { return; }
|
|
cookieEntry.value = cookie.value;
|
|
}
|
|
if ( queue.has(cookieKey) ) { return; }
|
|
queue.set(cookieKey, Date.now() + 653);
|
|
if ( queueTimer === undefined ) {
|
|
queueTimer = vAPI.setTimeout(process, 727);
|
|
}
|
|
};
|
|
})();
|
|
|
|
/******************************************************************************/
|
|
|
|
// Listen to any change in cookieland, we will update page stats accordingly.
|
|
|
|
vAPI.cookies.onRemoved = function(cookie) {
|
|
const cookieKey = cookieKeyFromCookie(cookie);
|
|
if ( removeCookieFromDict(cookieKey) ) {
|
|
µm.logger.writeOne({
|
|
realm: 'message',
|
|
text: i18nCookieDeleteSuccess.replace('{{value}}', cookieKey),
|
|
prettify: 'cookie'
|
|
});
|
|
}
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
// Listen to any change in cookieland, we will update page stats accordingly.
|
|
|
|
vAPI.cookies.onAllRemoved = function() {
|
|
for ( const cookieKey of cookieDict.keys() ) {
|
|
if ( removeCookieFromDict(cookieKey) ) {
|
|
µm.logger.writeOne({
|
|
realm: 'message',
|
|
text: i18nCookieDeleteSuccess.replace('{{value}}', cookieKey),
|
|
prettify: 'cookie'
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
vAPI.cookies.getAll().then(cookies => {
|
|
for ( const cookie of cookies ) {
|
|
addCookieToDict(cookie);
|
|
}
|
|
});
|
|
vAPI.cookies.start();
|
|
|
|
vAPI.setTimeout(processRemoveQueue, processRemoveQueuePeriod);
|
|
vAPI.setTimeout(processClean, processCleanPeriod);
|
|
|
|
/******************************************************************************/
|
|
|
|
// Expose only what is necessary
|
|
|
|
return {
|
|
recordPageCookies: recordPageCookiesAsync
|
|
};
|
|
|
|
/******************************************************************************/
|
|
|
|
})();
|
|
|
|
/******************************************************************************/
|
|
|