Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
/**
* Security utilities for Firefox Smart Window security layer.
*
* This module provides:
* - URL normalization for consistent comparison
* - eTLD+1 (effective top-level domain) validation
* - TabLedger: Per-tab trusted URL storage
* - SessionLedger: Container for all tab ledgers in a Smart Window session
*
* Security Model:
* ---------------
* - Each tab maintains its own ledger of trusted URLs
* - Request-scoped context merges current tab + @mentioned tabs
* - URLs are normalized before storage and comparison
* - Same eTLD+1 validation prevents injection via canonical/og:url
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = XPCOMUtils.declareLazy({
console: () =>
console.createInstance({
maxLogLevelPref: "browser.ml.logLevel",
prefix: "SecurityUtils",
}),
});
/** TTL for ledger entries (30 minutes) */
const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
/** Max URLs per tab (prevents memory exhaustion) */
const MAX_URLS_PER_TAB = 1000;
/** Tracking params to strip during normalization */
const TRACKING_PARAMS = new Set([
"fbclid",
"gclid",
"msclkid",
"mc_eid",
"_ga",
// Note: utm_* params are handled via startsWith() pattern below
]);
/**
* Normalizes a URL for consistent comparison.
*
* Ensures the same logical URL always produces the same normalized string,
* regardless of superficial differences like:
* - Query param order (sorted alphabetically)
* - Tracking params (utm_*, fbclid, etc. are stripped)
* - Case differences in hostname
*
* @param {string} urlString - URL to normalize
* @param {string} [baseUrl] - Base URL for relative resolution
* @returns {object} { success, url?, error? }
*/
export function normalizeUrl(urlString, baseUrl = null) {
if (!urlString || !String(urlString).trim()) {
return {
success: false,
error: "Empty URL",
};
}
try {
let url;
try {
url = baseUrl ? new URL(urlString, baseUrl) : new URL(urlString);
} catch (parseError) {
return {
success: false,
error: "Invalid URL format",
};
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return {
success: false,
error: `Unsupported scheme: ${url.protocol}`,
};
}
const cleanedParams = new URLSearchParams();
for (const [key, value] of url.searchParams) {
if (key.startsWith("utm_") || TRACKING_PARAMS.has(key)) {
continue;
}
cleanedParams.append(key, value);
}
cleanedParams.sort();
const search = cleanedParams.toString();
let normalizedUrl = `${url.protocol}//${url.hostname}`;
if (url.port) {
normalizedUrl += `:${url.port}`;
}
normalizedUrl += url.pathname;
if (search) {
normalizedUrl += `?${search}`;
}
return {
success: true,
url: normalizedUrl,
};
} catch (error) {
return {
success: false,
error: error.message || String(error),
};
}
}
/**
* Validates that two URLs share the same eTLD+1 (effective top-level domain).
*
* @param {string} url1 - First URL (typically page URL)
* @param {string} url2 - Second URL (typically canonical/og:url)
* @returns {boolean} True if both URLs share the same eTLD+1
*/
export function areSameSite(url1, url2) {
try {
const parsed1 = new URL(url1);
const parsed2 = new URL(url2);
const eTLD1 = Services.eTLD.getBaseDomainFromHost(parsed1.hostname);
const eTLD2 = Services.eTLD.getBaseDomainFromHost(parsed2.hostname);
return eTLD1 === eTLD2;
} catch (error) {
lazy.console.error("areSameSite error:", error.message);
return false;
}
}
/**
* Per-tab storage for trusted URLs.
*
* Each tab maintains its own ledger of URLs that are authorized for
* security-sensitive operations. URLs are stored with expiration timestamps
* and the ledger enforces size limits to prevent memory exhaustion.
*/
export class TabLedger {
/**
* Creates a new tab ledger.
*
* @param {string} tabId - The tab identifier
*/
constructor(tabId) {
this.tabId = tabId;
this.ttlMs = DEFAULT_TTL_MS;
this.maxUrls = MAX_URLS_PER_TAB;
/** @type {Map<string, number>} URL --> expiration timestamp */
this.urls = new Map();
/** @type {number} Last cleanup timestamp */
this.lastCleanup = ChromeUtils.now();
}
/**
* Seeds the ledger with initial URLs.
*
* Behavior:
* - Runs cleanup of expired entries before adding
* - Invalid URLs are skipped silently (no error thrown)
* - Stops adding when maxUrls limit is reached
* - Each URL expires after TTL (default 30 minutes)
*
* @param {string[]} urls - URLs to seed
* @param {string} [baseUrl] - Optional base URL for resolving relative URLs
*/
seed(urls, baseUrl = null) {
this.#cleanup();
const now = ChromeUtils.now();
const expiresAt = now + this.ttlMs;
for (const url of urls) {
if (this.urls.size >= this.maxUrls) {
break;
}
const normalized = normalizeUrl(url, baseUrl);
if (normalized.success) {
this.urls.set(normalized.url, expiresAt);
}
}
this.lastCleanup = now;
}
/**
* Adds a single URL to the ledger.
*
* @param {string} url - URL to add
* @param {string} [baseUrl] - Optional base URL for resolving relatives
* @returns {boolean} True if added successfully, false if invalid or at capacity
*/
add(url, baseUrl = null) {
this.#cleanup();
if (this.urls.size >= this.maxUrls) {
return false;
}
const normalized = normalizeUrl(url, baseUrl);
if (!normalized.success) {
return false;
}
const expiresAt = ChromeUtils.now() + this.ttlMs;
this.urls.set(normalized.url, expiresAt);
return true;
}
/**
* Checks if a URL is in the ledger and not expired.
*
* @param {string} url - URL to check (will be normalized)
* @param {string} [baseUrl] - Optional base URL for resolving relatives
* @returns {boolean} True if URL is in ledger and not expired
*/
has(url, baseUrl = null) {
const normalized = normalizeUrl(url, baseUrl);
if (!normalized.success) {
return false;
}
const expiresAt = this.urls.get(normalized.url);
if (expiresAt === undefined) {
return false;
}
// Check expiration
if (ChromeUtils.now() > expiresAt) {
this.urls.delete(normalized.url);
return false;
}
return true;
}
/**
* Clears all URLs from the ledger.
* Typically called on tab navigation or tab close.
*/
clear() {
this.urls.clear();
this.lastCleanup = ChromeUtils.now();
}
/**
* Returns the number of URLs currently in the ledger (including expired).
*
* @returns {number} Number of URLs
*/
size() {
return this.urls.size;
}
/**
* Removes expired entries from the ledger.
* Called automatically during add() and can be called manually.
*
* @private
*/
#cleanup() {
const now = ChromeUtils.now();
for (const [url, expiresAt] of this.urls) {
if (now > expiresAt) {
this.urls.delete(url);
}
}
this.lastCleanup = now;
}
/**
* Returns all URLs currently in the ledger (expired entries removed).
*
* @returns {string[]} Array of URLs
*/
getAll() {
this.#cleanup();
return Array.from(this.urls.keys());
}
}
/**
* Container for all tab ledgers in an AI Window session.
*
* A session represents a single AI Window instance. Each AI Window
* creates its own SessionLedger when opened. The session ends when the AI
* Window is closed.
*
* SessionLedger manages the lifecycle of individual TabLedgers and provides
* methods to build request-scoped contexts by merging tab ledgers.
*
* Lifetime: SessionLedger is ephemeral and in-memory only. It is scoped to
* the current browser session and cleared on restart. Ledgers are not
* persisted to disk or restored via session restore.
*/
export class SessionLedger {
/**
* Creates a new session ledger.
*
* @param {string} sessionId - The Smart Window session identifier
*/
constructor(sessionId) {
this.sessionId = sessionId;
/** @type {Map<string, TabLedger>} Map of tab ID --> TabLedger */
this.tabs = new Map();
}
/**
* Gets or creates a TabLedger for the specified tab.
*
* @param {string} tabId - The tab identifier
* @returns {TabLedger} The tab's ledger
*/
forTab(tabId) {
if (!this.tabs.has(tabId)) {
this.tabs.set(tabId, new TabLedger(tabId));
}
return this.tabs.get(tabId);
}
/**
* Merges ledgers from multiple tabs into a temporary request-scoped ledger.
*
* This is used to build context for requests with @mentions, where the user
* explicitly authorizes access to multiple tabs.
*
* IMPORTANT: The returned merged ledger is a temporary view. It should be
* used for a single request and then discarded. It does NOT support add()
* operations (read-only for policy evaluation).
*
* @param {string[]} tabIds - Tab IDs to merge (typically current + @mentioned)
* @returns {object} Merged ledger with has() and size() methods
*/
merge(tabIds) {
const mergedUrls = new Set();
for (const tabId of tabIds) {
const ledger = this.forTab(tabId);
const now = ChromeUtils.now();
for (const [url, expiresAt] of ledger.urls) {
if (now <= expiresAt) {
mergedUrls.add(url);
}
}
}
// Return a temporary read-only ledger
return {
/**
* Checks if URL is in any of the merged ledgers.
*
* @param {string} url - URL to check
* @param {string} [baseUrl] - Optional base URL
* @returns {boolean} True if URL is in any merged ledger
*/
has(url, baseUrl = null) {
const normalized = normalizeUrl(url, baseUrl);
if (!normalized.success) {
return false;
}
return mergedUrls.has(normalized.url);
},
/**
* Returns number of unique URLs in merged ledger.
*
* @returns {number} Number of URLs
*/
size() {
return mergedUrls.size;
},
};
}
/**
* Removes a tab's ledger completely.
* Typically called when tab closes.
*
* @param {string} tabId - The tab identifier
*/
removeTab(tabId) {
this.tabs.delete(tabId);
}
/** Clears all tab ledgers. */
clearAll() {
for (const ledger of this.tabs.values()) {
ledger.clear();
}
this.tabs.clear();
}
/** @returns {number} Number of tabs */
tabCount() {
return this.tabs.size;
}
}