Differential Revision: https://phabricator.services.mozilla.com/D311287
393 lines
12 KiB
JavaScript
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 };
|