Implements push-based URL security validation for AI Window. The parent process pushes a trusted URL list to the child whenever the conversation's security ledger changes (e.g., user @mentions a tab). ai-chat-message validates anchors synchronously during render against this list. Trusted URLs become clickable, others will be unclickable/plaintext (fail-closed). This validates the actual rendered DOM rather than raw LLM text, avoiding parser disagreement between markdown-to-HTML and regex-based extraction. Differential Revision: https://phabricator.services.mozilla.com/D280039
588 lines
17 KiB
JavaScript
588 lines
17 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/. */
|
|
|
|
/**
|
|
* Unit tests for SecurityUtils.sys.mjs
|
|
*
|
|
* Tests URL normalization, eTLD validation, and ledger management:
|
|
* - normalizeUrl() - URL validation and normalization
|
|
* - areSameSite() - eTLD+1 validation
|
|
* - TabLedger - per-tab URL storage with TTL
|
|
* - SessionLedger - multi-tab ledger management
|
|
*
|
|
* Focus: Critical paths and edge cases that affect security
|
|
*/
|
|
const { normalizeUrl, TabLedger, SessionLedger } = ChromeUtils.importESModule(
|
|
"chrome://global/content/ml/security/SecurityUtils.sys.mjs"
|
|
);
|
|
|
|
/**
|
|
* Test: valid HTTP URLs normalize successfully.
|
|
*
|
|
* Reason:
|
|
* HTTP URLs are valid input for the security layer. The normalizer
|
|
* must accept them and return a normalized form for consistent
|
|
* ledger comparison.
|
|
*/
|
|
add_task(async function test_normalizeUrl_valid_http() {
|
|
const result = normalizeUrl("http://example.com/page");
|
|
|
|
Assert.ok(result.success, "Should succeed for valid HTTP URL");
|
|
Assert.ok(result.url, "Should return normalized URL");
|
|
Assert.ok(result.url.startsWith("http://"), "Should preserve http scheme");
|
|
});
|
|
|
|
/**
|
|
* Test: valid HTTPS URLs normalize successfully.
|
|
*
|
|
* Reason:
|
|
* HTTPS URLs are the primary input for the security layer. The normalizer
|
|
* must accept them and preserve the scheme in the output.
|
|
*/
|
|
add_task(async function test_normalizeUrl_valid_https() {
|
|
const result = normalizeUrl("https://example.com/page");
|
|
|
|
Assert.ok(result.success, "Should succeed for valid HTTPS URL");
|
|
Assert.ok(result.url, "Should return normalized URL");
|
|
Assert.ok(result.url.startsWith("https://"), "Should preserve https scheme");
|
|
});
|
|
|
|
/**
|
|
* Test: URLs with query parameters normalize successfully.
|
|
*
|
|
* Reason:
|
|
* Query parameters are part of resource identity. The normalizer must
|
|
* preserve them so that URLs like `page?id=1` and `page?id=2` are
|
|
* treated as distinct resources.
|
|
*/
|
|
add_task(async function test_normalizeUrl_with_query_params() {
|
|
const result = normalizeUrl("https://example.com/page?foo=bar&baz=qux");
|
|
|
|
Assert.ok(result.success, "Should succeed for URL with query params");
|
|
Assert.ok(result.url.includes("?"), "Should preserve query parameters");
|
|
});
|
|
|
|
/**
|
|
* Test: empty string fails normalization.
|
|
*
|
|
* Reason:
|
|
* Empty strings are invalid URLs. The normalizer must reject them
|
|
* with an error rather than returning an empty or malformed result.
|
|
*/
|
|
add_task(async function test_normalizeUrl_empty_string() {
|
|
const result = normalizeUrl("");
|
|
|
|
Assert.ok(!result.success, "Should fail for empty string");
|
|
Assert.ok(result.error, "Should return error");
|
|
});
|
|
|
|
/**
|
|
* Test: whitespace-only string fails normalization.
|
|
*
|
|
* Reason:
|
|
* Whitespace-only strings are invalid URLs. The normalizer must
|
|
* reject them rather than treating whitespace as a valid resource.
|
|
*/
|
|
add_task(async function test_normalizeUrl_whitespace() {
|
|
const result = normalizeUrl(" ");
|
|
|
|
Assert.ok(!result.success, "Should fail for whitespace-only string");
|
|
Assert.ok(result.error, "Should return error");
|
|
});
|
|
|
|
/**
|
|
* Test: invalid URL format fails normalization.
|
|
*
|
|
* Reason:
|
|
* Malformed URLs cannot be validated against the ledger. The normalizer
|
|
* must reject them so the security layer can deny the request (fail-closed).
|
|
*/
|
|
add_task(async function test_normalizeUrl_invalid_format() {
|
|
const result = normalizeUrl("not-a-valid-url");
|
|
|
|
Assert.ok(!result.success, "Should fail for invalid URL format");
|
|
Assert.ok(result.error, "Should return error");
|
|
});
|
|
|
|
/**
|
|
* Test: non-http/https schemes fail normalization.
|
|
*
|
|
* Reason:
|
|
* Only http/https URLs are valid for web content fetching. Schemes like
|
|
* ftp://, file://, and javascript: must be rejected to prevent attacks
|
|
* using unexpected protocol handlers.
|
|
*/
|
|
add_task(async function test_normalizeUrl_non_http_scheme() {
|
|
const schemes = ["ftp://example.com", "file:///path", "javascript:alert(1)"];
|
|
|
|
for (const url of schemes) {
|
|
const result = normalizeUrl(url);
|
|
Assert.ok(!result.success, `Should fail for scheme: ${url}`);
|
|
Assert.ok(result.error, "Should return error");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test: null/undefined fail normalization gracefully.
|
|
*
|
|
* Reason:
|
|
* Defensive programming: the normalizer must handle null/undefined
|
|
* without throwing, returning a failure result instead.
|
|
*/
|
|
add_task(async function test_normalizeUrl_null_undefined() {
|
|
const resultNull = normalizeUrl(null);
|
|
const resultUndefined = normalizeUrl(undefined);
|
|
|
|
Assert.ok(!resultNull.success, "Should fail for null");
|
|
Assert.ok(!resultUndefined.success, "Should fail for undefined");
|
|
});
|
|
|
|
/**
|
|
* Test: fragments are removed during normalization.
|
|
*
|
|
* Reason:
|
|
* Fragments (#section) identify positions within a page, not different
|
|
* resources. Stripping them ensures `page` and `page#section` are treated
|
|
* as the same resource for security purposes.
|
|
*/
|
|
add_task(async function test_normalizeUrl_strips_fragments() {
|
|
const result = normalizeUrl("https://example.com/page#section");
|
|
|
|
Assert.ok(result.success, "Should succeed");
|
|
Assert.ok(!result.url.includes("#"), "Should strip fragment");
|
|
});
|
|
|
|
/**
|
|
* Test: tracking parameters are removed during normalization.
|
|
*
|
|
* Reason:
|
|
* Tracking parameters (utm_source, etc.) don't change the resource.
|
|
* Stripping them prevents false denials when the same page is accessed
|
|
* with different tracking parameters.
|
|
*/
|
|
add_task(async function test_normalizeUrl_strips_tracking() {
|
|
const result = normalizeUrl(
|
|
"https://example.com/page?utm_source=test&foo=bar"
|
|
);
|
|
|
|
Assert.ok(result.success, "Should succeed");
|
|
Assert.ok(!result.url.includes("utm_"), "Should strip utm parameters");
|
|
Assert.ok(
|
|
result.url.includes("foo=bar"),
|
|
"Should preserve non-tracking params"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: relative URLs work with baseUrl.
|
|
*
|
|
* Reason:
|
|
* Page content may contain relative URLs. The normalizer must resolve
|
|
* them against a base URL to produce absolute URLs for ledger comparison.
|
|
*/
|
|
add_task(async function test_normalizeUrl_relative_with_base() {
|
|
const result = normalizeUrl("/page", "https://example.com");
|
|
|
|
Assert.ok(result.success, "Should succeed with baseUrl");
|
|
Assert.ok(
|
|
result.url.includes("example.com/page"),
|
|
"Should resolve relative URL"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: TabLedger can be created.
|
|
*
|
|
* Reason:
|
|
* TabLedger is the per-tab URL storage. It must initialize correctly
|
|
* with a tab ID and start empty.
|
|
*/
|
|
add_task(async function test_TabLedger_creation() {
|
|
const ledger = new TabLedger("tab-123");
|
|
|
|
Assert.ok(ledger, "Should create ledger");
|
|
Assert.equal(ledger.tabId, "tab-123", "Should store tab ID");
|
|
Assert.equal(ledger.size(), 0, "Should start empty");
|
|
});
|
|
|
|
/**
|
|
* Test: seed() adds multiple URLs to ledger.
|
|
*
|
|
* Reason:
|
|
* When a page loads, multiple URLs (page URL, linked resources) are
|
|
* seeded at once. seed() must add all valid URLs to the ledger.
|
|
*/
|
|
add_task(async function test_TabLedger_seed() {
|
|
const ledger = new TabLedger("tab-123");
|
|
const urls = ["https://example.com", "https://example.com/page"];
|
|
|
|
ledger.seed(urls);
|
|
|
|
Assert.equal(
|
|
ledger.lookup("https://example.com"),
|
|
"https://example.com/",
|
|
"Should return normalized URL"
|
|
);
|
|
Assert.equal(
|
|
ledger.lookup("https://example.com/page"),
|
|
"https://example.com/page",
|
|
"Should return normalized URL"
|
|
);
|
|
Assert.equal(ledger.size(), 2, "Should have correct size");
|
|
});
|
|
|
|
/**
|
|
* Test: add() adds individual URLs.
|
|
*
|
|
* Reason:
|
|
* Single URLs may be added incrementally (e.g., dynamic content).
|
|
* add() must work for individual URL additions.
|
|
*/
|
|
add_task(async function test_TabLedger_add() {
|
|
const ledger = new TabLedger("tab-123");
|
|
|
|
ledger.add("https://example.com");
|
|
|
|
Assert.equal(
|
|
ledger.lookup("https://example.com"),
|
|
"https://example.com/",
|
|
"Should return normalized URL"
|
|
);
|
|
Assert.equal(ledger.size(), 1, "Should have size 1");
|
|
});
|
|
|
|
/**
|
|
* Test: has() returns false for URLs not in ledger.
|
|
*
|
|
* Reason:
|
|
* The core security check: has() must return false for unseen URLs
|
|
* so the policy can deny access to untrusted resources.
|
|
*/
|
|
add_task(async function test_TabLedger_has_missing() {
|
|
const ledger = new TabLedger("tab-123");
|
|
ledger.add("https://example.com");
|
|
|
|
Assert.equal(
|
|
ledger.lookup("https://evil.com"),
|
|
null,
|
|
"Should return null for missing URL"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: clear() empties the ledger.
|
|
*
|
|
* Reason:
|
|
* When a tab navigates to a new page, the old URLs are no longer
|
|
* valid. clear() must remove all URLs from the ledger.
|
|
*/
|
|
add_task(async function test_TabLedger_clear() {
|
|
const ledger = new TabLedger("tab-123");
|
|
ledger.seed(["https://example.com", "https://example.com/page"]);
|
|
|
|
ledger.clear();
|
|
|
|
Assert.equal(ledger.size(), 0, "Should be empty after clear");
|
|
Assert.equal(
|
|
ledger.lookup("https://example.com"),
|
|
null,
|
|
"Should return null after clear"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: ledger enforces size limit.
|
|
*
|
|
* Reason:
|
|
* Unbounded ledger growth could cause memory issues. The size limit
|
|
* prevents malicious pages from bloating the ledger with many URLs.
|
|
*/
|
|
add_task(async function test_TabLedger_size_limit() {
|
|
const maxUrls = 1000;
|
|
const ledger = new TabLedger("tab-123");
|
|
|
|
// Try to add more than max
|
|
for (let i = 0; i < maxUrls + 2; i++) {
|
|
ledger.add(`https://example.com/page${i}`);
|
|
}
|
|
|
|
Assert.lessOrEqual(ledger.size(), maxUrls, "Should not exceed max size");
|
|
});
|
|
|
|
/**
|
|
* Test: invalid URLs are rejected gracefully.
|
|
*
|
|
* Reason:
|
|
* Malformed URLs (empty strings, null, non-URLs) should be silently
|
|
* ignored rather than added to the ledger or causing exceptions.
|
|
*/
|
|
add_task(async function test_TabLedger_invalid_urls() {
|
|
const ledger = new TabLedger("tab-123");
|
|
|
|
ledger.add("not-a-url");
|
|
ledger.add("");
|
|
ledger.add(null);
|
|
|
|
Assert.equal(ledger.size(), 0, "Should not add invalid URLs");
|
|
});
|
|
|
|
/**
|
|
* Test: SessionLedger can be created.
|
|
*
|
|
* Reason:
|
|
* SessionLedger manages per-tab ledgers for a session. It must
|
|
* initialize with a session ID and start with no tabs.
|
|
*/
|
|
add_task(async function test_SessionLedger_creation() {
|
|
const session = new SessionLedger("session-123");
|
|
|
|
Assert.ok(session, "Should create session ledger");
|
|
Assert.equal(session.sessionId, "session-123", "Should store session ID");
|
|
Assert.equal(session.tabCount(), 0, "Should start with no tabs");
|
|
});
|
|
|
|
/**
|
|
* Test: forTab() creates and retrieves tab ledgers.
|
|
*
|
|
* Reason:
|
|
* forTab() is the primary interface for accessing tab ledgers. It must
|
|
* create a new ledger on first access and return the same instance
|
|
* on subsequent calls for the same tab.
|
|
*/
|
|
add_task(async function test_SessionLedger_forTab() {
|
|
const session = new SessionLedger("session-123");
|
|
|
|
const ledger1 = session.forTab("tab-1");
|
|
const ledger2 = session.forTab("tab-1"); // Same tab
|
|
|
|
Assert.ok(ledger1, "Should create ledger for tab-1");
|
|
Assert.equal(ledger1, ledger2, "Should return same ledger for same tab");
|
|
Assert.equal(session.tabCount(), 1, "Should have 1 tab");
|
|
});
|
|
|
|
/**
|
|
* Test: different tabs get different ledgers.
|
|
*
|
|
* Reason:
|
|
* Tab isolation: each tab must have its own ledger. URLs from one tab
|
|
* should not be automatically trusted in another tab.
|
|
*/
|
|
add_task(async function test_SessionLedger_multiple_tabs() {
|
|
const session = new SessionLedger("session-123");
|
|
|
|
const ledger1 = session.forTab("tab-1");
|
|
const ledger2 = session.forTab("tab-2");
|
|
|
|
Assert.notEqual(
|
|
ledger1,
|
|
ledger2,
|
|
"Different tabs should have different ledgers"
|
|
);
|
|
Assert.equal(session.tabCount(), 2, "Should have 2 tabs");
|
|
});
|
|
|
|
/**
|
|
* Test: merge() combines URLs from multiple tabs.
|
|
*
|
|
* Reason:
|
|
* The @mentions feature requires merging ledgers from multiple tabs.
|
|
* merge() must return a combined set of URLs from all specified tabs.
|
|
*/
|
|
add_task(async function test_SessionLedger_merge() {
|
|
const session = new SessionLedger("session-123");
|
|
|
|
const ledger1 = session.forTab("tab-1");
|
|
const ledger2 = session.forTab("tab-2");
|
|
|
|
ledger1.add("https://example.com/page1");
|
|
ledger2.add("https://example.com/page2");
|
|
|
|
const merged = session.merge(["tab-1", "tab-2"]);
|
|
|
|
Assert.equal(
|
|
merged.lookup("https://example.com/page1"),
|
|
"https://example.com/page1",
|
|
"Should return normalized URL from tab-1"
|
|
);
|
|
Assert.equal(
|
|
merged.lookup("https://example.com/page2"),
|
|
"https://example.com/page2",
|
|
"Should return normalized URL from tab-2"
|
|
);
|
|
Assert.equal(merged.size(), 2, "Should have 2 URLs");
|
|
});
|
|
|
|
/**
|
|
* Test: removeTab() removes a tab's ledger.
|
|
*
|
|
* Reason:
|
|
* When a tab is closed, its ledger should be removed to free memory.
|
|
* Accessing the same tab ID later should create a fresh empty ledger.
|
|
*/
|
|
add_task(async function test_SessionLedger_removeTab() {
|
|
const session = new SessionLedger("session-123");
|
|
|
|
session.forTab("tab-1").add("https://example.com");
|
|
session.forTab("tab-2").add("https://example.com");
|
|
|
|
session.removeTab("tab-1");
|
|
|
|
Assert.equal(session.tabCount(), 1, "Should have 1 tab after removal");
|
|
|
|
// Getting the tab again should create a new empty ledger
|
|
const newLedger = session.forTab("tab-1");
|
|
Assert.equal(
|
|
newLedger.size(),
|
|
0,
|
|
"New ledger for removed tab should be empty"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: clearAll() clears all tab ledgers.
|
|
*
|
|
* Reason:
|
|
* Session reset or cleanup may require removing all ledgers at once.
|
|
* clearAll() must remove all tabs and their associated ledgers.
|
|
*/
|
|
add_task(async function test_SessionLedger_clearAll() {
|
|
const session = new SessionLedger("session-123");
|
|
|
|
session.forTab("tab-1").add("https://example.com");
|
|
session.forTab("tab-2").add("https://example.com");
|
|
|
|
session.clearAll();
|
|
|
|
Assert.equal(session.tabCount(), 0, "Should have no tabs after clearAll");
|
|
});
|
|
|
|
/**
|
|
* Test: ledgers normalize URLs consistently.
|
|
*
|
|
* Reason:
|
|
* URLs must be normalized both when added and when checked. A URL
|
|
* added with a fragment should match a check without the fragment
|
|
* (and vice versa) after normalization.
|
|
*/
|
|
add_task(async function test_ledger_normalizes_urls() {
|
|
const ledger = new TabLedger("tab-123");
|
|
|
|
// Add URL with fragment
|
|
ledger.add("https://example.com/page#section");
|
|
|
|
// Check without fragment (should still match after normalization)
|
|
Assert.equal(
|
|
ledger.lookup("https://example.com/page"),
|
|
"https://example.com/page",
|
|
"Should return normalized URL without fragment"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: mergeAll returns empty view for empty ledger.
|
|
*
|
|
* Reason:
|
|
* When no tabs have been seeded, mergeAll should return an empty view
|
|
* with size() === 0.
|
|
*/
|
|
add_task(async function test_mergeAll_empty_ledger() {
|
|
const sessionLedger = new SessionLedger("session-1");
|
|
const result = sessionLedger.mergeAll();
|
|
|
|
Assert.equal(result.size(), 0, "Empty ledger should return empty view");
|
|
});
|
|
|
|
/**
|
|
* Test: mergeAll includes URLs from a single tab.
|
|
*
|
|
* Reason:
|
|
* mergeAll should include all URLs from all tabs. With one tab,
|
|
* it should return all URLs from that tab.
|
|
*/
|
|
add_task(async function test_mergeAll_single_tab() {
|
|
const sessionLedger = new SessionLedger("session-1");
|
|
sessionLedger.forTab("tab-1").add("https://mozilla.org");
|
|
sessionLedger.forTab("tab-1").add("https://example.com");
|
|
|
|
const result = sessionLedger.mergeAll();
|
|
|
|
Assert.equal(result.size(), 2, "Should contain both URLs");
|
|
// Note: lookup() normalizes URLs internally, so trailing slash is added
|
|
Assert.equal(
|
|
result.lookup("https://mozilla.org/"),
|
|
"https://mozilla.org/",
|
|
"Should have mozilla.org"
|
|
);
|
|
Assert.equal(
|
|
result.lookup("https://example.com/"),
|
|
"https://example.com/",
|
|
"Should have example.com"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: mergeAll deduplicates URLs across tabs.
|
|
*
|
|
* Reason:
|
|
* The same URL trusted in multiple tabs should only appear once
|
|
* in the merged view.
|
|
*/
|
|
add_task(async function test_mergeAll_multiple_tabs_deduped() {
|
|
const sessionLedger = new SessionLedger("session-1");
|
|
sessionLedger.forTab("tab-1").add("https://mozilla.org");
|
|
sessionLedger.forTab("tab-2").add("https://mozilla.org");
|
|
sessionLedger.forTab("tab-2").add("https://github.com");
|
|
|
|
const result = sessionLedger.mergeAll();
|
|
|
|
Assert.equal(result.size(), 2, "Duplicate URLs should be deduped");
|
|
// Note: lookup() normalizes URLs internally, so trailing slash is added
|
|
Assert.equal(
|
|
result.lookup("https://mozilla.org/"),
|
|
"https://mozilla.org/",
|
|
"Should have mozilla.org"
|
|
);
|
|
Assert.equal(
|
|
result.lookup("https://github.com/"),
|
|
"https://github.com/",
|
|
"Should have github.com"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test: mergeAll returns same API as merge().
|
|
*
|
|
* Reason:
|
|
* mergeAll should return the same type of view as merge() for
|
|
* API consistency - an object with has() and size() methods.
|
|
*/
|
|
add_task(async function test_mergeAll_returns_same_api_as_merge() {
|
|
const sessionLedger = new SessionLedger("session-1");
|
|
sessionLedger.forTab("tab-1").add("https://mozilla.org");
|
|
|
|
const mergeAllResult = sessionLedger.mergeAll();
|
|
const mergeResult = sessionLedger.merge(["tab-1"]);
|
|
|
|
Assert.equal(
|
|
typeof mergeAllResult.lookup,
|
|
"function",
|
|
"mergeAll should return object with lookup()"
|
|
);
|
|
Assert.equal(
|
|
typeof mergeResult.lookup,
|
|
"function",
|
|
"merge should return object with lookup()"
|
|
);
|
|
Assert.equal(
|
|
typeof mergeAllResult.size,
|
|
"function",
|
|
"mergeAll should return object with size()"
|
|
);
|
|
Assert.equal(
|
|
typeof mergeResult.size,
|
|
"function",
|
|
"merge should return object with size()"
|
|
);
|
|
});
|