/** \file * \brief This file contains common functions for Network Boundary Shield. * * \author Copyright (C) 2020 Pavel Pohner * \author Copyright (C) 2020-2021 Martin Bednář * \author Copyright (C) 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 NBS Network Boundary Shield * * \brief The Network Boundary Shield (NBS) is a protection against attacks from an external network (the Internet) * to an internal network - especially against a reconnaissance attacks when a web browser is abused as a proxy. * See, for example, the ForcePoint report https://www.forcepoint.com/sites/default/files/resources/files/report-attacking-internal-network-en_0.pdf, * https://www.forcepoint.com/blog/x-labs/attacking-internal-network-public-internet-using-browser-proxy. * Another example is the detection of applications running on the localhost, see * https://jshelter.org/localportscanning/. * * The NBS functionality is based on filtering HTTP requests. The Network Boundary Shield uses blocking webRequest API to handle HTTP requests. * This means that processing of each HTTP request is paused before it is analyzed and allowed (if it seems benign) or blocked (if it is suspicious). * * The main goal of NBS is to prevent attacks like a public website requests a resource from the * local computer (e.g. to determine open TCP ports and thus running applications) or * internal network (e.g. the logo of the manufacturer of the local router); NBS will detect that * a web page hosted on the public Internet tries to connect to a local IP address. NBS blocks only * HTTP requests from a web page hosted on a public IP address to a private network resource. The * user can allow specific web pages to access local resources (e.g. when using Intranet services). * * NBS uses CSV files provided by IANA * (https://www.iana.org/assignments/locally-served-dns-zones/locally-served-dns-zones.xml) to * determine public and local IP address prefixes. Both IPv4 and IPv6 is supported. The CSV files * are downloaded during the $(PROJECT_NAME) building process. * * The NBS has only a small impact on the web browser performance. The impact differs for each implementation. * * More information about the Network Boundary Shield can be obtained from the master thesis by Pavel Pohner: https://www.vutbr.cz/studenti/zav-prace/detail/129272 (in Czech). */ /** \file * \ingroup NBS * * This file contains basic logic of the NBS, NBS global variables and objects, * functions for reading and parsing CSV files, and functions for identifying and processing IP addresses and checking IP ranges. */ /** * Locally served IPV4 DNS zones loaded from IANA. */ var localIPV4DNSZones; /** * Locally served IPV6 DNS zones loaded from IANA. */ var localIPV6DNSZones; /** * Associtive array of hosts, that are currently among trusted "do not blocked" hosts. */ var doNotBlockHosts = {}; /** * Associtive array of settings supported by this module. */ var nbsSettings = {}; /** * Object holding active notifications of this module. */ var nbsNotifications = {}; /** * Definition of settings supported by this module. */ const NBS_DEF_SETTINGS = { blocking: { label: browser.i18n.getMessage("nbsBlocking"), description: browser.i18n.getMessage("nbsBlockingDescription"), description2: [browser.i18n.getMessage("nbsBlockingDescription2")], params: [ { // 0 short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOff"), description: browser.i18n.getMessage("nbsBlockingOffDescription") }, { // 1 short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOn"), description: browser.i18n.getMessage("nbsBlockingOnDescription") } ] }, notifications: { label: browser.i18n.getMessage("shieldNotifications"), description: browser.i18n.getMessage("NBSNotificationsDescription"), description2: [browser.i18n.getMessage("NBSNotificationsDescription2")], params: [ { // 0 short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOff"), description: browser.i18n.getMessage("NBSNotificationsOffDescription") }, { // 1 short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOn"), description: browser.i18n.getMessage("NBSNotificationsOnDescription") } ] } }; /** * The function that loads module configuration from sync storage. */ function nbsLoadConfiguration() { browser.storage.sync.get(["requestShieldOn", "nbsWhitelist", "nbsSettings"]).then(function(result) { //If found object is true or undefined, turn the requestShieldOn if (result.requestShieldOn == undefined || result.requestShieldOn) { //Hook up the listeners browser.webRequest.onBeforeSendHeaders.addListener( beforeSendHeadersListener, {urls: [""]}, ["blocking", "requestHeaders"] ); if (typeof onResponseStartedListener === "function") { browser.webRequest.onResponseStarted.addListener( onResponseStartedListener, {urls: [""]}, ["responseHeaders"] ); } } doNotBlockHosts = result.nbsWhitelist ? result.nbsWhitelist : {}; nbsSettings = result.nbsSettings ? result.nbsSettings : {}; }); } /// \cond (Exclude this section from the doxygen documentation. If this section is not excluded, it is documented as a separate function.) /// Hook up the listener for receiving messages nbsLoadConfiguration(); browser.runtime.onMessage.addListener(nbsCommonMessageListener); browser.runtime.onMessage.addListener(nbsMessageListener); browser.runtime.onMessage.addListener(nbsSettingsListener); // Listen for permissions removal to adapt settings accordingly browser.permissions.onRemoved.addListener((permissions) => { correctSettingsForRemovedPermissions(permissions.permissions, nbsSettings, NBS_DEF_SETTINGS); browser.storage.sync.set({"nbsSettings": nbsSettings}); }); // Obtain file path in user's file system and read CSV file with IPv4 local zones readFile(browser.runtime.getURL("ipv4.dat")) .then(_res => { //Parse loaded CSV and store it in prepared variable localIPV4DNSZones = parseCSV(_res, true); }) .catch(_error => { console.error(_error ); }); // Obtain file path in user's file system and read CSV file with IPv6 local zones readFile(browser.runtime.getURL("ipv6.dat")) .then(_res => { //Parse loaded CSV and store it in prepared variable localIPV6DNSZones = parseCSV(_res, false); }) .catch(_error => { console.error(_error ); }); /// \endcond /** * Checks validity of IPv4 addresses. * * \param url An URL that may or may not contains an IPv4 address instead of a domain name. * * \returns TRUE if the url matches IPv4 regex, FALSE otherwise. */ function isIPV4(url) { var reg = new RegExp("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$"); return reg.test(url); } /** * Checks validity IPV6 address. * * \param url An URL that may or may not contains an IPv6 address instead of a domain name. * * \returns TRUE, if URL is valid IPV6 address, FALSE otherwise. */ function isIPV6(url) { if (url[0] === "[" && url[url.length - 1] === "]") { url = url.substring(1, url.length - 1); } var reg = new RegExp("^(?:(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-fA-F]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,1}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,2}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,3}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:[0-9a-fA-F]{1,4})):)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,4}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,5}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,6}(?:(?:[0-9a-fA-F]{1,4})))?::))))$", 'm'); return reg.test(url); } /** * Checks whether the ipAddr is found in IPv4 localZones. * If the IPv4 address is found in any IPv4 local zone, it means that this IPv4 address is private. * IPv4 local zone is e.g. 192.168.000.000/16. * * \param ipAddr Valid IPv4 address. * * \returns TRUE if ipAddr exists in localZones fetched from IANA, FALSE otherwise. */ function isIPV4Private(ipAddr) { //Split IP address on dots, obtain 4 numbers var substrIP = ipAddr.split('.'); //Convert IP address into array of 4 integers var ipArray = substrIP.map(function(val){ return parseInt(val, 10); }); //For each IPv4 locally served zone for (var i = 0; i < localIPV4DNSZones.length; i++) { //Split the zone into array of J numbers var zone = localIPV4DNSZones[i].split('.'); var k = 0; //For each number of local zone IP //(Decrementing, because local zones IPs are reverted for (var j = zone.length - 1; j >= 0; j--) { //Check if the corresponding numbers match //If not, then break and move onto next local zone if (ipArray[k] != zone[j]) { break; } else if(j == 0) //Checked all numbers of local zone { return true; } k++; } } return false; } /** * Checks whether the ipAddr is found in IPv6 localZones. * If the IPv6 address is found in any IPv6 local zone, it means that this IPv6 address is private. * IPv6 local zone is e.g. fe80::/10. * * \param ipAddr Valid IPv6 address. * * \returns TRUE if ipAddr exists in localZones fetched from IANA, FALSE otherwise. */ function isIPV6Private(ipAddr) { //Expand shorten IPv6 addresses to full length ipAddr = expandIPV6(ipAddr); //Split into array of fields var substrIP = ipAddr.split(":"); //Join the fields into one string ipAddr = substrIP.join("").toUpperCase(); //For each IPv6 locally served zone for (var i = 0; i < localIPV6DNSZones.length; i++) { var zone = localIPV6DNSZones[i]; //For each char of zone for (var j = 0; j < zone.length; j++) { //Compare the chars, if they do not match, break and move onto next zone if (ipAddr.charAt(j) != zone.charAt(j)) { break; } //Checked all chars of current zone -> private IP range else if(j == zone.length - 1) { return true; } } } return false; } /** * Function for parsing CSV files obtained from IANA. * It strips .IN-ADDR and .IP6 from zones and comma delimiter, merges them into array by CSV rows. * * \param csv CSV obtained from IANA. * \param ipv4 Boolean, saying whether the csv is IPv4 CSV or IPv6. * * \returns an array of parsed CSV values. */ function parseCSV(csv, ipv4) { //converting into array var csvArray = CSVToArray(csv); var DNSzones = []; if (ipv4) //ipv4.csv { //cycle through first column of the CSV -> obtaining IP zones //Starting with i = 1, skipping the CSV header for (var i = 1; i < csvArray.length; i++) { //i-1, means start from 0 //Obtains IP zone, strips .IN-ADDR from the end of it, stroes into array DNSzones[i-1] = csvArray[i][0].substring(0, csvArray[i][0].indexOf(".IN-ADDR")); } return DNSzones; } else //ipv6.csv { //Same as ipv4 for (var i = 1; i < csvArray.length-1; i++) { DNSzones[i-1] = csvArray[i][0].substring(0, csvArray[i][0].indexOf(".IP6")); } for (var i = 0; i < DNSzones.length; i++) { //Additionally splits the IP zone on dots var splitted = DNSzones[i].split("."); DNSzones[i] = ""; //Joins splitted IP zone into one string for (var j = splitted.length - 1; j >= 0 ; j--) { DNSzones[i] += splitted[j]; } } return DNSzones; } } /** * Auxillary function for parsing CSV files. * Converts CSV to array. * * \param strData Loaded CSV file as a string. * * \returns array containing CSV rows. */ function CSVToArray(strData){ // Create a regular expression to parse the CSV values. var objPattern = new RegExp( ( // Delimiters. "(\\,|\\r?\\n|\\r|^)" + // Quoted fields. "(?:\"([^\"]*(?:\"\"[^\"]*)*)\"|" + // Standard fields. "([^\"\\,\\r\\n]*))" ), "gi" ); //Array to hold data var csvData = [[]]; //Array to hold regex matches var regexMatches = null; //While not match while (regexMatches = objPattern.exec(strData)){ // Get the delimiter that was found var strMatchedDelimiter = regexMatches[1]; if (strMatchedDelimiter.length && (strMatchedDelimiter != ",")){ //New row csvData.push([]); } // captured data (quoted or unquoted) if (regexMatches[2]){ //quoted var strMatchedValue = regexMatches[2].replace( new RegExp( "\"\"", "g" ), "\"" ); } else { //non-quoted value. var strMatchedValue = regexMatches[3]; } //Add to data array csvData[csvData.length - 1].push( strMatchedValue ); } // Return the parsed data return( csvData ); } /** * Function for expanding shorten ipv6 addresses. * * \param ip6addr Valid ipv6 address. * * \returns expanded ipv6 address in string. */ function expandIPV6(ip6addr) { if (ip6addr[0] === "[" && ip6addr[ip6addr.length - 1] === "]") { ip6addr = ip6addr.substring(1, ip6addr.length - 1); } var expandedIP6 = ""; //Check for omitted groups of zeros (::) if (ip6addr.indexOf("::") == -1) { //There are none omitted groups of zeros expandedIP6 = ip6addr; } else { //Split IP on one compressed group var splittedIP = ip6addr.split("::"); var amountOfGroups = 0; //For each group for (var i = 0; i < splittedIP.length; ++i) { //Split on : amountOfGroups += splittedIP[i].split(":").length; } expandedIP6 += splittedIP[0] + ":"; //For each splitted group for (var i = 0; i < 8 - amountOfGroups; ++i) { //insert zeroes expandedIP6 += "0000:"; } //Insert the rest of the splitted IP expandedIP6 += splittedIP[1]; } //Split expanded IPv6 into parts var addrParts = expandedIP6.split(":"); var addrToReturn = ""; //For each part for (var i = 0; i < 8; ++i) { //check the length of the part while(addrParts[i].length < 4) { //if it's less than 4, insert zero addrParts[i] = "0" + addrParts[i]; } addrToReturn += i != 7 ? addrParts[i] + ":" : addrParts[i]; } return addrToReturn; } /** * 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 isNbsWhitelisted(hostname) { //Calling a function from url.js var domains = extractSubDomains(hostname); for (var domain of domains) { if (doNotBlockHosts[domain] != undefined) { return true; } } return false; } /** * Log data about NBS blocking in context of tabs. * This data will be used for notification creation. * * \param origin Origin of the request. * \param target Target of the request. * \param tabId Tab ID of blocked request. */ function notifyBlockedRequest(origin, target, tabId) { if (nbsSettings.notifications) { nbsNotifications[tabId] = nbsNotifications[tabId] || {}; nbsNotifications[tabId].records = nbsNotifications[tabId].records || {}; nbsNotifications[tabId].records[`${origin},${target}`] = (nbsNotifications[tabId].records[`${origin},${target}`] || 0) + 1; nbsNotifications[tabId].total = (nbsNotifications[tabId].total || 0) + 1; } // start notifying a user when the first blocked request occurs if (nbsNotifications[tabId].total == 1) { setTimeout(showNbsNotification, 2000, tabId); createCumulativeNotification(tabId); } } // Listen for tab update to clear notifications data of the tab browser.tabs.onUpdated.addListener(function (tabId, changeInfo) { if (changeInfo.status == "loading") { clearNbsNotification(tabId); } }); // Listen for tab remove to clear notifications data of the tab browser.tabs.onRemoved.addListener(clearNbsNotification); /** * Clear notification data for the tab. * * \param tabId Tab ID of notification. */ function clearNbsNotification(tabId) { if (nbsNotifications[tabId]) { if (nbsNotifications[tabId].timerId) { clearTimeout(nbsNotifications[tabId].timerId); } delete nbsNotifications[tabId]; } } /** * Create second notification containing a summary of accumulated data. * This notification is shown after the initial one if a page continues to access local network. * * \param tabId Integer number representing ID of browser tab. */ async function createCumulativeNotification(tabId) { if (nbsNotifications[tabId].last == nbsNotifications[tabId].total) { let active = await browser.notifications.getAll(); if (!Object.keys(active).includes("nbs-" + tabId)) { showNbsNotification(tabId); } return; } nbsNotifications[tabId].last = nbsNotifications[tabId].total; nbsNotifications[tabId].timerId = setTimeout(createCumulativeNotification, 4000, tabId); } /** * Creates and presents notification about blocked requests. * * \param tabId Integer number representing ID of browser tab. */ function showNbsNotification(tabId) { nbsNotifications[tabId].last = nbsNotifications[tabId].total; let message; let records = Object.keys(nbsNotifications[tabId].records); if (records.length == 1) { // The page contacted just one target, display both host and target let [origin, target] = records[0].split(","); let count = nbsNotifications[tabId].records[records[0]]; let params = [origin, target, count]; message = nbsSettings.blocking ? browser.i18n.getMessage("NBSBlockedMessageWithTarget", params) : browser.i18n.getMessage("NBSDetectedMessageWithTarget", params); } else { // The page contacted multiple targets, display just the host let host = getEffectiveDomain(availableTabs[tabId].url); let params = [host, nbsNotifications[tabId].total]; message = nbsSettings.blocking ? browser.i18n.getMessage("NBSBlockedMessageMultipleTargets", params) : browser.i18n.getMessage("NBSDetectedMessageMultipleTargets", params); } browser.notifications.create("nbs-" + tabId, { "type": "basic", "iconUrl": browser.runtime.getURL("img/icon-48.png"), "title": nbsSettings.blocking ? browser.i18n.getMessage("NBSBlockedTitle") : browser.i18n.getMessage("NBSDetectedTitle"), "message": message }); setTimeout(() => { browser.notifications.clear("nbs-" + tabId); }, 6000); } /** * \brief The event listener, hooked up to the webExtension onMessage event. * * The listener sends message response which contains information if the current site is whitelisted or not. * * \param message Receives full message (destructured as {message, site}). * \param sender Sender of the message. */ function nbsMessageListener({message, site}, sender) { //Message came from popup,js, asking whether is this site whitelisted if (message === "is current site whitelisted?") { return Promise.resolve(`current site is ${isNbsWhitelisted(site) ? '' : 'not '}whitelisted`); } } /** * \brief The event listener, hooked up to the webExtension onMessage event. * * The listener sends message response which contains information about cuurent module settings. * * \param message Receives full message. */ function nbsSettingsListener(message) { if (message.purpose === "nbs-get-settings") { // send settings definition and current values return Promise.resolve({ def: NBS_DEF_SETTINGS, val: nbsSettings }); } else if (message.purpose === "nbs-set-settings") { // update current settings nbsSettings[message.id] = message.value; browser.storage.sync.set({"nbsSettings": nbsSettings}); } else if (message.purpose === "nbs-load-config") { // load current configuration nbsLoadConfiguration(); } } /** * Event listener hooked up to webExtensions onMessage event. * Does appropriate action based on message (e.g. Turn on/off the NBS, add/remove a site to/from whitelist, ...). * * \param message Receives full message. * \param sender Sender of the message. */ function nbsCommonMessageListener(message, sender) { //Message came from options.js, updated whitelist if (message.message === "whitelist updated") { //actualize current doNotBlockHosts from storage browser.storage.sync.get(["nbsWhitelist"]).then(function(result){ doNotBlockHosts = result.nbsWhitelist; }); } //Mesage came from popup.js, whitelist this site else if (message.message === "add site to whitelist") { //Obtain current hostname and whitelist it var currentHost = message.site; doNotBlockHosts[currentHost] = true; browser.storage.sync.set({"nbsWhitelist":doNotBlockHosts}); } //Message came from popup.js, remove whitelisted site else if (message.message === "remove site from whitelist") { //Obtain current hostname and remove it currentHost = message.site; delete doNotBlockHosts[currentHost]; browser.storage.sync.set({"nbsWhitelist":doNotBlockHosts}); } //HTTP request shield was turned on else if (message.message === "turn request shield on") { //Hook up the listeners browser.webRequest.onBeforeSendHeaders.addListener( beforeSendHeadersListener, {urls: [""]}, ["blocking", "requestHeaders"] ); if (typeof onResponseStartedListener === "function") { browser.webRequest.onResponseStarted.addListener( onResponseStartedListener, {urls: [""]}, ["responseHeaders"] ); } } //HTTP request shield was turned off else if (message.message === "turn request shield off") { //Disconnect the listeners browser.webRequest.onBeforeSendHeaders.removeListener(beforeSendHeadersListener); if (typeof onResponseStartedListener === "function") { browser.webRequest.onResponseStarted.removeListener(onResponseStartedListener); } } }