diff --git a/browser/components/enterprisepolicies/Policies.sys.mjs b/browser/components/enterprisepolicies/Policies.sys.mjs index a32a545e14a6b..865798a5e9d86 100644 --- a/browser/components/enterprisepolicies/Policies.sys.mjs +++ b/browser/components/enterprisepolicies/Policies.sys.mjs @@ -26,6 +26,8 @@ ChromeUtils.defineESModuleGetters(lazy, { AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", BookmarksPolicies: "resource:///modules/policies/BookmarksPolicies.sys.mjs", + BuiltinDataLossPrevention: + "resource:///modules/policies/BuiltinDataLossPrevention.sys.mjs", CustomizableUI: "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", @@ -724,6 +726,19 @@ export var Policies = { }, }, + BuiltinDataLossPrevention: { + onBeforeAddons(manager, param) { + if (param.Enabled) { + lazy.BuiltinDataLossPrevention.enable(manager, param); + } else { + lazy.BuiltinDataLossPrevention.disable(manager); + } + }, + onRemove(manager, _oldParams) { + lazy.BuiltinDataLossPrevention.disable(manager); + }, + }, + CaptivePortal: { onBeforeAddons(manager, param) { setAndLockPref("network.captive-portal-service.enabled", param); @@ -1004,6 +1019,10 @@ export var Policies = { // to be consistent. Services.prefs.lockPref("browser.contentanalysis.enabled"); } + lazy.BuiltinDataLossPrevention.updateDLPMode(manager); + }, + onRemove(manager, _oldParams) { + lazy.BuiltinDataLossPrevention.updateDLPMode(manager); }, }, diff --git a/browser/components/enterprisepolicies/content/aboutPolicies.js b/browser/components/enterprisepolicies/content/aboutPolicies.js index 253ca494c08cb..25eff95e94ac4 100644 --- a/browser/components/enterprisepolicies/content/aboutPolicies.js +++ b/browser/components/enterprisepolicies/content/aboutPolicies.js @@ -254,6 +254,7 @@ function generateErrors() { "WindowsGPOParser", "Enterprise Policies Child", "BookmarksPolicies", + "BuiltinDataLossPrevention", "ProxyPolicies", "WebsiteFilter Policy", "macOSPoliciesParser", diff --git a/browser/components/enterprisepolicies/helpers/BuiltinDataLossPrevention.sys.mjs b/browser/components/enterprisepolicies/helpers/BuiltinDataLossPrevention.sys.mjs new file mode 100644 index 0000000000000..58dd74b65a422 --- /dev/null +++ b/browser/components/enterprisepolicies/helpers/BuiltinDataLossPrevention.sys.mjs @@ -0,0 +1,178 @@ +/* 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/. */ + +import { + setAndLockPref, + unsetAndUnlockPref, +} from "resource:///modules/policies/Policies.sys.mjs"; + +const PREF_LOGLEVEL = "browser.policies.loglevel"; +const DLP_MODE_PREF = "enterprise.dlp.mode"; +const BUILTIN_RULES_PREF = "enterprise.dlp.builtin.rules"; + +const VALID_ACTIONS = [ + "ClipboardCopy", + "ClipboardPaste", + "Download", + "DragAndDrop", + "FileUpload", + "Print", +]; +const VALID_TYPES = ["warn", "block"]; +const NAME_REGEX = /^[a-z0-9-]{1,64}$/; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + return new ConsoleAPI({ + prefix: "BuiltinDataLossPrevention", + maxLogLevel: "error", + maxLogLevelPref: PREF_LOGLEVEL, + }); +}); + +/** + * Helper for the BuiltinDataLossPrevention enterprise policy. + * + * The built-in DLP and ContentAnalysis policies share a single derived + * `enterprise.dlp.mode` pref. ContentAnalysis takes precedence: when both + * policies are enabled the mode is "contentanalysis"; when only the built-in + * policy is enabled it is "builtin"; when neither is enabled the pref is + * cleared. Both policies call `updateDLPMode` whenever they are applied or + * removed so the mode always reflects the final active policy set. + */ +export const BuiltinDataLossPrevention = { + /** + * Recompute the shared `enterprise.dlp.mode` pref from the active policy set. + * + * Reads the final active policies (so application order is irrelevant) and + * resolves precedence in one place. Called by both the BuiltinDataLossPrevention + * and ContentAnalysis handlers on add and remove. + * + * @param {object} manager - The enterprise policy manager, used to query the + * currently active policies via `getActivePolicies()`. + */ + updateDLPMode(manager) { + const activePolicies = manager.getActivePolicies(); + const caActive = activePolicies?.ContentAnalysis?.Enabled === true; + const builtinActive = + activePolicies?.BuiltinDataLossPrevention?.Enabled === true; + + if (!caActive && !builtinActive) { + unsetAndUnlockPref(DLP_MODE_PREF); + return; + } + + setAndLockPref(DLP_MODE_PREF, caActive ? "contentanalysis" : "builtin"); + }, + + /** + * Validate the configured rules, dropping any that are invalid. + * + * Each rule is checked independently; an invalid rule is skipped and logged + * to about:policies#errors while the remaining rules still load. Enforces: + * - Name unique within the policy and matching [a-z0-9-]{1,64} + * - Actions non-empty and drawn only from the known interception points + * - Domains present and non-empty (["*"] matches all domains) + * - Type (when present) is "warn" or "block" + * + * @param {Array} rules - The raw Rules array from the policy. + * @returns {Array} The subset of rules that passed validation. + */ + validateRules(rules) { + const valid = []; + const seenNames = new Set(); + + for (const rule of rules) { + const name = rule.Name; + + if (typeof name !== "string" || !NAME_REGEX.test(name)) { + lazy.log.error( + `BuiltinDataLossPrevention: skipping rule with invalid Name ` + + `${JSON.stringify(name)} - must match [a-z0-9-]{1,64}.` + ); + continue; + } + + if (seenNames.has(name)) { + lazy.log.error( + `BuiltinDataLossPrevention: skipping rule "${name}" - Name must be ` + + `unique within the policy.` + ); + continue; + } + + if ( + !Array.isArray(rule.Actions) || + !rule.Actions.length || + !rule.Actions.every(action => VALID_ACTIONS.includes(action)) + ) { + lazy.log.error( + `BuiltinDataLossPrevention: skipping rule "${name}" - Actions must ` + + `be non-empty and only contain ${VALID_ACTIONS.join(", ")}.` + ); + continue; + } + + if ( + !Array.isArray(rule.Domains) || + !rule.Domains.length || + !rule.Domains.every( + domain => typeof domain === "string" && domain.length + ) + ) { + lazy.log.error( + `BuiltinDataLossPrevention: skipping rule "${name}" - Domains must ` + + `be present and contain only non-empty strings (use ["*"] for all ` + + `domains).` + ); + continue; + } + + if ("Type" in rule && !VALID_TYPES.includes(rule.Type)) { + lazy.log.error( + `BuiltinDataLossPrevention: skipping rule "${name}" - Type must be ` + + `"warn" or "block".` + ); + continue; + } + + seenNames.add(name); + valid.push(rule); + } + + return valid; + }, + + /** + * Enable built-in DLP: validate and persist the rules, then recompute the + * DLP mode. + * + * Valid rules (including those with `Enabled: false`, which load but stay + * inactive) are written to the locked `enterprise.dlp.builtin.rules` pref as + * a JSON string for the DLP engine to consume. + * + * @param {object} manager - The enterprise policy manager. + * @param {object} param - The BuiltinDataLossPrevention policy object. + * @param {Array} [param.Rules] - The configured DLP rules. + */ + enable(manager, param) { + const rules = this.validateRules(param.Rules ?? []); + setAndLockPref(BUILTIN_RULES_PREF, JSON.stringify(rules)); + this.updateDLPMode(manager); + }, + + /** + * Disable built-in DLP: clear the persisted rules and recompute the DLP mode. + * + * @param {object} manager - The enterprise policy manager. + */ + disable(manager) { + unsetAndUnlockPref(BUILTIN_RULES_PREF); + this.updateDLPMode(manager); + }, +}; diff --git a/browser/components/enterprisepolicies/helpers/moz.build b/browser/components/enterprisepolicies/helpers/moz.build index 6c29b462711f5..11f9e9b9c0a66 100644 --- a/browser/components/enterprisepolicies/helpers/moz.build +++ b/browser/components/enterprisepolicies/helpers/moz.build @@ -8,6 +8,7 @@ with Files("**"): EXTRA_JS_MODULES.policies += [ "AIChatbotPolicies.sys.mjs", "BookmarksPolicies.sys.mjs", + "BuiltinDataLossPrevention.sys.mjs", "ProxyPolicies.sys.mjs", "WebsiteFilter.sys.mjs", ] diff --git a/browser/components/enterprisepolicies/schemas/policies-schema.json b/browser/components/enterprisepolicies/schemas/policies-schema.json index 794d536c3578d..0863d50475ca9 100644 --- a/browser/components/enterprisepolicies/schemas/policies-schema.json +++ b/browser/components/enterprisepolicies/schemas/policies-schema.json @@ -558,6 +558,53 @@ } } }, + "BuiltinDataLossPrevention": { + "type": "object", + "properties": { + "Enabled": { + "type": "boolean" + }, + "Rules": { + "type": "array", + "strict": false, + "items": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + }, + "Actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "Domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "Posture": { + "type": "string" + }, + "Type": { + "type": "string" + }, + "Message": { + "type": "string" + } + }, + "required": ["Name", "Actions", "Domains"] + } + } + }, + "x-category": "Security", + "description": "Enable Firefox's built-in Data Loss Prevention. If ContentAnalysis is also enabled, it takes precedence and the built-in DLP will not activate." + }, "CaptivePortal": { "type": "boolean", "x-category": "Network security", diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_dlp.js b/browser/components/enterprisepolicies/tests/xpcshell/test_dlp.js new file mode 100644 index 0000000000000..97c386220409b --- /dev/null +++ b/browser/components/enterprisepolicies/tests/xpcshell/test_dlp.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const DLP_MODE_PREF = "enterprise.dlp.mode"; +const BUILTIN_RULES_PREF = "enterprise.dlp.builtin.rules"; + +function getPersistedRules() { + return JSON.parse(Preferences.get(BUILTIN_RULES_PREF)); +} + +add_task(async function test_builtin_only() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: true, + }, + }, + }); + + checkLockedPref(DLP_MODE_PREF, "builtin"); + deepEqual(getPersistedRules(), [], "No rules persisted when none configured"); +}); + +add_task(async function test_content_analysis_only() { + await setupPolicyEngineWithJson({ + policies: { + ContentAnalysis: { + Enabled: true, + }, + }, + }); + + checkLockedPref(DLP_MODE_PREF, "contentanalysis"); +}); + +add_task(async function test_both_enabled_content_analysis_wins() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: true, + }, + ContentAnalysis: { + Enabled: true, + }, + }, + }); + + checkLockedPref(DLP_MODE_PREF, "contentanalysis"); +}); + +add_task(async function test_builtin_disabled() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: false, + }, + }, + }); + + checkUnsetPref(DLP_MODE_PREF); +}); + +add_task(async function test_no_dlp_policies() { + await setupPolicyEngineWithJson({ + policies: {}, + }); + + checkUnsetPref(DLP_MODE_PREF); +}); + +add_task(async function test_rule_validation() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: true, + Rules: [ + { + Name: "block-uploads", + Actions: ["FileUpload"], + Domains: ["drive.google.com"], + Type: "block", + }, + { + Name: "warn-copy", + Actions: ["ClipboardCopy", "Print"], + Domains: ["*"], + }, + // Invalid Name (uppercase and underscore). + { + Name: "Bad_Name", + Actions: ["Download"], + Domains: ["*"], + }, + // Duplicate Name. + { + Name: "block-uploads", + Actions: ["Print"], + Domains: ["*"], + }, + // Empty Actions. + { + Name: "empty-actions", + Actions: [], + Domains: ["*"], + }, + // Action not in the enum. + { + Name: "bad-action", + Actions: ["Teleport"], + Domains: ["*"], + }, + // Empty Domains. + { + Name: "empty-domains", + Actions: ["Print"], + Domains: [], + }, + // Domains array with an empty-string entry. + { + Name: "empty-domain-string", + Actions: ["Print"], + Domains: [""], + }, + // Invalid Type. + { + Name: "bad-type", + Actions: ["Print"], + Domains: ["*"], + Type: "destroy", + }, + // Missing required Domains (dropped by schema validation). + { + Name: "missing-domains", + Actions: ["Print"], + }, + ], + }, + }, + }); + + checkLockedPref(DLP_MODE_PREF, "builtin"); + + const rules = getPersistedRules(); + deepEqual( + rules.map(rule => rule.Name), + ["block-uploads", "warn-copy"], + "Only valid rules are persisted; the rest of the policy still applies" + ); +}); + +add_task(async function test_disabled_rule_still_loads() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: true, + Rules: [ + { + Name: "inactive-rule", + Enabled: false, + Actions: ["Print"], + Domains: ["*"], + }, + ], + }, + }, + }); + + const rules = getPersistedRules(); + deepEqual( + rules.map(rule => rule.Name), + ["inactive-rule"], + "A valid but disabled rule still loads (Enabled:false disables, not removes)" + ); + strictEqual(rules[0].Enabled, false, "Rule retains its Enabled flag"); +}); + +add_task(async function test_invalid_rule_does_not_shadow_valid_duplicate() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: true, + Rules: [ + // Invalid (empty Actions) - must not reserve the Name. + { + Name: "shared-name", + Actions: [], + Domains: ["*"], + }, + // Valid rule with the same Name - must still load. + { + Name: "shared-name", + Actions: ["Print"], + Domains: ["*"], + }, + ], + }, + }, + }); + + deepEqual( + getPersistedRules().map(rule => rule.Name), + ["shared-name"], + "An earlier invalid rule does not shadow a later valid rule sharing its Name" + ); +}); + +add_task(async function test_precedence_transitions() { + // Builtin first. + await setupPolicyEngineWithJson({ + policies: { BuiltinDataLossPrevention: { Enabled: true } }, + }); + checkLockedPref(DLP_MODE_PREF, "builtin"); + + // Add ContentAnalysis -> it takes precedence. + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { Enabled: true }, + ContentAnalysis: { Enabled: true }, + }, + }); + checkLockedPref(DLP_MODE_PREF, "contentanalysis"); + + // Remove ContentAnalysis -> builtin takes back over. + await setupPolicyEngineWithJson({ + policies: { BuiltinDataLossPrevention: { Enabled: true } }, + }); + checkLockedPref(DLP_MODE_PREF, "builtin"); +}); + +add_task(async function test_disable_clears_rules() { + await setupPolicyEngineWithJson({ + policies: { + BuiltinDataLossPrevention: { + Enabled: true, + Rules: [{ Name: "r1", Actions: ["Print"], Domains: ["*"] }], + }, + }, + }); + deepEqual( + getPersistedRules().map(rule => rule.Name), + ["r1"], + "Rule is persisted while enabled" + ); + + // Disabling the policy runs disable(), which clears the persisted rules. + // (restoreDefaultValues does not reset this novel locked pref, so a passing + // assertion here proves disable() actively cleared it.) + await setupPolicyEngineWithJson({ + policies: { BuiltinDataLossPrevention: { Enabled: false } }, + }); + checkUnsetPref(BUILTIN_RULES_PREF); + checkUnsetPref(DLP_MODE_PREF); +}); diff --git a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml index 67a2515763461..a1c9b857045fe 100644 --- a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml +++ b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml @@ -29,6 +29,8 @@ support-files = [ ["test_defaultbrowsercheck.js"] +["test_dlp.js"] + ["test_empty_policy.js"] ["test_exempt_domain_file_type_pairs_from_file_type_download_warnings.js"] diff --git a/browser/locales/en-US/browser/enterprise/enterprise-policies-descriptions.ftl b/browser/locales/en-US/browser/enterprise/enterprise-policies-descriptions.ftl index 1b58cde90b1bd..fe2c9ab9da7da 100644 --- a/browser/locales/en-US/browser/enterprise/enterprise-policies-descriptions.ftl +++ b/browser/locales/en-US/browser/enterprise/enterprise-policies-descriptions.ftl @@ -5,8 +5,9 @@ policy-AccessConnector2 = Configure the { -enterprise-feature-access-connector } for proxying web traffic. policy-AIChatbot = Configure available AI chatbot providers, default provider, and prompt features. policy-BlocklistDomainBrowsedTelemetry = Enable and configure security logging/telemetry when { -brand-short-name } blocks a visit to a blocklisted domain. +policy-BuiltinDataLossPrevention = Enable or disable Firefox's built-in data loss prevention. If ContentAnalysis is also enabled, it takes precedence. +policy-CrashReportsSubmit = Configure crash report submission settings. policy-DownloadTelemetry = Enable and configure security logging/telemetry when a download is triggered. policy-EnterpriseStorageEncryption = Enable enterprise-managed primary password for encrypted storage. policy-PrintPageTelemetry = Enable and configure security logging/telemetry when a page is printed. policy-Sync = Enable or disable sync and define which data to include. -policy-CrashReportsSubmit = Configure crash report submission settings.