From b936d16a7c5d911807fe44eb53edbba4f8a1b0c1 Mon Sep 17 00:00:00 2001 From: Anton Bershanskiy <bershan2@illinois.edu> Date: Tue, 13 Nov 2018 17:23:04 -0600 Subject: [PATCH] Added domain name status icons; started HSTS and thirdparty analysis Added Font Awesome domain name status icons "secure", "enabled", "disabled", "insecure", started implementing HSTS analysis (dynamic only, no preload lists). Created definition of thirdparty sources and primitive implementation. --- package.json | 5 +- source/common/background/cookiestore.js | 150 +++++++++++++++--- .../common/libraries/thirdparty/thirdparty.js | 17 ++ source/common/pages/popup/popup.css | 32 +++- source/common/pages/popup/popup.js | 67 +++++++- 5 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 source/common/libraries/thirdparty/thirdparty.js diff --git a/package.json b/package.json index cb085f9..5b4ff6a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,10 @@ }, "scripts": { "test": "test", - "build": "webpack", + "build": "webpack --mode=production", + "dev": "webpack --mode=development", "clean": "rm -rf build;", - "icons": "curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/question-circle-o.svg -o source/common/includes/fontawesome/question-circle-o.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/caret-down.svg -o source/common/includes/fontawesome/caret-down.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/caret-right.svg -o source/common/includes/fontawesome/caret-right.svg" + "icons": "curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/question-circle-o.svg -o source/common/includes/fontawesome/question-circle-o.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/caret-down.svg -o source/common/includes/fontawesome/caret-down.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/caret-right.svg -o source/common/includes/fontawesome/caret-right.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/check.svg -o source/common/includes/fontawesome/check.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/lock.svg -o source/common/includes/fontawesome/lock.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/unlock.svg -o source/common/includes/fontawesome/unlock.svg && curl https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/black/svg/exclamation-triangle.svg -o source/common/includes/fontawesome/exclamation-triangle.svg" }, "repository": { "type": "git", diff --git a/source/common/background/cookiestore.js b/source/common/background/cookiestore.js index bc28317..58a0d99 100644 --- a/source/common/background/cookiestore.js +++ b/source/common/background/cookiestore.js @@ -6,6 +6,8 @@ const cookie = require("cookie") const parseResponseHeaderStrictTransportSecurity = require("../libraries/hsts/hsts").parseResponseHeaderStrictTransportSecurity +const thirdparty = require("../libraries/thirdparty/thirdparty").thirdparty + // Initialize the cookie database upon extension installation const urlPopup = window.location.origin + "/pages/popup/popup.html" @@ -13,14 +15,30 @@ const urlPopup = window.location.origin + "/pages/popup/popup.html" // TODO: make an actual datasructure. var cookieDatabase = {"example":"example"} - /* Structure of tabObservations is as follows: - tabObservations is a dictionaty with keys tabId and values - objects: originUrl (string) - observations - array of arrays of observations + tabObservations is a dictionaty with keys tabId and values objects + originUrl (string) - sanity check that page is correct + domains - a dictionary with keys domains and values objects + thirdparty (boolean) - if this should be displayed on "Primary resources" or "Third-party resources" + this is assigned when record is created + status (string) + this is assigned/updated when record is requested by UI + status has one of the following values: + "secure" -- domain is already protected, e.g. by HSTS or CSP + "enabled" -- we protect vulnerable domain + "disabled" -- we let vulnerable domain be vulnerable + "insecure" -- domain does not support HTTPS (hopeless case) + // reason - string explanation, to be put on tooltip CSP HSTS + cookies - dictionary of cookies with key cookie name and value object + (it is summary to be displayed in the popup) + events - all things that ever happened */ var tabObservations = {} +// Disctionary with keys domains and values the current HSTS record +// TODO: remember time of record creation +var hstsObservations = {} + /* * Prepare datastructures of the newly installed extension */ @@ -52,9 +70,59 @@ platform.runtime.onInstalled.addListener (function() { case "general": console.log("Popup open: General") break + // Prepare security report case "security": - console.log("Popup open: Security", tabObservations) - sendResponse(tabObservations[message.tabId]) + console.log("Popup open: Security, preparing report") + let report = tabObservations[message.tabId] + console.log("REPORT BEFORE", report) + console.log(hstsObservations) + for (const domain in report.domains){ + // Update domain status + switch(report.domains[domain].status){ + case "secure": + case "disabled": + case "insecure": + case null: + // Need to update security assessment + // TODO: which check CSP and differentiate between "disabled" and "insecure" + // REFACTOR: Move to "library"? + var status = null + + // Check HSTS + const subDomains = domain.split(".") + const numSubDomains = subDomains.length + var currSubDomain = "" + for (var i=1; i<=numSubDomains; i++){ + // Update subdomain + currSubDomain = subDomains[numSubDomains-i] + currSubDomain + // Check against the HSTS records + if (hstsObservations[currSubDomain] !== undefined && hstsObservations[currSubDomain].content.maxAge > 0){ + // TODO: take into account time of record creation + // TODO: take into account consistency + // TODO: take into account includeSubdomains directve if currSubDomain !== domain + // TODO: take into account preload list + status = "secure" + } + console.log(domain, subDomains, currSubDomain, hstsObservations[currSubDomain]) + // Prepare for the next iteration + currSubDomain = "." + currSubDomain + } + // Check CSP + // TODO! + report.domains[domain].status = status + break + + + case "enabled": + // Nothing to do, security assessment does not affect anything + break + default: + console.log ("ERROR: Unexpected status for " + domain +" while preparing report.", report[domain]) + break + } + } + console.log("Popup open: Security, sending report", report) + sendResponse(report) break default: console.log("Error") @@ -78,21 +146,52 @@ function onPermissionWebRequestGranted(){ /* * Remembers observations about tabs (pages) + * TODO: make this into a Promise + * TODO: avoid passing `request` (pick out only the necessary parts in caller) */ - function rememberTabObservation(request, observation){ + function rememberTabObservation(request, observations){ const tabId = request.tabId, originUrl = request.originUrl, resource = request.url, parentFrameId = request.parentFrameId -// console.log("New observation", request) // if no record exists, create one; if record is tied to different origin, assume it is a different page // TODO: is the above assumption correct for, e.g., SPA? - const newPage = false//typeof tabObservations[tabId] === "object" && parentFrameId !== -1 && tabObservations[tabId].originUrl !== originUrl + const newPage = false// parentFrameId !== -1 && tabObservations[tabId].originUrl !== originUrl if (tabObservations[tabId] === undefined || newPage) - tabObservations[tabId] = {originUrl: originUrl, observations:{}} + tabObservations[tabId] = {originUrl: originUrl, domains:{}} + + var resourceDomain = new URL(resource).hostname + if (tabObservations[tabId].domains[resourceDomain] === undefined){ + // Determine whether domain is third-party + // TODO: find library or improve own implementation + const originDomain = new URL(originUrl).hostname + const thirdparty_ = thirdparty(originDomain, resourceDomain) - var resourceHost = new URL(resource).host - if (tabObservations[tabId].observations[resourceHost] === undefined) - tabObservations[tabId].observations[resourceHost] = [] - tabObservations[tabId].observations[resourceHost].push(observation) -// console.log("New observation logged", tabObservations) + tabObservations[tabId].domains[resourceDomain] = {thirdparty: thirdparty_, status: null, events: []} + } + tabObservations[tabId].domains[resourceDomain].events.push(observations) + for (var observation of observations){ + switch (observation.type){ + case "HSTS": + // TODO: Is consistency critera good? + if (hstsObservations[resourceDomain] === undefined){ + // this is first HSTS header encountered + hstsObservations[resourceDomain] = { + consistent: true, + content: observation.content, + createdTime: null // TODO: remember time + } + } else { + // this domain already has HSTS policy + if (hstsObservations[resourceDomain] && hstsObservations[resourceDomain].maxAge !== observation.content) + hstsObservations[resourceDomain].consistent = false + hstsObservations[resourceDomain].content = observation.content + hstsObservations[resourceDomain].createdTime = null // TODO: remember time + } + break + case "CSP": + break + default: + console.log("ERROR: Unhandled observation", observation) + } + } } /* @@ -109,10 +208,12 @@ function onPermissionWebRequestGranted(){ observations.push({type: "Cookie", content: "Cookie sent: " + details.requestHeaders[i].value}) -/* if (protocol === "http:" && (cookieDatabase[name] === undefined || cookieDatabase[name].secureOrigin === true)){ - console.log("LEAK", name, value, parsed) + for (const cookieName in parsed){ + if (protocol === "http:" && (cookieDatabase[cookieName] === undefined || cookieDatabase[cookieName].secureOrigin === true)){ + observations.push({type: "Cookie", content: "Cookie leak: " + cookieName + "=" + parsed[cookieName]}) + } } -*/ + break } } @@ -133,7 +234,7 @@ function onPermissionWebRequestGranted(){ var observations = [] // Get additional parameters - const protocol = new URL(details.url).protocol + const url = new URL(details.url).protocol for (var i = 0; i < details.responseHeaders.length; ++i) { const headerName = details.responseHeaders[i].name.toLowerCase() const headerValue = details.responseHeaders[i].value @@ -141,14 +242,16 @@ function onPermissionWebRequestGranted(){ case "set-cookie": const name = headerValue.substring(0, headerValue.indexOf("=")) observations.push({type: "Cookie", content: "Cookie set: "+name}) - cookieDatabase[name] = {secureOrigin: protocol === "https:", httpOnly: true} + cookieDatabase[name] = {secureOrigin: url.protocol === "https:", httpOnly: true} break case "strict-transport-security": + // Remember HSTS header for later reference in the popup + // Parsing library options: did not find any, had to write my own var hstsAttributes = parseResponseHeaderStrictTransportSecurity(headerValue) - observations.push({type: "HSTS", content: [headerValue, hstsAttributes]}) + observations.push({type: "HSTS", content: hstsAttributes}) break -// Options: did not find any, had to write my own + case "content-security-policy": // Options: // https://www.npmjs.com/package/content-security-policy-parser @@ -168,6 +271,9 @@ function onPermissionWebRequestGranted(){ case "x-content-security-policy": // fallthrough case "x-webkit-csp": + // These are predecessors to HSTS. Although they are still supported by modern browsers, some bugs are known. + // We ignore them in desiding host security status, and instead display a warning. + // In practice, I haven't seen these ever used, so we don't waste time on them. const message = "CSP Information: " + details.responseHeaders[i].name + " is deprecated and is known to cause problems. https://content-security-policy.com/111" observations.push({type: "Warning", content: message}) break diff --git a/source/common/libraries/thirdparty/thirdparty.js b/source/common/libraries/thirdparty/thirdparty.js new file mode 100644 index 0000000..9d3564f --- /dev/null +++ b/source/common/libraries/thirdparty/thirdparty.js @@ -0,0 +1,17 @@ +/* + * Determine if resource is a third-party based only on domain names + * @param origin - string, domain of the orgin page or frame + * @param resource - string, domain of the resource + * @returns boolean - true if thirdpary, false otherwise + */ + +module.exports = {thirdparty: thirdparty} + +// This is a very primitive check, need to switch to eTLD+1 check and entities list +// Currently it checks that origin domain is a suffix for the resource domain +// (after dropping common sub-domain "www" from origin) +// E.g.: "cdn.example.com".endsWith("www.example.com".replace("www.", "")) === true + +function thirdparty (origin, resource){ + return !resource.endsWith(origin.replace("www.", "")) +} diff --git a/source/common/pages/popup/popup.css b/source/common/pages/popup/popup.css index 6462946..9b8983c 100644 --- a/source/common/pages/popup/popup.css +++ b/source/common/pages/popup/popup.css @@ -23,6 +23,7 @@ h1, h2, h3, p { height: 100%; float: left; background-color: #eeeeee; + cursor: pointer; } /* Style of the Details section */ @@ -83,6 +84,7 @@ h1, h2, h3, p { .info{ width: 1em; float: right; + cursor: help; } /* @@ -120,7 +122,7 @@ h1, h2, h3, p { * on the left of collapsible list item * in OPEN position */ -.list-collapsible > li.list-collapsible--active::before { +ul.list-collapsible > li.list-collapsible--active::before { background-image: url("/includes/fontawesome/caret-down.svg"); background-size: 20px; display: inline-block; @@ -128,3 +130,31 @@ h1, h2, h3, p { height: 20px; content:""; } + +/* The icon to the right of the domain name in the list + * Can be one of "secure", "enabled", "disabled", "insecure" + */ + +li.list-domain > a::after { + background-size: 20px; + display: inline-block; + width: 20px; + height: 20px; + content:""; +} + +li.list-domain--secure > a::after { + background-image: url("/includes/fontawesome/check.svg"); +} + +li.list-domain--enabled > a::after { + background-image: url("/includes/fontawesome/lock.svg"); +} + +li.list-domain--disabled > a::after { + background-image: url("/includes/fontawesome/unlock.svg"); +} + +li.list-domain--insecure > a::after { + background-image: url("/includes/fontawesome/exclamation-triangle.svg"); +} diff --git a/source/common/pages/popup/popup.js b/source/common/pages/popup/popup.js index fe3444d..0140aa6 100644 --- a/source/common/pages/popup/popup.js +++ b/source/common/pages/popup/popup.js @@ -2,6 +2,7 @@ // TODO: Move to platform // List of pages on which extension is forbidden for security considerations. +// key is the URL prefix and value is the message to the user. const restrictedPrefixes = { "about:": "Firefox internal page.", "https://addons.mozilla.org/": "Mozilla extension store (AMO) page.", @@ -11,12 +12,14 @@ const restrictedPrefixes = { const platform = chrome var tabId = null +var firstPartyDomain = null platform.tabs.query({active: true, currentWindow: true }, function(activeTabs){ tabId = activeTabs[0].id + firstPartyDomain = (new URL(activeTabs[0].url)).hostname console.log(activeTabs) - pageNothingToDo(activeTabs) + pageNothingToDo(activeTabs[0].url) // Notify other pages (background) that popup is open platform.runtime.sendMessage({popupOpen: true, popupSection: "general", tabId: tabId}) @@ -34,10 +37,9 @@ function handleError(error) { } */ -function pageNothingToDo(activeTabs){ +function pageNothingToDo(url){ // Check the current page against all restricted pages - var url = activeTabs[0].url for (const prefix in restrictedPrefixes) // this is equivalent to startsWith() if (url.substring(0,prefix.length) === prefix){ @@ -89,8 +91,14 @@ function editLabel(evt) { } } -// Collapsible lists can be done with <details> and <summary>, -// but browser support is limited +/* + * Handle click on collapsable list label: + * show/hide details and change list label decoration + * Collapsible lists can be done with <details> and <summary>, + * but: 1. browser support is limited + * 2. still need JS toggle expanded/collapsed list decodation + * to the left of the label, aka arrow icons > or V + */ function collapsibleList(evt){ var target = evt.target // if clicked on the label A, go level up to the LI @@ -103,10 +111,52 @@ function collapsibleList(evt){ } } +/* + * + */ +function securityList(information, editable){ + function createElement(label_text, editable){ + var elem = document.createElement("LI") + var label = document.createElement("A") + label.innerText = label_text + elem.appendChild(label) + + // If element label is editable, create button for editing it + if (editable !== false){ + var btn = document.createElement("BUTTON") + btn.innerText = "edit" + elem.appendChild(btn) + } + return elem + } + // Create list + var list = document.createElement("UL") + for (const domain in information){ + // Create the list element with its details + var elem = createElement(domain, editable) + // Assign the right CSS class to display security status icon + const cssSecurityClass = "list-domain--" + information[domain].status + elem.classList.add(cssSecurityClass) + elem.classList.add("list-domain") + list.appendChild(elem) + + // Create sublist of cookies + var cookie_list = document.createElement("UL") + elem.appendChild(cookie_list) + + // Display sublist of cookies + // TODO: IMPLEMENT + // TODO: Display cookies for higher domains? + var entry = createElement("TODO: display cookies", editable) + cookie_list.appendChild(entry) + } + return list +} /* * Create nested list with editable labels (optional) */ +/* function nestedList(information, editable){ @@ -129,13 +179,14 @@ function nestedList(information, editable){ function createElement(label_text, editable){ var elem = document.createElement("LI") var label = document.createElement("A") - label.innerText = label_text + label.innerText = JSON.stringify(label_text) elem.appendChild(label) // If element label is editable, create button for editing it if (editable !== false){ var btn = document.createElement("BUTTON") btn.innerText = "edit" elem.appendChild(btn) + elem.classList.add("list-secure") } return elem } @@ -160,6 +211,7 @@ function nestedList(information, editable){ } return list } +*/ document.addEventListener("DOMContentLoaded", function() { function displaySectionPrimary(information) {} @@ -171,14 +223,13 @@ document.addEventListener("DOMContentLoaded", function() { // TODO: avoid complete list re-creation, // since it looses information about open and closed sublists - const listNew = nestedList(information.observations) + const listNew = securityList(information.domains) listNew.classList.add("list-collapsible") listNew.addEventListener("click", editLabel) listNew.addEventListener("click", collapsibleList) list.replaceWith(listNew) listNew.id = "details-" + "security" + "-list" - console.log("background script sent a response:", information) } function displaySectionDebugging(information) {} -- GitLab