Files
sousa-gecko/toolkit/components/ipprotection/IPPExceptionsManager.sys.mjs
T

393 lines
12 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
IPProtectionService:
"moz-src:///toolkit/components/ipprotection/IPProtectionService.sys.mjs",
});
const PERM_NAME = "ipp-vpn";
const INCLUSION_PREF = "browser.ipProtection.inclusion.match_patterns";
const MATCH_PATTERN_OPTIONS = {
ignorePath: true,
restrictSchemes: false,
};
const DEFAULT_EXCLUDED_URL_PREFS = [
"browser.ipProtection.guardian.endpoint",
"captivedetect.canonicalURL",
];
/**
* Converts an excluded page URL to a host match pattern (scheme://host/*), or
* null if the URL cannot be parsed.
*
* @param {string} url
* @returns {?string}
*/
function urlToHostPattern(url) {
try {
const uri = Services.io.newURI(url);
return `${uri.scheme}://${uri.host}/*`;
} catch (_) {
return null;
}
}
/** @typedef {"included"|"excluded"|"default"} IPPPrincipalRule */
export const IPPPrincipalRules = Object.freeze({
INCLUDED: "included",
EXCLUDED: "excluded",
DEFAULT: "default",
});
/**
* Manages site exceptions for IP Protection.
* It communicates with Services.perms to update the ipp-vpn permission type.
* Site exclusions are marked as permissions with DENY capabilities.
*
* While permissions related UI (eg. panels and dialogs) already handle changes to ipp-vpn,
* the intention of this class is to abstract methods for updating ipp-vpn as needed
* from other non-permissions related UI.
*
* This is also the single source of truth for classifying a principal as
* included/excluded/default for the proxy, via getPrincipalRule. It owns the
* inclusion MatchPatternSet (from INCLUSION_PREF) and the excluded-origins
* MatchPatternSet (from the default-excluded prefs + authProvider.excludedUrlPrefs),
* both kept up to date via pref observers while inited.
*/
class ExceptionsManager extends EventTarget {
#inited = false;
#observer = null;
#inclusionPrefObserver = null;
#inclusionSet = new MatchPatternSet([], MATCH_PATTERN_OPTIONS);
#excludedOrigins = new MatchPatternSet([], MATCH_PATTERN_OPTIONS);
#excludedPrefObserver = null;
#observedExcludedPrefs = [];
init() {
if (this.#inited) {
return;
}
// ES6 classes that extend EventTarget cannot be coerced into nsIObserver.
// Work around this by using a function as the observer.
this.#observer = (subject, topic, data) => {
this.observe(subject, topic, data);
};
Services.obs.addObserver(this.#observer, "perm-changed");
this.#inclusionSet = ExceptionsManager.getInclusionList();
this.#inclusionPrefObserver = () => {
this.#inclusionSet = ExceptionsManager.getInclusionList();
};
Services.prefs.addObserver(INCLUSION_PREF, this.#inclusionPrefObserver);
// The excluded origins come from the default-excluded prefs plus the active
// auth provider's prefs; observe each so the set stays current.
this.#observedExcludedPrefs = [
...DEFAULT_EXCLUDED_URL_PREFS,
...(lazy.IPProtectionService.authProvider?.excludedUrlPrefs ?? []),
];
this.#excludedPrefObserver = () => this.#rebuildExcludedOrigins();
for (const pref of this.#observedExcludedPrefs) {
Services.prefs.addObserver(pref, this.#excludedPrefObserver);
}
this.#rebuildExcludedOrigins();
this.#inited = true;
}
uninit() {
if (!this.#inited) {
return;
}
Services.obs.removeObserver(this.#observer, "perm-changed");
this.#observer = null;
if (this.#inclusionPrefObserver) {
Services.prefs.removeObserver(
INCLUSION_PREF,
this.#inclusionPrefObserver
);
this.#inclusionPrefObserver = null;
}
if (this.#excludedPrefObserver) {
for (const pref of this.#observedExcludedPrefs) {
Services.prefs.removeObserver(pref, this.#excludedPrefObserver);
}
this.#excludedPrefObserver = null;
this.#observedExcludedPrefs = [];
}
this.#inited = false;
}
/**
* Rebuilds the excluded-origins MatchPatternSet from the observed prefs
* (default-excluded prefs + the active auth provider's prefs).
*/
#rebuildExcludedOrigins() {
const patterns = [];
for (const pref of this.#observedExcludedPrefs) {
const pattern = urlToHostPattern(Services.prefs.getStringPref(pref, ""));
if (pattern) {
patterns.push(pattern);
}
}
this.#excludedOrigins = new MatchPatternSet(
patterns,
MATCH_PATTERN_OPTIONS
);
}
observe(subject, topic, data) {
if (topic !== "perm-changed") {
return;
}
let permission = subject.QueryInterface(Ci.nsIPermission);
if (permission.type !== PERM_NAME) {
return;
}
const isExclusion =
permission.capability === Ci.nsIPermissionManager.DENY_ACTION;
const added = data === "added" && isExclusion;
const removed = data === "deleted" && isExclusion;
if (added || removed) {
if (added) {
Glean.ipprotection.exclusionAdded.add(1);
}
this.dispatchEvent(
new CustomEvent("IPPExceptionsManager:ExclusionChanged")
);
}
}
/**
* Adds a principal to ipp-vpn with capability DENY_ACTION
* for site exclusions.
*
* @param {nsIPrincipal} principal
* The principal that we want to add as a site exception.
*/
addExclusion(principal) {
Services.perms.addFromPrincipal(
principal,
PERM_NAME,
Ci.nsIPermissionManager.DENY_ACTION
);
}
/**
* Removes an existing principal from ipp-vpn.
*
* @param {nsIPrincipal} principal
* The principal that we want to remove as a site exception.
*/
removeExclusion(principal) {
Services.perms.removeFromPrincipal(principal, PERM_NAME);
}
/**
* Returns true if the principal already exists in ipp-vpn
* and is registered as a permission with a DENY_ACTION
* capability (site exclusions).
* Else returns false if no such principal exists.
*
* @param {nsIPrincipal} principal
* The principal that we want to check is saved in ipp-vpn
* as a site exclusion.
* @returns {boolean}
* True if the principal exists as a site exclusion.
*/
hasExclusion(principal) {
let permission = this.getExceptionPermissionObject(principal);
return permission?.capability === Ci.nsIPermissionManager.DENY_ACTION;
}
/**
* Get the permission object for a site exception if it exists in ipp-vpn.
*
* Use exactHost=true to only match the specific origin, not the base domain.
* This ensures that subdomains aren't implicitly excluded when entering
* a site in the about:preferences dialog. It also avoids an issue where we
* try to remove a subdomain as an exclusion when the site doesn't exist in ipp-vpn
* (see Bug 2016676).
*
* Eg. if we enter "example.com" in the dialog, "www.example.com" and
* "subdomain.example.com" won't be considered exclusions.
*
* @param {nsIPrincipal} principal
* The principal that we want to check is saved in ipp-vpn.
*
* @returns {nsIPermission}
* The permission object for a site exception, or null if unavailable.
*/
getExceptionPermissionObject(principal) {
let permissionObject = Services.perms.getPermissionObject(
principal,
PERM_NAME,
true /* exactHost */
);
return permissionObject;
}
/**
* Gets the total number of site exclusions added to ipp-vpn.
*
* @returns {number}
* The count of site exclusions in ipp-vpn.
*/
getExclusionCount() {
let count = 0;
for (let perm of Services.perms.getAllByTypes([PERM_NAME])) {
if (perm.capability === Ci.nsIPermissionManager.DENY_ACTION) {
count++;
}
}
return count;
}
/**
* Sets the given principal as an exclusion or non exclusion.
*
* @param {nsIPrincipal} principal
* The principal we want to update for the exclusion state.
* @param {boolean} shouldExclude
* True to set the principal as an exclusion. Otherwise false.
*
* @example
* // Assuming the principal represents a site https://www.example.com,
* // this line sets https://www.example.com as an exclusion
* // in ipp-vpn.
* IPPExceptionsManager.setExclusion(nsIPrincipal, true);
*/
setExclusion(principal, shouldExclude) {
if (!principal) {
return;
}
const isExclusion = this.hasExclusion(principal);
// Early return if already in desired state
if ((shouldExclude && isExclusion) || (!shouldExclude && !isExclusion)) {
return;
}
if (shouldExclude) {
this.addExclusion(principal);
} else {
this.removeExclusion(principal);
}
}
/**
* Builds the inclusion MatchPatternSet from INCLUSION_PREF.
*
* @returns {MatchPatternSet}
*/
static getInclusionList() {
let raw = Services.prefs.getStringPref(INCLUSION_PREF, "[]");
let arr = JSON.parse(raw);
if (!Array.isArray(arr)) {
throw new TypeError(`${INCLUSION_PREF} does not contain a JSON array`);
}
let patterns = arr.filter(s => typeof s === "string" && s.length);
return new MatchPatternSet(patterns, MATCH_PATTERN_OPTIONS);
}
/**
* @param {nsIPrincipal} principal
* @returns {boolean} True if the principal is a loopback host or a local IP.
*/
static isLocal(principal) {
return principal.isLoopbackHost || principal.isLocalIpAddress;
}
/**
* Classifies a principal as included/excluded/default for the proxy.
*
* This is the single source of truth shared by the channel filter and the UI.
* Everything is derived from the principal: the inclusion match (its URI),
* scheme, loopback/local, the ipp-vpn user permission, and the default
* excluded origins. Fails safe to EXCLUDED on any error.
*
* @param {nsIPrincipal} principal
* @returns {IPPPrincipalRule}
*/
getPrincipalRule(principal) {
if (!this.#inited) {
this.init();
}
try {
const uri = principal?.URI;
// Exclude non-http(s) schemes (about:, file:, etc.), but NOT null
// principals: a null principal's scheme is moz-nullprincipal even when it
// backs real http(s) content (e.g. a sandboxed iframe), so its scheme says
// nothing about whether the traffic should be proxied.
if (
!principal?.isNullPrincipal &&
!principal?.schemeIs("http") &&
!principal?.schemeIs("https")
) {
return IPPPrincipalRules.EXCLUDED;
}
if (ExceptionsManager.isLocal(principal)) {
return IPPPrincipalRules.EXCLUDED;
}
// Infrastructure origins the VPN itself depends on (guardian endpoint,
// captive detection). These must beat inclusion.
if (uri && this.#excludedOrigins.matches(uri)) {
return IPPPrincipalRules.EXCLUDED;
}
if (uri && this.#inclusionSet.matches(uri)) {
return IPPPrincipalRules.INCLUDED;
}
if (this.hasExclusion(principal)) {
return IPPPrincipalRules.EXCLUDED;
}
return IPPPrincipalRules.DEFAULT;
} catch (_) {
return IPPPrincipalRules.EXCLUDED;
}
}
/**
* Whether the user can manage a VPN site exclusion for this principal — i.e.
* it is a normal content page, not an about: page or the system principal.
*
* @param {nsIPrincipal} principal
* The principal to evaluate.
* @returns {boolean}
* True if the user can manage an exclusion for this page.
*/
canManage(principal) {
if (!principal) {
return false;
}
// about:/resource: pages and the system principal are not user-manageable sites.
// Loopback/local hosts are always excluded by the proxy so the user cannot
// toggle them either.
return (
!principal.schemeIs("about") &&
!principal.schemeIs("resource") &&
!principal.isSystemPrincipal &&
!ExceptionsManager.isLocal(principal)
);
}
}
const IPPExceptionsManager = new ExceptionsManager();
export { IPPExceptionsManager };