/** \file
* \brief Fingerprint Detector (FPD) main logic, fingerprinting evaluation and other essentials
*
* \author Copyright (C) 2021-2022 Marek Salon
*
* \license SPDX-License-Identifier: GPL-3.0-or-later
*/
//
// 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 .
//
/**
* \defgroup FPD Fingerprint Detector
*
* \brief Fingerprint Detector (FPD) is a module that detects browser fingerprint extraction and prevents
* its sharing. To learn more about Browser Fingerprinting topic, see study "Browser Fingerprinting: A survey" available
* here: https://arxiv.org/pdf/1905.01051.pdf
*
* The FPD module uses wrapping technique to inject code that allows log API calls and accesses for every visited web page
* and its frames. Logged JS APIs can be specified in wrappers-lvl_X.json file, where X represents FPD config identifier.
*
* Fingerprint Detector is based on heuristic system that can be defined in form of API groups. A group represents
* a set of APIs that may have something in common. Access to the group is triggered when a certain amount APIs is accessed.
* Hierarchy of groups creates a tree-like structure, where access to the root group means fingerprinting activity. Groups can
* be configured in groups-lvl_X.json file, where X represents FPD config identifier.
*
* The FPD evaluate API groups with every request made in scope of certain browser tab. When FPD detects fingerprinting activity,
* blocking of subsequent requests is issued (if allowed in settings). Local browsing data of fingerprinting origin are also cleared
* to prevent caching extracted fingerprint in browser storage.
*
*/
/** \file
*
* \brief This file is part of Fingerprint Detector (FPD) and contains API groups evaluation logic. File also contains
* event listeners used for API logging, requests blocking and tabs management.
*
* \ingroup FPD
*/
/**
* FPD enable flag. Evaluate only when active.
*/
var fpDetectionOn;
/**
* Associtive array of hosts, that are currently among trusted ones.
*/
var fpdWhitelist = {};
/**
* Associtive array of settings supported by this module.
*/
var fpdSettings = {};
/**
* API logs database of following structure:
* "tabId" : {
* "resource" : {
* "type" : {
* arguments {
* arg : "access count"
* },
* total : "total access count"
* }
* }
* }
*
* *values in quotations are substituted by concrete names
*
*/
var fpDb = new Observable();
/**
* Stores latest evaluation statistics for every examined tab. This statistics contains data about accessed groups and resources
* and their weights after evaluation. It can be used for debugging or as an informative statement in GUI.
* It also contains flag for every tab to limit number of notifications.
*/
var latestEvals = {};
/**
* Parsed groups object containing necessary group information needed for evaluation.
* Groups are indexed by level and name for easier and faster access.
*/
var fpGroups = {};
/**
* Object containing information about unsupported wrappers for given browser.
*/
var unsupportedWrappers = {};
/**
* Array containing names of unsupported wrappers that should be treated like supported ones during groups evaluation.
*/
var exceptionWrappers = ["CSSStyleDeclaration.prototype.fontFamily"];
/**
* Contains information about tabs current state.
*/
var availableTabs = {};
/**
* A global variable shared with level_cache that controls the collection of calling scripts for FPD
* report.
*/
var fpd_track_callers_tab = undefined;
/**
* Definition of settings supported by this module.
*/
const FPD_DEF_SETTINGS = {
behavior: {
label: browser.i18n.getMessage("fpdBehavior"),
description: browser.i18n.getMessage("fpdBehaviorDescription"),
description2: [],
params: [
{
// 0
short: browser.i18n.getMessage("fpdBehaviorPassive"),
description: browser.i18n.getMessage("fpdBehaviorPassiveDescription")
},
{
// 1
short: browser.i18n.getMessage("fpdBehaviorLimitedBlocking"),
description: browser.i18n.getMessage("fpdBehaviorBlockingDescription"),
description2: [
browser.i18n.getMessage("fpdBehaviorBlockingDescription2"),
browser.i18n.getMessage("fpdBehaviorLimitedBlockingDescription3"),
browser.i18n.getMessage("fpdBehaviorLimitedBlockingDescription4"),
browser.i18n.getMessage("fpdBehaviorBlockingDescriptionWarning")
]
},
{
// 2
short: browser.i18n.getMessage("fpdBehaviorFullBlocking"),
description: browser.i18n.getMessage("fpdBehaviorBlockingDescription"),
description2: [
browser.i18n.getMessage("fpdBehaviorBlockingDescription2"),
browser.i18n.getMessage("fpdBehaviorFullBlockingDescription3"),
browser.i18n.getMessage("fpdBehaviorFullBlockingDescription4"),
browser.i18n.getMessage("fpdBehaviorBlockingDescriptionWarning")
],
permissions: ["browsingData"]
}
]
},
notifications: {
label: browser.i18n.getMessage("shieldNotifications"),
description: browser.i18n.getMessage("fpdNotificationsDescription"),
description2: [browser.i18n.getMessage("fpdNotificationsDescription2")],
params: [
{
// 0
short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOff"),
description: browser.i18n.getMessage("fpdNotificationsOffDescription")
},
{
// 1
short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOn"),
description: browser.i18n.getMessage("fpdNotificationsOnDescription")
}
]
},
detection: {
label: browser.i18n.getMessage("fpdDetection"),
description: browser.i18n.getMessage("fpdDetectionDescription"),
description2: [],
params: [
{
// 0
short: browser.i18n.getMessage("fpdDetectionDefault"),
description: browser.i18n.getMessage("fpdDetectionDefaultDescription"),
description2: [
browser.i18n.getMessage("fpdDetectionDefaultDescription2"),
browser.i18n.getMessage("fpdDetectionDefaultDescription3"),
]
},
{
// 1
short: browser.i18n.getMessage("fpdStrict"),
description: browser.i18n.getMessage("fpdStrictDescription"),
description2: [
browser.i18n.getMessage("fpdStrictDescription2"),
browser.i18n.getMessage("fpdStrictDescription3")
]
}
]
}
};
// unify default color of popup badge background between different browsers
browser.browserAction.setBadgeBackgroundColor({color: "#6E7378"});
// unify default color of popup badge text between different browsers
if (typeof browser.browserAction.setBadgeTextColor === "function") {
browser.browserAction.setBadgeTextColor({color: "#FFFFFF"});
}
/**
* This function initializes FPD module, loads configuration from storage, and registers listeners needed for fingerprinting detection.
*/
function initFpd() {
// fill up fpGroups object with necessary data for evaluation
for (let groupsLevel in fp_levels.groups) {
fpGroups[groupsLevel] = fpGroups[groupsLevel] || {};
processGroupsRecursive(fp_levels.groups[groupsLevel], groupsLevel);
// Translate severity names
for (record of fp_levels.groups[groupsLevel].severity) {
record[1] = browser.i18n.getMessage(record[1]);
}
}
// load configuration and settings from storage
fpdLoadConfiguration();
// take care of unsupported resources for cross-browser behaviour uniformity
balanceUnsupportedWrappers();
// listen for messages intended for FPD module
browser.runtime.onMessage.addListener(fpdCommonMessageListener);
// listen for click on notification when FPD detects fingerprinting to create a report
browser.notifications.onClicked.addListener((notificationId) => {
if (notificationId.startsWith("fpd")) {
var tabId = notificationId.split("-")[1];
generateFpdReport(tabId);
}
});
// listen for update of browser tabs and start periodic FP evaluation
browser.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
availableTabs[tabId] = tab;
if (changeInfo.status == "loading") {
refreshDb(tabId);
fpDb[tab.id] = {};
periodicEvaluation(tab.id, 500);
}
});
// listen for removal of browser tabs to clean fpDb object
browser.tabs.onRemoved.addListener(function (tabId) {
refreshDb(tabId);
delete availableTabs[tabId];
});
// listen for removal of optional permissions to adjust settings accordingly
browser.permissions.onRemoved.addListener((permissions) => {
correctSettingsForRemovedPermissions(permissions.permissions, fpdSettings, FPD_DEF_SETTINGS);
browser.storage.sync.set({"fpdSettings": fpdSettings});
});
// listen for requests through webRequest API and decide whether to block them
browser.webRequest.onBeforeRequest.addListener(
fpdRequestCancel,
{urls: [""]},
["blocking"]
);
// listen for navigation events to initiate storage clearing of fingerprinting web pages
browser.webNavigation.onBeforeNavigate.addListener((details) => {
if (latestEvals[details.tabId] && latestEvals[details.tabId].stopNotifyFlag && fpdSettings.behavior > 0) {
// clear storages (using content script) for every frame in this tab
if (details.tabId >= 0) {
browser.tabs.sendMessage(details.tabId, {
cleanStorage: true,
ignoreWorkaround: fpdSettings.behavior > 1
});
}
}
});
// get state of all existing tabs and start periodic FP evaluation
browser.tabs.query({}).then(function(results) {
results.forEach(function(tab) {
availableTabs[tab.id] = tab;
fpDb[tab.id] = {};
periodicEvaluation(tab.id, 500);
});
});
}
/*
* --------------------------------------------------
* CRITERIA CORRECTION
* --------------------------------------------------
*/
/**
* This function provides ability to balance/optimalize evaluation heuristics for cross-browser behaviour uniformity.
* It checks which resources are unsupported for given browser and adjust criteria of loaded FPD configuration accordingly.
*/
function balanceUnsupportedWrappers() {
// object containing groups effected by criteria adjustment and corresponding deleted subgroups
var effectedGroups = {};
// check supported wrappers for each level of FPD configuration
for (let level in fp_levels.wrappers) {
unsupportedWrappers[level] = [];
effectedGroups = {};
// find out which wrappers are unsupported and store them in "unsupportedWrappers" object
for (let wrapper of fp_levels.wrappers[level]) {
// split path of object from its name
var resourceSplitted = split_resource(wrapper.resource);
// access nested object in browser's "window" object using path string
var resolvedPath = resourceSplitted["path"].split('.').reduce((o, p) => o ? o[p] : undefined, window);
// if resource or resource path is undefined -> resource unsupported && no exception for the resource
if (!(resolvedPath && resourceSplitted["name"] in resolvedPath) && !exceptionWrappers.includes(wrapper.resource)) {
// store wrapper object to "unsupportedWrappers" object
unsupportedWrappers[level].push(wrapper);
// mark all groups as effected if containing unsupported resource
for (let groupObj of wrapper.groups) {
effectedGroups[groupObj.group] = effectedGroups[groupObj.group] || [];
}
// remove wrapper from FPD configuration (in "fp_levels")
fp_levels.wrappers[level] = fp_levels.wrappers[level].filter((x) => (x != wrapper));
}
}
// adjust/correct effected groups criteria
correctGroupCriteria(fp_levels.groups[level], effectedGroups, level);
}
// refresh "fpGroups" object after criteria adjustment for upcoming evaluations
for (let groupsLevel in fp_levels.groups) {
fpGroups[groupsLevel] = {};
processGroupsRecursive(fp_levels.groups[groupsLevel], groupsLevel);
}
}
/**
* The function that corrects groups criteria according to unsupported wrappers.
* Groups should be deleted when more than half of weights (resources) cannot be obtained in the same group (whole group is invalidated).
* Groups criteria of "value" type are also recomputed to take into account unsupported resources and deleted groups/subgroups.
*
* \param rootGroup Group object of certain level from FPD configuration (in "fp_levels.wrappers" format).
* \param effectedGroups Object containing groups effected by criteria adjustment and corresponding deleted subgroups.
* \param level FPD configuration level identifier.
*
* \returns true if group should be deleted because of unsupported wrappers
* \returns false if group don't need to be deleted
*/
function correctGroupCriteria(rootGroup, effectedGroups, level) {
// if rootGroup has subgroups, then process all subgroups recursively
if (rootGroup.groups) {
for (let groupIdx in rootGroup.groups) {
// if correctGroupCriteria return true, subgroup is deleted
if (correctGroupCriteria(rootGroup.groups[groupIdx], effectedGroups, level)) {
// rootGroup is now also effected and added to "effectedGroups" with deleted subgroup
effectedGroups[rootGroup.name] = effectedGroups[rootGroup.name] || [];
effectedGroups[rootGroup.name].push(rootGroup.groups[groupIdx].name);
}
// rootGroup is effected because at least one of its child was effected too
if (Object.keys(effectedGroups).includes(rootGroup.groups[groupIdx].name)) {
effectedGroups[rootGroup.name] = effectedGroups[rootGroup.name] || [];
}
}
// remove deleted subgroups from original group object
if (effectedGroups[rootGroup.name]) {
rootGroup.groups = rootGroup.groups.filter((x) => (!effectedGroups[rootGroup.name].includes(x.name)))
}
}
// if group is effected, try to correct its criteria
if (Object.keys(effectedGroups).includes(rootGroup.name)) {
// original sum of max weights of direct children
var maxOriginalWeight = 0;
// sum of max weights after deletion of unsupported wrappers
var maxNewWeight = 0;
// get values of "maxOriginalWeight" and "maxNewWeight"
for (let resource in fpGroups[level][rootGroup.name].items) {
// if resource is subgroup
if (fpGroups[level][rootGroup.name].items[resource] == "group") {
let maxWeight = fpGroups[level][resource].criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
maxOriginalWeight += maxWeight;
maxNewWeight += !effectedGroups[rootGroup.name].includes(resource) ? maxWeight : 0;
}
// if resource is property or function
else {
// specific resource object from FPD configuration of wrappers
var resourceObj;
// array of groups where resource can be found
var groupsArray;
// flag - resource is supported (resource found in "fp_levels.wrappers" instead of "unsupportedWrappers")
var supported = false;
// get resource from wrappers and extract all group objects in context of parent group (rootGroup)
if (resourceObj = fp_levels.wrappers[level].filter((x) => (x.resource == resource))[0]) {
groupsArray = resourceObj.groups.filter((x) => (x.group == rootGroup.name));
supported = true;
}
else {
resourceObj = unsupportedWrappers[level].filter((x) => (x.resource == resource))[0];
groupsArray = resourceObj.groups.filter((x) => (x.group == rootGroup.name));
}
// get maximal obtainable weight for resource from every group object
for (let groupObj of groupsArray) {
if (groupObj.criteria && groupObj.criteria.length > 0) {
let maxWeight = groupObj.criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
maxOriginalWeight += maxWeight;
maxNewWeight += supported ? maxWeight : 0;
}
else {
maxOriginalWeight += 1;
maxNewWeight += supported ? 1 : 0;
}
}
}
}
// adjust "value" criteria to follow their original percentage state
for (let criterion of fpGroups[level][rootGroup.name].criteria) {
if ("value" in criterion) {
// original percentage of given criterion
var percValue = (criterion.value*100) / maxOriginalWeight;
// get new criterion value adjusted for new max weights
criterion.value = Math.round((percValue*maxNewWeight) / 100);
}
}
// if sum of max weights of unsupported resources is higher than 50% of total original weights
// in this case, group should be deleted or manually corrected in FPD configuration to contain only supported resources
if (2*maxNewWeight < maxOriginalWeight) {
return true;
}
return false;
}
}
/*
* --------------------------------------------------
* GROUPS EVALUATION
* --------------------------------------------------
*/
/**
* The function initializing evaluation of logged API calls (fpDb) according to groups criteria (fpGroups).
*
* \param tabId Integer number representing ID of browser tab that is going to be evaluated.
*
* \returns object where "weight" property represents evaluated weight of root group and "severity" property contain severity array
*/
function evaluateGroups(tabId) {
// get url of evaluated tab
var url = availableTabs[tabId] ? availableTabs[tabId].url : "";
var ret = {
weight: 0,
severity: []
}
// inaccesible or invalid url - do not evaluate
if (!url) {
return ret;
}
// clear old evalStats
latestEvals[tabId] = latestEvals[tabId] || {};
latestEvals[tabId].evalStats = [];
// check if the level exists within FPD configuration, if not use default FPD configuration
level = fpdSettings.detection ? fpdSettings.detection : 0;
// getting root group name as a start point for recursive evaluation
var rootGroup = fp_levels.groups[level] ? fp_levels.groups[level].name : undefined;
// start recursive evaluation if all needed objects are defined
if (rootGroup && fpGroups[level] && fp_levels.wrappers[level]) {
let evalRes = evaluateGroupsCriteria(rootGroup, level, tabId)[0];
ret.weight = evalRes.actualWeight;
if (fp_levels.groups[level].severity) {
let sortedSeverity = fp_levels.groups[level].severity.sort((x, y) => x[0] - y[0]);
ret.severity = sortedSeverity.filter((x) => (x[0] <= evalRes.actualWeightsSum)).reverse()[0];
}
}
return ret;
}
/**
* The function that evaluates group criteria according to evaluation of its child items (groups/resources).
*
* \param rootGroup Group name that needs to be evaluated.
* \param level Level ID of groups and wrappers used for evaluation.
* \param tabId Integer number representing ID of evaluated browser tab.
*
* \returns Array that contains "Result" objects
*
* Result object contains following properties:
* actualWeight (Obtained weight value of group after evaluation)
* maxWeight (Maximum obtainable weight value of group)
* type (Type of group item - group/call/get/set)
* accesses (Number of accesses to specified resource - groups always 0)
*/
function evaluateGroupsCriteria(rootGroup, level, tabId) {
// result object that is delegated to parent group
var res = {};
// all result objects from child items of rootGroup
var scores = [];
// array of relevant criteria based on groupTypes
var relevantCriteria = [];
// types of criteria that are relevant for evaluating rootGroup
var groupTypes = [];
// evaluate every item of rootGroup and add result objects to scores array
for (let item in fpGroups[level][rootGroup].items) {
if (fpGroups[level][rootGroup].items[item] == "group") {
scores = scores.concat(evaluateGroupsCriteria(item, level, tabId));
}
else {
scores = scores.concat(evaluateResourcesCriteria(item, rootGroup ,level, tabId));
}
}
/*
Group type is determined by first criteria object:
- access - evaluation of child items is based on sum of accesses
- value/percentage - evaluation of child items is based on sum of weights or percentage of sum of maxWeights
*/
groupTypes = Object.keys(fpGroups[level][rootGroup].criteria[0]).includes("access") ? ["access"] : ["value", "percentage"];
relevantCriteria = fpGroups[level][rootGroup].criteria.filter((x) => (groupTypes.some((y) => (Object.keys(x).includes(y)))));
// now evaluating group
res.type = "group";
// get maximal obtainable weight for rootGroup
res.maxWeight = fpGroups[level][rootGroup].criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
// compute actualWeight of rootGroup with value of accesses
res.accesses = 0;
res.actualWeight = 0;
res.actualWeightsSum = 0;
if (groupTypes.length == 2) {
// groupTypes contains "value" and "percetange" - take weight of child items into account
var actualWeightsSum = scores.reduce((x, {actualWeight}) => (x + actualWeight), 0);
var maxWeightsSum = scores.reduce((x, {maxWeight}) => (x + maxWeight), 0);
// recalculate percentage values of relevant criteria to exact values
var relativeCriteria = [];
for (let criteriaObj of relevantCriteria) {
if (criteriaObj.value) {
relativeCriteria.push(criteriaObj);
}
else {
relativeCriteria.push({
value: Math.round(maxWeightsSum * (criteriaObj.percentage/100)),
weight: criteriaObj.weight
});
}
}
// sort relevant and relative criteria by value
relativeCriteria.sort((x, y) => (x.value - y.value));
// filter criteria and take weight of highest achieved criteria
var filteredCriteria = relativeCriteria.filter((x) => (x.value <= actualWeightsSum)).reverse()[0];
res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
res.actualWeightsSum = actualWeightsSum;
}
else {
// groupTypes contains "access" - take access of child items into account
var accessesSum = scores.reduce((x, {accesses}) => (x + accesses), 0);
// sort relevant criteria
relevantCriteria.sort((x, y) => (x.access - y.access));
// filter criteria and take weight of highest achieved criteria
var filteredCriteria = relevantCriteria.filter((x) => (x.access <= accessesSum)).reverse()[0];
res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
res.actualWeightsSum = accessesSum;
}
// update group statistics in latestEvals
latestEvals[tabId].evalStats.push({
title: rootGroup,
type: "group",
weight: res.actualWeight,
sum: res.actualWeightsSum
});
return [res];
}
/**
* The function that evaluates resource (wrapper) criteria according to API calls logs.
*
* \param resource Full name of resource/wrapper.
* \param groupName Name of direct parent group.
* \param level Level ID of groups and wrappers used for evaluation.
* \param tabId Integer number representing ID of evaluated browser tab.
*
* \returns Array that contains "Result" objects
*
* Result object contains following properties (all of them in context of parent group):
* actualWeight (Obtained weight value of resource after evaluation)
* maxWeight (Maximum obtainable weight value of resource)
* type (Type of resource - call/get/set)
* accesses (Number of accesses to specified resource)
*/
function evaluateResourcesCriteria(resource, groupName, level, tabId) {
// all result objects for given resource (set/get/call)
var scores = [];
// get resource from wrappers and extract all group objects in context of parent group (groupName)
var resourceObj = fp_levels.wrappers[level].filter((x) => (x.resource == resource))[0];
var groupsArray = resourceObj.groups.filter((x) => (x.group == groupName));
// evaluate every retrieved group object
for (let groupObj of groupsArray) {
// initialize new result object
var res = {}
// get resource type from group object (get/set/call)
if (resourceObj.type == "property") {
if (groupObj.property) {
res.type = groupObj.property
}
else {
// property not defined => implicit get
res.type = "get";
}
}
else {
res.type = "call"
}
// get maximal obtainable weight for resource from group object
if (groupObj.criteria && groupObj.criteria.length > 0) {
res.maxWeight = groupObj.criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
}
else {
// criteria not defined => set implicit criteria
res.maxWeight = 1;
groupObj.criteria = [{value: 1, weight: 1}];
}
// compute actualWeight of resource in context of parent group from logs located in fpDb object
res.actualWeight = 0;
if (fpDb[tabId] && fpDb[tabId][resource] && fpDb[tabId][resource][res.type]) {
let record = fpDb[tabId][resource][res.type];
// logs for given resource and type exist
if (groupObj.arguments) {
// if arguments logging is defined, evaluate resource accordingly
if (groupObj.arguments == "diff") {
// "diff" - accesses depend on number of different arguments
res.accesses = Object.keys(record.args).length;
}
else if (groupObj.arguments == "same") {
// "same" - accesses depend on maximum number of same arguments calls
res.accesses = Object.values(record.args).reduce((x, y) => x > y ? x : y);
}
else {
// try to interpret arguments as regular expression and take accesses that match this expression
try {
let re = new RegExp(...groupObj.arguments);
res.accesses = Object.keys(record.args).reduce(
(x, y) => (re.test(y) ? x + record.args[y] : x), 0);
} catch {
res.accesses = 0;
}
}
}
else {
// arguments logging not defined, simply take total accesses to resource
res.accesses = record.total
}
// sort criteria by value
groupObj.criteria.sort((x, y) => (x.value - y.value));
// filter criteria and take weight of highest achieved criteria
var filteredCriteria = groupObj.criteria.filter((x) => (x.value <= res.accesses)).reverse()[0];
res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
}
else {
// no logs of given criteria
res.accesses = 0;
}
scores.push(res)
}
// update resource statistics in latestEvals
scores.forEach(function (res) {
latestEvals[tabId].evalStats.push({
title: resource,
type: "resource",
resource: res.type,
group: groupName,
weight: res.actualWeight,
accesses: res.accesses
});
});
return scores;
}
/*
* --------------------------------------------------
* EVENT HANDLERS
* --------------------------------------------------
*/
/**
* Callback function that parses and handles messages recieved by FPD module.
* Messages may contain wrappers logging data that are stored into fpDb object.
* Also listen for popup messages to update FPD state and whitelist.
*
* \param message Receives full message.
* \param sender Sender of the message.
*/
function fpdCommonMessageListener(record, sender) {
if (record) {
switch (record.purpose) {
case "fp-detection":
// check objects existance => if do not exist, create new one
fpDb[sender.tab.id] = fpDb[sender.tab.id] || {};
fpDb[sender.tab.id][record.resource] = fpDb[sender.tab.id][record.resource] || {};
fpDb[sender.tab.id][record.resource][record.type] = fpDb[sender.tab.id][record.resource][record.type] || {};
// object that contains access counters
const fpCounterObj = fpDb[sender.tab.id][record.resource][record.type];
const argsStr = record.args.join();
fpCounterObj["args"] = fpCounterObj["args"] || {};
// increase counter for accessed arguments
fpCounterObj["args"][argsStr] = fpCounterObj["args"][argsStr] || 0;
fpCounterObj["args"][argsStr] += 1;
// increase counter for total accesses
fpCounterObj["total"] = fpCounterObj["total"] || 0;
fpCounterObj["total"] += 1;
fpDb.update(record.resource, sender.tab.id, record.type, fpCounterObj["total"]);
// Track callers
fpCounterObj["callers"] = fpCounterObj["callers"] || {};
if (record.stack !== undefined) {
fpCounterObj["callers"][record.stack] = true;
}
break;
case "fpd-state-change":
browser.storage.sync.get(["fpDetectionOn"]).then(function(result) {
fpDetectionOn = result.fpDetectionOn;
});
break;
case "fpd-whitelist-check": {
// answer to popup, when asking whether is the site whitelisted
return Promise.resolve(isFpdWhitelisted(record.url));
}
case "add-fpd-whitelist":
// obtain current hostname and whitelist it
var currentHost = record.url;
fpdWhitelist[currentHost] = true;
browser.storage.sync.set({"fpdWhitelist": fpdWhitelist});
break;
case "remove-fpd-whitelist":
// obtain current hostname and remove it form whitelist
var currentHost = record.url;
delete fpdWhitelist[currentHost];
browser.storage.sync.set({"fpdWhitelist": fpdWhitelist});
break;
case "update-fpd-whitelist":
// update current fpdWhitelist from storage
browser.storage.sync.get(["fpdWhitelist"]).then(function(result) {
fpdWhitelist = result.fpdWhitelist;
});
break;
case "fpd-get-report-data": {
// get current FPD level for evaluated tab
if (record.tabId) {
level = fpdSettings.detection ? fpdSettings.detection : 0;
return Promise.resolve({
tabObj: availableTabs[record.tabId],
groups: {root: fp_levels.groups[level].name, all: fpGroups[level]},
fpDb: fpDb[record.tabId],
latestEvals: latestEvals[record.tabId],
exceptionWrappers: exceptionWrappers
});
}
}
case "fpd-create-report":
// create FPD report for the tab
if (record.tabId) {
generateFpdReport(record.tabId);
}
break;
case "fpd-fetch-severity": {
// send severity value of the latest evaluation
let severity = [];
if (record.tabId && isFpdOn(record.tabId) && latestEvals[record.tabId]) {
severity = latestEvals[record.tabId].severity;
}
return Promise.resolve(severity);
}
case "fpd-get-settings": {
// send settings definition and current values
return Promise.resolve({
def: FPD_DEF_SETTINGS,
val: fpdSettings
});
}
case "fpd-set-settings":
// update current settings
fpdSettings[record.id] = record.value;
browser.storage.sync.set({"fpdSettings": fpdSettings});
break;
case "fpd-load-config":
// load current configuration
fpdLoadConfiguration();
break;
case "fpd-clear-storage":
// clear browser storage for the origin
clearStorageFacilities(record.url);
break;
case "fpd-fetch-hits": {
let {tabId} = record;
// filter by tabId;
let hits = Object.create(null);
if (fpDb[tabId]) {
for ([resource, recordObj] of Object.entries(fpDb[tabId])) {
let total = 0;
for (let stat of Object.values(recordObj)) { // by type
total += stat.total;
}
let group_name = wrapping_groups.wrapper_map[resource];
if (group_name) {
get_or_create(hits, group_name, 0);
hits[group_name] += total;
}
}
}
return Promise.resolve(hits);
}
case "fpd-track-callers": {
let tabId = Number(record.tabId);
fpd_track_callers_tab = tabId;
return browser.tabs.reload(tabId);
}
case "fpd-track-callers-stop": {
fpd_track_callers_tab = undefined;
}
}
}
}
/**
* The function that makes decisions about requests blocking. If blocking enabled, also clear browsing data.
*
* \param requestDetails Details about the request.
*
* \returns Object containing key "cancel" with value true if request is blocked, otherwise with value false
*/
function fpdRequestCancel(requestDetails) {
// chrome fires onBeforeRequest event before tabs.onUpdated => refreshDb won't happen in time
// need to refreshDb when main_frame request occur, otherwise also user's requests will be blocked
if (requestDetails.type == "main_frame") {
refreshDb(requestDetails.tabId);
}
return evaluateFingerprinting(requestDetails.tabId)
}
/*
* --------------------------------------------------
* MISCELLANEOUS
* --------------------------------------------------
*/
/**
* The function that loads module configuration from sync storage.
*/
function fpdLoadConfiguration() {
browser.storage.sync.get(["fpDetectionOn", "fpdWhitelist", "fpdSettings"]).then(function(result) {
fpDetectionOn = result.fpDetectionOn ? true : false;
fpdWhitelist = result.fpdWhitelist ? result.fpdWhitelist : {};
fpdSettings = result.fpdSettings ? result.fpdSettings : {};
});
}
/**
* The function transforming recursive groups definition into indexed fpGroups object.
*
* \param input Group object from loaded JSON format.
* \param groupsLevel Level ID of groups to process.
*/
function processGroupsRecursive(input, groupsLevel) {
fpGroups[groupsLevel][input.name] = {};
fpGroups[groupsLevel][input.name]["description"] = input["description"] || "";
// criteria missing => set implicit criteria
fpGroups[groupsLevel][input.name]["criteria"] = input["criteria"] || [{value:1, weight:1}];
fpGroups[groupsLevel][input.name]["items"] = {};
// retrieve associated resources (wrappers) for input group
for (let resourceObj of fp_levels.wrappers[groupsLevel]) {
if (resourceObj.groups.filter((x) => (x.group == input.name)).length > 0) {
fpGroups[groupsLevel][input.name]["items"][resourceObj.resource] = resourceObj.type;
}
}
// retrieve associated sub-groups for given input group and process them recursively
if (input["groups"]) {
for (let groupObj of input["groups"]) {
fpGroups[groupsLevel][input.name]["items"][groupObj.name] = "group";
processGroupsRecursive(groupObj, groupsLevel);
}
}
}
/**
* Check if the hostname or any of it's domains is whitelisted.
*
* \param hostname Any hostname (subdomains allowed).
*
* \returns TRUE when domain (or subdomain) is whitelisted, FALSE otherwise.
*/
function isFpdWhitelisted(hostname) {
var domains = extractSubDomains(hostname);
for (var domain of domains) {
if (fpdWhitelist[domain] != undefined) {
return true;
}
}
return false;
}
/**
* The function that returns FPD setting for given url.
*
* \param tabId Tab identifier for which FPD setting is needed.
*
* \returns Boolean value TRUE if FPD is on, otherwise FALSE.
*/
function isFpdOn(tabId) {
if (!availableTabs[tabId]) {
return false;
}
let url = getEffectiveDomain(availableTabs[tabId].url);
if (fpDetectionOn && !isFpdWhitelisted(url)) {
return true;
}
return false;
}
/**
* The function that creates notification and informs user about fingerprinting activity.
*
* \param tabId Integer number representing ID of suspicious browser tab.
*/
function notifyFingerprintBlocking(tabId) {
let msg;
if (fpdSettings.behavior > 0) {
msg = browser.i18n.getMessage("fpdBlockingSubsequent");
}
if (fpdSettings.behavior == 0) {
msg = browser.i18n.getMessage("fpdClickNotificationDetails");
}
browser.notifications.create("fpd-" + tabId, {
type: "basic",
iconUrl: browser.runtime.getURL("img/icon-48.png"),
title: browser.i18n.getMessage("fpdNotificationTitle"),
message: browser.i18n.getMessage("fpdNotificationMessage",
[
msg,
availableTabs[tabId].title.slice(0, 30),
getEffectiveDomain(availableTabs[tabId].url)
])
});
setTimeout(() => {
browser.notifications.clear("fpd-" + tabId);
}, 6000);
}
/**
* The function that generates a report about fingerprinting evaluation in a separate window.
*
* \param tabId Integer number representing ID of evaluated browser tab.
*/
function generateFpdReport(tabId) {
// open popup window containing FPD report
browser.windows.create({
url: "/fp_report.html?id=" + tabId,
type: "popup",
height: 600,
width: 800
});
}
/**
* Clear all stored logs for a tab.
*
* \param tabId Integer number representing ID of browser tab.
*/
function refreshDb(tabId) {
if (fpDb[tabId]) {
delete fpDb[tabId];
}
if (latestEvals[tabId]) {
delete latestEvals[tabId];
}
if (availableTabs[tabId] && availableTabs[tabId].timerId) {
clearTimeout(availableTabs[tabId].timerId);
}
}
/**
* The function that periodically starts fingerprinting evaluation without the need for a request.
* Delay is increased exponentially and doubles in every call.
*
* \param tabId Integer number representing ID of browser tab.
* \param delay Initial value of a delay in milliseconds.
*/
function periodicEvaluation(tabId, delay) {
evaluateFingerprinting(tabId);
if (availableTabs[tabId]) {
// limit max delay to 90s per tab
availableTabs[tabId].timerId = setTimeout(periodicEvaluation, delay, tabId, delay > 90000 ? delay : delay*2);
}
}
/**
* The function that starts evaluation process and if fingerprinting is detected, it reacts accordingly.
*
* \param tabId Integer number representing ID of evaluated browser tab.
*
* \returns Object containing key "cancel" with value true if request is blocked, otherwise with value false
*/
function evaluateFingerprinting(tabId) {
// if FPD enabled for the site continue with FP evaluation
if (isFpdOn(tabId)) {
// start FP evaluation process and store result array
var evalResult = evaluateGroups(tabId);
// store latest severity value after evaluation of given tab
if (evalResult.severity) {
latestEvals[tabId].severity = evalResult.severity;
}
// modify color of browserAction
if (evalResult.severity[2]) {
browser.browserAction.setBadgeBackgroundColor({color: evalResult.severity[2], tabId: tabId});
}
// if actualWeight of root group is higher than 0 => reactive phase and applying measures
if (evalResult.weight) {
// create notification for user if behavior is "notification" or higher (only once for every tab load)
if (fpdSettings.notifications == 1 && !latestEvals[tabId].stopNotifyFlag) {
latestEvals[tabId].stopNotifyFlag = true;
notifyFingerprintBlocking(tabId);
}
// block request and clear cache data only if "blocking" behavior is set
if (fpdSettings.behavior > 0) {
// clear storages (using content script) for every frame in this tab
if (tabId >= 0) {
browser.tabs.sendMessage(tabId, {
cleanStorage: true,
ignoreWorkaround: fpdSettings.behavior > 1
});
}
return {
cancel: true
};
}
}
}
return {
cancel: false
};
}
/**
* The function that clears all supported browser storage mechanisms for a given origin.
*
* \param url URL address of the origin.
*/
function clearStorageFacilities(url) {
// clear all browsing data for origin of tab url to prevent fingerprint caching
if (url && fpdSettings.behavior > 1) {
try {
// "origins" key only supported by Chromium browsers
browser.browsingData.remove({
"origins": [new URL(url).origin]
}, {
"appcache": true,
"cache": true,
"cacheStorage": true,
"cookies": true,
"fileSystems": true,
"indexedDB": true,
"localStorage": true,
"serviceWorkers": true,
"webSQL": true
});
}
catch (e) {
// need to use "hostnames" key for Firefox
if (e.message.includes("origins")) {
browser.browsingData.remove({
"hostnames": extractSubDomains(new URL(url).hostname).filter((x) => (x.includes(".")))
}, {
"cache": true,
"cookies": true,
"indexedDB": true,
"localStorage": true,
"serviceWorkers": true
});
}
else {
throw e;
}
}
}
}
/**
* The function splitting resource string into path and name.
* For example: "window.navigator.userAgent" => path: "window.navigator", name: "userAgent"
*
* \param wrappers text String representing full name of resource.
*
* \returns Object consisting of two properties (path, name) for given resource.
*/
function split_resource(text) {
var index = text.lastIndexOf('.');
return {
path: text.slice(0, index),
name: text.slice(index + 1)
}
}