diff --git a/README.md b/README.md index 6d62a6a..a9533a1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,32 @@ This addon blocks websites from using javascript to port scan your computer/inte 4. Provides an optional allowlist to prevent portscans and tracking scripts from being blocked on trusted domains, IP addresses, and CIDR ranges 5. Gives a nice notification when one of the above scenarios are blocked 6. This addon doesn't store or log browsing history. Blocking decisions stay in memory for the current browser session (badge counters / blocked host lists per tab). To decide whether a third-party host is a LexisNexis/ThreatMetrix endpoint, the addon may issue a DNS CNAME lookup via Firefox's `dns` API for hosts that are not already on the known ThreatMetrix suffix list; results are cached in memory for the session only and are never written to disk or sent to any Port Authority server. +7. Prompts you when a page from the internet tries to navigate you to a local address, letting you allow or block on a per-origin basis + +## Selective Allow — Cross-Origin Local Navigation + +When a page on the internet contains a link to a local address (e.g. `http://localhost:8080`), Port Authority blocks the navigation by default. Rather than failing silently, it opens a **separate decision window** (not the toolbar popup) so you can decide what to do. A desktop notification is also shown pointing you at that prompt. + +This UI only appears at the moment such a navigation is blocked. You will not see Allow Once / Always Allow in the normal extension popup or settings until after you have saved an Always Allow entry. + +**The popup shows:** +- The external origin that contained the link (e.g. `github.com`) +- The local address being navigated to +- The request protocol + +**Your options:** + +| Button | Effect | +|--------|--------| +| **Block** | Request stays blocked. Nothing is saved. | +| **Allow Once** | Allowed for the rest of this browser session. Resets on browser restart. | +| **Always Allow** | The `origin → destination` pair is saved to extension settings. Future navigations from the same origin to the same local host are allowed immediately without prompting. | + +Saved "Always Allow" entries can be reviewed and removed from the extension settings page (gear icon in the popup). + +> **Note:** This prompt only appears for full page navigations (`main_frame`) to literal local/private addresses. Background requests — `fetch`, XHR, iframes — and DNS-rebinding style probes are still silently blocked and logged in the extension popup. Permissions are keyed by `URL.host` (host + port), not by path or scheme. + +Manual check: serve or host [`TestPortScans.html`](./TestPortScans.html) from a non-local origin and use the **Selective Allow** links on that page. ## Allowlist - **Domains** (e.g. `discord.com`) match the page origin only — including an optional non-default port when present @@ -40,10 +66,10 @@ This addon blocks websites from using javascript to port scan your computer/inte - Hostname compares strip trailing dots so FQDN forms like `h.online-metrix.net.` still match - Transient DNS failures fail open (request allowed) after an explicit catch; known ThreatMetrix suffixes do not depend on DNS and are still blocked - Allowlist matching lives in `global/allowlist.js`: domains use exact `URL.host` equality; portless IP entries and CIDR ranges use shared IP helpers from `global/privateAddress.js` - +- Selective Allow (issue #57) lives in `global/selectiveAllow.js`: only `main_frame` requests to literal local URLs are prompted; allow decisions are bound to a server-issued `promptId` and the stored pending record. Allow Once is session-only; Always Allow is storage-only so removing a pair in settings takes effect immediately. `file://` and `blob:` initiators use path/href keys (not a shared protocol token); `data:` initiators stay on the silent block path. Pending prompts are created atomically per origin→destination pair and clear when the decision window/tab is closed (or if the UI cannot be bound to an id). ## Automated Tests -Unit tests cover private-address classification, request-filter decisions (port scans, DNS rebinding, ThreatMetrix CNAMEs, allowlist including IP/CIDR), storage helpers, notifications/badges, allowlist parsing, DOM helpers, and manifest wiring. +Unit tests cover private-address classification, request-filter decisions (port scans, DNS rebinding, ThreatMetrix CNAMEs, allowlist including IP/CIDR), Selective Allow validation, storage helpers, notifications/badges, allowlist parsing, DOM helpers, and manifest wiring. ```bash npm test diff --git a/TestPortScans.html b/TestPortScans.html index 4bfca13..16d1caa 100644 --- a/TestPortScans.html +++ b/TestPortScans.html @@ -140,6 +140,36 @@ Port Authority addon for Firefox + +

Selective Allow (cross-origin local navigation)

+

+ Port Authority blocks top-level navigations from other sites to local addresses, then opens a + separate decision window (plus a notification) with Block / Allow Once / Always Allow. + That window is not the toolbar popup. +

+

+ Requirements to see it: +

+ +

+ Open http://localhost:8080/ (new tab) +

+

+ Same-tab variant: + Open http://127.0.0.1:8080/ +

+

+ Subresource scans from the button above stay silently blocked; only these full-page navigations + should show the Selective Allow prompt. +

+ diff --git a/background.js b/background.js index 3b7947e..8b0528b 100755 --- a/background.js +++ b/background.js @@ -1,6 +1,7 @@ import { getItemFromLocal, setItemInLocal, + modifyItemInLocal, addBlockedPortToHost, addBlockedTrackingHost, increaseBadge, @@ -13,10 +14,25 @@ import { } from "./global/BrowserStorageManager.js"; import { getBadgeForTab } from "./global/tabActivity.js"; import { evaluateRequest, createDnsResultCache } from "./global/requestFilter.js"; +import { + openSelectiveAllowPopup, + notifySelectiveAllow, +} from "./global/browserActions.js"; +import { isLocalRequestUrl } from "./global/privateAddress.js"; +import { + CROSS_ORIGIN_ALLOWLIST_KEY, + createSelectiveAllowState, + listHasCrossOriginEntry, + originAllowKey, + validatePendingAllow, +} from "./global/selectiveAllow.js"; /** Session-scoped DNS result cache — not persisted to disk. */ const dnsResultCache = createDnsResultCache(); +/** Session allow (Allow Once) + pending-prompt tracking. */ +const selectiveAllow = createSelectiveAllowState(); + async function startup() { // Defaults apply until settings are explicitly written. console.log("Startup called"); @@ -41,6 +57,91 @@ function blockPortScan(requestDetails, url) { return { cancel: true }; } +/** @param {number|undefined} tabId */ +function normalizedNavigationTabId(tabId) { + return Number.isInteger(tabId) && tabId >= 0 ? tabId : undefined; +} + +/** + * Open the decision UI outside the blocking webRequest stack. + * Pending is cleared unless a UI id is successfully bound for close tracking. + */ +function scheduleSelectiveAllowPrompt(pending) { + setTimeout(() => { + (async () => { + let opened; + try { + opened = await openSelectiveAllowPopup( + pending.origin, + pending.destination, + pending.originalUrl, + pending.promptId + ); + } catch (error) { + console.error("Failed to open selective allow prompt:", error); + selectiveAllow.clearPendingByPromptId(pending.promptId); + return; + } + + if (!opened || !selectiveAllow.bindPromptUi(pending.promptId, opened)) { + console.error("Selective allow UI opened without a trackable id"); + selectiveAllow.clearPendingByPromptId(pending.promptId); + return; + } + + try { + await notifySelectiveAllow(pending.origin, pending.destination); + } catch (error) { + console.warn("Selective allow notification failed:", error); + } + })(); + }, 0); +} + +/** + * Top-level navigations to literal local addresses get a Selective Allow prompt + * instead of a silent block. Subresource port scans still use blockPortScan(). + */ +async function handleSelectiveAllowNavigation(requestDetails, url) { + let originUrl; + try { + originUrl = new URL(requestDetails.originUrl); + } catch { + return blockPortScan(requestDetails, url); + } + + const origin = originAllowKey(originUrl); + const destination = url.host; + if (!origin || !destination) { + // Unkeyable opaque initiator (e.g. data:) — keep the silent block path. + return blockPortScan(requestDetails, url); + } + + // Allow Once (session) — Always Allow is storage-only and checked below. + if (selectiveAllow.isSessionAllowed(origin, destination)) { + return { cancel: false }; + } + + const crossOriginList = await getItemFromLocal(CROSS_ORIGIN_ALLOWLIST_KEY, []); + if (listHasCrossOriginEntry(crossOriginList, origin, destination)) { + return { cancel: false }; + } + + // Atomic create-or-update after the storage await so concurrent navigations + // for the same pair share one promptId / one decision UI. + const { pending, created } = selectiveAllow.ensurePendingPrompt({ + origin, + destination, + originalUrl: requestDetails.url, + navigationTabId: normalizedNavigationTabId(requestDetails.tabId), + }); + if (created) { + scheduleSelectiveAllowPrompt(pending); + } + + return { cancel: true }; +} + async function cancel(requestDetails) { const decision = await evaluateRequest(requestDetails, { getAllowedDomains: () => getAllowedDomainListCached(), @@ -49,8 +150,6 @@ async function cancel(requestDetails) { }); if (!decision.cancel) { - // Avoid per-request console I/O on the hot allow path — logging every - // first-party asset on SPAs (Figma, etc.) retains huge console buffers. if (decision.reason === "unparseable-origin") { console.error("Aborted filtering on domain due to unparseable originUrl: ", requestDetails.originUrl); } else if (decision.reason === "unparseable-url") { @@ -62,6 +161,13 @@ async function cancel(requestDetails) { } if (decision.reason === "portscan") { + if ( + requestDetails.type === "main_frame" && + decision.url && + isLocalRequestUrl(decision.url) + ) { + return handleSelectiveAllowNavigation(requestDetails, decision.url); + } return blockPortScan(requestDetails, decision.url); } @@ -107,10 +213,6 @@ async function stop() { } } -/** - * Reset per-tab activity when the tab navigates to a new URL. - * Borrowed and modified from https://gitlab.com/KevinRoebert/ClearUrls/-/blob/master/core_js/badgedHandler.js - */ function handleUpdated(tabId, changeInfo, tabInfo) { if (!changeInfo.url) return; @@ -122,17 +224,67 @@ function handleUpdated(tabId, changeInfo, tabInfo) { } } -/** - * Closed tabs must drop their activity maps — otherwise badges/blocked_* grow - * without bound across a long browsing session (issue #52 / #47). - */ function handleRemoved(tabId) { + selectiveAllow.clearPendingByUiTabId(tabId); clearTabActivityData(tabId); } +function handleWindowRemoved(windowId) { + selectiveAllow.clearPendingByWindowId(windowId); +} + +/** + * @param {number|undefined} tabId + * @param {string} url + */ +async function navigateAllowedUrl(tabId, url) { + if (Number.isInteger(tabId) && tabId >= 0) { + try { + await browser.tabs.update(tabId, { url, active: true }); + return; + } catch (error) { + console.warn("Selective allow could not update tab; opening a new one:", { + tabId, + error, + }); + } + } + await browser.tabs.create({ url }); +} + +/** Parse JSON-stringified storage.onChanged values. */ +function parseStorageChangeValue(raw) { + if (raw === undefined) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +/** + * Settings removals must drop any leftover session allows for the same pair + * (defense in depth if a pair was also Allow Once'd earlier in the session). + */ +function syncSessionAllowsWithCrossOriginChange(change) { + if (!change) return; + const oldList = parseStorageChangeValue(change.oldValue); + const newList = parseStorageChangeValue(change.newValue); + if (!Array.isArray(oldList)) return; + const next = Array.isArray(newList) ? newList : []; + for (const entry of oldList) { + if ( + entry?.origin && + entry?.destination && + !listHasCrossOriginEntry(next, entry.origin, entry.destination) + ) { + selectiveAllow.revokeSessionAllow(entry.origin, entry.destination); + } + } +} + const extensionOrigin = new URL(browser.runtime.getURL("")).origin; async function onMessage(message, sender) { - // Defense in depth: runtime.onMessage is extension-internal, but reject unexpected origins. if (sender.origin !== extensionOrigin) { console.warn("Message from unexpected origin:", sender.url); return; @@ -144,6 +296,45 @@ async function onMessage(message, sender) { break; case "getTabActivity": return getTabActivityForTab(message.tabId); + case "selectiveAllowDismiss": { + if (typeof message.promptId === "string") { + selectiveAllow.clearPendingByPromptId(message.promptId); + } + break; + } + case "allowOnce": + case "alwaysAllow": { + const pending = selectiveAllow.getPendingByPromptId(message.promptId); + if (!pending) { + console.warn("Rejected selective allow decision: unknown or expired prompt", message); + return; + } + + const validated = validatePendingAllow(pending); + if (!validated.ok) { + console.warn("Rejected selective allow decision:", validated.reason, message); + selectiveAllow.clearPendingByPromptId(pending.promptId); + return; + } + + const { origin, destination, originalUrl, tabId } = validated; + selectiveAllow.clearPendingByPromptId(pending.promptId); + + if (message.type === "allowOnce") { + selectiveAllow.allowInSession(origin, destination); + } else { + // Always Allow is storage-only so settings removal takes effect immediately. + await modifyItemInLocal(CROSS_ORIGIN_ALLOWLIST_KEY, [], (list) => { + if (listHasCrossOriginEntry(list, origin, destination)) { + return list; + } + return list.concat([{ origin, destination }]); + }); + } + + await navigateAllowedUrl(tabId, originalUrl); + break; + } default: console.warn("Port Authority: unknown message: ", message); break; @@ -154,8 +345,12 @@ browser.runtime.onMessage.addListener(onMessage); browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== "local") return; applyStorageChangesToCaches(changes); + if (Object.prototype.hasOwnProperty.call(changes, CROSS_ORIGIN_ALLOWLIST_KEY)) { + syncSessionAllowsWithCrossOriginChange(changes[CROSS_ORIGIN_ALLOWLIST_KEY]); + } }); startup(); browser.tabs.onUpdated.addListener(handleUpdated); browser.tabs.onRemoved.addListener(handleRemoved); +browser.windows.onRemoved.addListener(handleWindowRemoved); diff --git a/global/browserActions.js b/global/browserActions.js index 4990863..8574729 100644 --- a/global/browserActions.js +++ b/global/browserActions.js @@ -23,6 +23,20 @@ export async function notifyThreatMetrix(domain_name) { return notify("threatmetrix-notification", "Tracking Script Blocked", message); } +/** + * Heads-up that a Selective Allow decision window/tab was opened. + * @param {string} origin + * @param {string} destination + */ +export async function notifySelectiveAllow(origin, destination) { + const from = origin || "this page"; + return notify( + "selective-allow-notification", + "Local Navigation Blocked", + `Port Authority blocked ${from} from opening ${destination}. Choose Block, Allow Once, or Always Allow in the prompt.` + ); +} + /** * Updates the extension button's badge text on the relevant tab. * @param {string|number} text @@ -49,3 +63,57 @@ export async function getActiveTabId() { }); return tabs[0]?.id; } + +/** + * Build the Selective Allow decision page URL. + * @param {string} origin + * @param {string} destination + * @param {string} originalUrl Display-only; authority is promptId + pending record + * @param {string} promptId + * @returns {string} + */ +export function buildSelectiveAllowUrl(origin, destination, originalUrl, promptId) { + const params = new URLSearchParams({ origin, destination, originalUrl, promptId }); + return browser.runtime.getURL(`selectiveAllow/selectiveAllow.html?${params}`); +} + +/** + * Opens a Selective Allow decision UI. + * Prefers a popup window; falls back to a tab if the window cannot open. + * + * @param {string} origin + * @param {string} destination + * @param {string} originalUrl + * @param {string} promptId + * @returns {Promise<{ mode: "window"|"tab", id: number }|undefined>} + */ +export async function openSelectiveAllowPopup(origin, destination, originalUrl, promptId) { + const url = buildSelectiveAllowUrl(origin, destination, originalUrl, promptId); + + try { + const win = await browser.windows.create({ + url, + type: "popup", + width: 520, + height: 320, + allowScriptsToClose: true, + focused: true, + }); + if (Number.isInteger(win?.id) && win.id >= 0) { + return { mode: "window", id: win.id }; + } + } catch (windowError) { + console.warn("Selective allow popup window failed; opening a tab instead:", windowError); + } + + try { + const tab = await browser.tabs.create({ url, active: true }); + if (Number.isInteger(tab?.id) && tab.id >= 0) { + return { mode: "tab", id: tab.id }; + } + } catch (tabError) { + console.error("Selective allow fallback tab failed:", tabError); + } + + return undefined; +} diff --git a/global/selectiveAllow.js b/global/selectiveAllow.js new file mode 100644 index 0000000..164a2e9 --- /dev/null +++ b/global/selectiveAllow.js @@ -0,0 +1,268 @@ +/** + * Cross-origin local navigation (Selective Allow) helpers. + * Pure decision logic — no badge/notification side effects. + */ +import { isLocalRequestUrl } from "./privateAddress.js"; + +/** Storage key for persisted { origin, destination } pairs. */ +export const CROSS_ORIGIN_ALLOWLIST_KEY = "cross_origin_allowlist"; + +/** Reject pathological data:/blob keys from being persisted or prompted. */ +const MAX_ORIGIN_KEY_LENGTH = 512; + +/** + * Stable allowlist / pending key for the page that initiated the navigation. + * - http(s) / host-based: `URL.host` + * - file://: path so one HTML file cannot authorize all files + * - other hostless (blob:, about:, …): full href without hash + * Returns null when the initiator cannot be keyed safely (caller should silent-block). + * @param {URL} originUrl + * @returns {string|null} + */ +export function originAllowKey(originUrl) { + if (!(originUrl instanceof URL)) { + return null; + } + if (originUrl.protocol === "file:") { + return `file://${originUrl.pathname}`; + } + if (originUrl.host) { + return originUrl.host; + } + // data: URLs are content-addressed and can be huge — do not selective-allow. + if (originUrl.protocol === "data:") { + return null; + } + const key = originUrl.href.split("#")[0]; + if (!key || key.length > MAX_ORIGIN_KEY_LENGTH) { + return null; + } + return key; +} + +/** + * @param {string} origin Allow-key of the initiating page + * @param {string} destination URL.host of the local target + * @returns {string} + */ +export function makeAllowKey(origin, destination) { + return `${origin}|${destination}`; +} + +/** + * True when the entry list already has this origin → destination pair. + * @param {{ origin: string, destination: string }[]} list + * @param {string} origin + * @param {string} destination + */ +export function listHasCrossOriginEntry(list, origin, destination) { + return (list ?? []).some( + (entry) => entry?.origin === origin && entry?.destination === destination + ); +} + +/** + * @typedef {object} PendingPrompt + * @property {string} promptId + * @property {string} origin + * @property {string} destination + * @property {string} originalUrl + * @property {number|undefined} navigationTabId + * @property {number} [uiWindowId] + * @property {number} [uiTabId] + */ + +/** + * Validate a pending server-side prompt record before allowing navigation. + * @param {PendingPrompt|undefined|null} pending + * @returns {{ + * ok: true, + * origin: string, + * destination: string, + * originalUrl: string, + * tabId: number|undefined, + * parsedUrl: URL, + * } | { ok: false, reason: string }} + */ +export function validatePendingAllow(pending) { + if (!pending || typeof pending !== "object") { + return { ok: false, reason: "missing-pending" }; + } + + const { origin, destination, originalUrl, navigationTabId } = pending; + if ( + typeof origin !== "string" || + origin.length === 0 || + typeof destination !== "string" || + destination.length === 0 || + typeof originalUrl !== "string" || + originalUrl.length === 0 + ) { + return { ok: false, reason: "invalid-pending" }; + } + + let parsedUrl; + try { + parsedUrl = new URL(originalUrl); + } catch { + return { ok: false, reason: "unparseable-url" }; + } + + if (parsedUrl.host !== destination) { + return { ok: false, reason: "destination-mismatch" }; + } + + if (!isLocalRequestUrl(parsedUrl)) { + return { ok: false, reason: "not-local" }; + } + + const tabId = + Number.isInteger(navigationTabId) && navigationTabId >= 0 + ? navigationTabId + : undefined; + + return { + ok: true, + origin, + destination, + originalUrl, + tabId, + parsedUrl, + }; +} + +function newPromptId() { + return ( + globalThis.crypto?.randomUUID?.() ?? + `prompt-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); +} + +/** + * In-memory session allow + in-flight prompt tracking for one background page. + * One map keyed by promptId; close handlers scan (prompt count stays tiny). + */ +export function createSelectiveAllowState() { + /** @type {Set} Allow Once only — never mirrors Always Allow. */ + const sessionAllowSet = new Set(); + /** @type {Map} */ + const pendingById = new Map(); + + function findPending(predicate) { + for (const record of pendingById.values()) { + if (predicate(record)) return record; + } + return undefined; + } + + return { + isSessionAllowed(origin, destination) { + return sessionAllowSet.has(makeAllowKey(origin, destination)); + }, + + /** Allow Once — session memory only. */ + allowInSession(origin, destination) { + sessionAllowSet.add(makeAllowKey(origin, destination)); + }, + + /** Drop a session allow (e.g. settings removed a persisted pair). */ + revokeSessionAllow(origin, destination) { + sessionAllowSet.delete(makeAllowKey(origin, destination)); + }, + + hasPendingPrompt(origin, destination) { + return Boolean( + findPending( + (r) => r.origin === origin && r.destination === destination + ) + ); + }, + + /** + * Atomically create-or-update the pending prompt for a pair. + * Callers should only open UI when `created` is true. + * @param {{ + * origin: string, + * destination: string, + * originalUrl: string, + * navigationTabId?: number, + * }} details + * @returns {{ pending: PendingPrompt, created: boolean }} + */ + ensurePendingPrompt(details) { + const existing = findPending( + (r) => r.origin === details.origin && r.destination === details.destination + ); + if (existing) { + existing.originalUrl = details.originalUrl; + existing.navigationTabId = details.navigationTabId; + return { pending: existing, created: false }; + } + + /** @type {PendingPrompt} */ + const record = { + promptId: newPromptId(), + origin: details.origin, + destination: details.destination, + originalUrl: details.originalUrl, + navigationTabId: details.navigationTabId, + }; + pendingById.set(record.promptId, record); + return { pending: record, created: true }; + }, + + /** + * @param {string} promptId + * @returns {PendingPrompt|undefined} + */ + getPendingByPromptId(promptId) { + if (typeof promptId !== "string" || !promptId) return undefined; + return pendingById.get(promptId); + }, + + /** + * @param {string} promptId + * @param {{ mode: "window"|"tab", id: number }} opened + * @returns {boolean} false when the UI id cannot be bound + */ + bindPromptUi(promptId, opened) { + const record = pendingById.get(promptId); + if (!record || !opened || !Number.isInteger(opened.id) || opened.id < 0) { + return false; + } + if (opened.mode === "window") { + record.uiWindowId = opened.id; + delete record.uiTabId; + } else if (opened.mode === "tab") { + record.uiTabId = opened.id; + delete record.uiWindowId; + } else { + return false; + } + return true; + }, + + clearPendingByPromptId(promptId) { + pendingById.delete(promptId); + }, + + clearPendingByWindowId(windowId) { + const record = findPending((r) => r.uiWindowId === windowId); + if (record) pendingById.delete(record.promptId); + }, + + clearPendingByUiTabId(tabId) { + const record = findPending((r) => r.uiTabId === tabId); + if (record) pendingById.delete(record.promptId); + }, + + /** @returns {number} */ + get sessionSize() { + return sessionAllowSet.size; + }, + /** @returns {number} */ + get pendingSize() { + return pendingById.size; + }, + }; +} diff --git a/selectiveAllow/localRequestSelectiveAllow.js b/selectiveAllow/localRequestSelectiveAllow.js new file mode 100644 index 0000000..5e29780 --- /dev/null +++ b/selectiveAllow/localRequestSelectiveAllow.js @@ -0,0 +1,39 @@ +// Handles Selective Allow decisions for cross-origin local request blocking. + +const params = new URLSearchParams(location.search); +const origin = params.get("origin") ?? "unknown"; +const destination = params.get("destination") ?? "unknown"; +const originalUrl = params.get("originalUrl") ?? ""; +const promptId = params.get("promptId") ?? ""; + +const protocol = (() => { + try { + return new URL(originalUrl).protocol.replace(":", ""); + } catch { + return "unknown"; + } +})(); + +document.getElementById("detail-origin").textContent = origin; +document.getElementById("detail-destination").textContent = destination; +document.getElementById("detail-protocol").textContent = protocol; + +async function sendDecision(type) { + try { + await browser.runtime.sendMessage({ type, promptId }); + } finally { + window.close(); + } +} + +document.getElementById("btn-block").addEventListener("click", () => { + sendDecision("selectiveAllowDismiss"); +}); + +document.getElementById("btn-allow-once").addEventListener("click", () => { + sendDecision("allowOnce"); +}); + +document.getElementById("btn-always-allow").addEventListener("click", () => { + sendDecision("alwaysAllow"); +}); diff --git a/selectiveAllow/selectiveAllow.css b/selectiveAllow/selectiveAllow.css new file mode 100644 index 0000000..a500763 --- /dev/null +++ b/selectiveAllow/selectiveAllow.css @@ -0,0 +1,106 @@ +/* selectiveAllow popup — supplements common.css */ + +body { + padding: 0; + overflow: hidden; + min-width: 320px; +} + +header { + gap: 10px; + padding: 10px 14px; + flex-shrink: 0; +} + +header h1 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +main { + padding: 14px 16px 16px; + gap: 12px; + flex: 1; +} + +#prompt-text { + margin: 0; + font-size: 0.95rem; +} + +/* Two-column definition list */ +#request-details { + margin: 0; + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 16px; + row-gap: 4px; + font-size: 0.9rem; +} + +#request-details dt { + font-weight: 600; + color: var(--foreground); + opacity: 0.7; +} + +#request-details dd { + margin: 0; + word-break: break-all; + user-select: text; +} + +/* Action buttons row */ +#action-row { + gap: 8px; + justify-content: flex-end; + margin-top: 4px; + flex-wrap: wrap; +} + +#action-row button { + padding: 6px 14px; + border: 1px solid transparent; + border-radius: 4px; + font-size: 0.9rem; + cursor: pointer; + font-family: inherit; + transition: filter 0.1s; +} + +#action-row button:hover { + filter: brightness(1.1); +} + +/* Block — neutral/muted */ +#btn-block { + background-color: color-mix(in oklab, var(--foreground) 15%, var(--background)); + color: var(--foreground); + border-color: color-mix(in oklab, var(--foreground) 30%, var(--background)); +} + +/* Allow Once — blue */ +#btn-allow-once { + background-color: #1a73e8; + color: #ffffff; +} + +/* Always Allow — green */ +#btn-always-allow { + background-color: #1e8c45; + color: #ffffff; +} + +@media (prefers-color-scheme: dark) { + #btn-allow-once { + background-color: #4a9eff; + color: #111; + } + + #btn-always-allow { + background-color: #2db55d; + color: #111; + } +} diff --git a/selectiveAllow/selectiveAllow.html b/selectiveAllow/selectiveAllow.html new file mode 100644 index 0000000..38c78df --- /dev/null +++ b/selectiveAllow/selectiveAllow.html @@ -0,0 +1,33 @@ + + + + + + Port Authority — Access Request + + + + +
+ Port Authority Logo +

Port Authority

+
+
+

A page on the internet wants to navigate to a local address.

+
+
From
+
+
To
+
+
Protocol
+
+
+
+ + + +
+
+ + + diff --git a/settings/settings.html b/settings/settings.html index 03a626a..1277cda 100644 --- a/settings/settings.html +++ b/settings/settings.html @@ -49,6 +49,19 @@

Allowed Entries

+ + diff --git a/settings/settings.js b/settings/settings.js index 22dd105..61d87c0 100644 --- a/settings/settings.js +++ b/settings/settings.js @@ -110,3 +110,63 @@ function allowlist_add_listener(event) { allowlist_add_form.addEventListener("submit", allowlist_add_listener); load_allowlist(); + +// Populate `#cross_origin_section` — persisted Selective Allow pairs +let cross_origin_remove_controller; +const cross_origin_wrapper = document.getElementById("cross_origin_section"); +const cross_origin_contents = document.getElementById("cross_origin_contents"); + +function cross_origin_item(entry, abort_signal) { + /** Removes this entry from storage and refreshes the display */ + const remove_listener = () => { + modifyItemInLocal("cross_origin_allowlist", [], + (list) => list.filter( + (e) => !(e.origin === entry.origin && e.destination === entry.destination) + ) + ).then((list) => load_cross_origin_list(list)); + }; + + const label = `${entry.origin} → ${entry.destination}`; + const item = createElement("li", {}, [ + createElement("span", { class: "domain" }, label), + createElement("button", { + class: "unselectable", + "aria-label": `Remove permission for ${label}`, + }, "✕"), + ]); + + item.querySelector("button").addEventListener("click", remove_listener, { + signal: abort_signal, + }); + return item; +} + +async function load_cross_origin_list(list) { + // Drop prior remove-button listeners before rebuilding the list. + if (cross_origin_remove_controller) cross_origin_remove_controller.abort(); + cross_origin_remove_controller = new AbortController(); + + // If not provided, fetch the cross-origin allowlist from storage + list ??= await getItemFromLocal("cross_origin_allowlist", []); + + // Clear stale contents, if any + cross_origin_contents.replaceChildren(); + + // Early return, hiding wrapper if no data provided + if (!list || list.length === 0) { + cross_origin_wrapper.setAttribute("hidden", ""); + return; + } + + // Populate the list items + for (const entry of list) { + cross_origin_contents.appendChild( + cross_origin_item(entry, cross_origin_remove_controller.signal) + ); + } + + // Unhide the container wrapper at end + cross_origin_wrapper.removeAttribute("hidden"); +} + +load_cross_origin_list(); diff --git a/tests/browserActions.test.js b/tests/browserActions.test.js index dc0420c..cba5a5f 100644 --- a/tests/browserActions.test.js +++ b/tests/browserActions.test.js @@ -8,6 +8,8 @@ export async function run() { const created = []; const badgeUpdates = []; const tabQueries = []; + const windowsCreated = []; + const tabsCreated = []; globalThis.browser = { notifications: { @@ -27,6 +29,16 @@ export async function run() { tabQueries.push(q); return [{ id: 42, url: "https://active.example/" }]; }, + create: async (details) => { + tabsCreated.push(details); + return { id: 99, ...details }; + }, + }, + windows: { + create: async (details) => { + windowsCreated.push(details); + return { id: 1, ...details }; + }, }, }; @@ -63,6 +75,15 @@ export async function run() { assert(created[0].message.includes("hidden LexisNexis endpoint"), "fallback tmx message"); } + suite("notifySelectiveAllow"); + { + created.length = 0; + await actions.notifySelectiveAllow("github.com", "localhost:8080"); + assertEqual(created[0].id, "selective-allow-notification", "selective allow id"); + assert(created[0].message.includes("github.com"), "origin in message"); + assert(created[0].message.includes("localhost:8080"), "destination in message"); + } + suite("updateBadges"); { badgeUpdates.length = 0; @@ -91,4 +112,58 @@ export async function run() { const id = await actions.getActiveTabId(); assertEqual(id, undefined, "no active tab returns undefined"); } + + suite("openSelectiveAllowPopup"); + { + windowsCreated.length = 0; + tabsCreated.length = 0; + const result = await actions.openSelectiveAllowPopup( + "github.com", + "localhost:8080", + "http://localhost:8080/app", + "prompt-abc" + ); + assertEqual(windowsCreated.length, 1, "popup window created"); + assertEqual(result?.mode, "window", "reports window mode"); + assertEqual(result?.id, 1, "returns window id"); + const url = new URL(windowsCreated[0].url); + assert(url.pathname.endsWith("selectiveAllow/selectiveAllow.html"), "popup path"); + assertEqual(url.searchParams.get("promptId"), "prompt-abc", "promptId query"); + assertEqual(url.searchParams.get("tabId"), null, "tabId not in query"); + assertEqual(url.searchParams.get("origin"), "github.com", "origin display query"); + } + { + windowsCreated.length = 0; + tabsCreated.length = 0; + globalThis.browser.windows.create = async () => { + throw new Error("window blocked"); + }; + const result = await actions.openSelectiveAllowPopup( + "github.com", + "localhost:8080", + "http://localhost:8080/", + "prompt-y" + ); + assertEqual(result?.mode, "tab", "falls back to tab mode"); + assertEqual(result?.id, 99, "returns tab id"); + assertEqual(tabsCreated.length, 1, "fallback tab created"); + assert(tabsCreated[0].url.includes("promptId=prompt-y"), "fallback includes promptId"); + } + { + windowsCreated.length = 0; + tabsCreated.length = 0; + globalThis.browser.windows.create = async () => ({ id: undefined }); + globalThis.browser.tabs.create = async (details) => { + tabsCreated.push(details); + return { id: 77, ...details }; + }; + const result = await actions.openSelectiveAllowPopup( + "github.com", + "localhost:8080", + "http://localhost:8080/", + "prompt-z" + ); + assertEqual(result?.mode, "tab", "falls back when window id missing"); + assertEqual(result?.id, 77, "uses fallback tab id"); + } } diff --git a/tests/manifest.test.js b/tests/manifest.test.js index 228b02b..4dd7c21 100644 --- a/tests/manifest.test.js +++ b/tests/manifest.test.js @@ -50,6 +50,16 @@ export async function run() { assert(background.includes("onRemoved"), "cleans up tab activity on tab close"); assert(background.includes("resetSessionTabActivity"), "resets stale session activity on startup"); assert(background.includes("getAllowedDomainListCached"), "uses cached allowlist on hot path"); + assert(background.includes("handleSelectiveAllowNavigation"), "selective allow for main_frame locals"); + assert(background.includes("validatePendingAllow"), "validates allow Once/Always against pending"); + assert(background.includes("allowOnce"), "handles allowOnce messages"); + assert(background.includes("alwaysAllow"), "handles alwaysAllow messages"); + assert(background.includes("clearPendingByWindowId"), "clears pending when prompt window closes"); + assert(background.includes("windows.onRemoved"), "listens for prompt window close"); + assert(background.includes("originAllowKey"), "uses stable origin keys including file paths"); + assert(background.includes("ensurePendingPrompt"), "atomic pending create-or-update"); + assert(background.includes("revokeSessionAllow"), "can revoke session allows"); + assert(background.includes("syncSessionAllowsWithCrossOriginChange"), "settings removal syncs session"); suite("requestFilter.js ThreatMetrix remediation surface"); const requestFilter = readText("global/requestFilter.js"); @@ -72,17 +82,22 @@ export async function run() { const settings = readText("settings/settings.js"); assert(settings.includes('from "../global/allowlist.js"'), "imports allowlist module"); assert(settings.includes("normalizeAllowlistEntry"), "uses normalizeAllowlistEntry"); + assert(settings.includes("cross_origin_allowlist"), "manages selective allow permissions"); + assert(settings.includes("load_cross_origin_list"), "renders cross-origin allowlist"); suite("core modules exist"); for (const path of [ "global/privateAddress.js", "global/requestFilter.js", "global/allowlist.js", + "global/selectiveAllow.js", "global/BrowserStorageManager.js", "global/tabActivity.js", "global/browserActions.js", "global/constants.js", "global/domUtils.js", + "selectiveAllow/selectiveAllow.html", + "selectiveAllow/localRequestSelectiveAllow.js", "popup/PopupUI.js", "popup/switch.js", "TestPortScans.html", diff --git a/tests/run.js b/tests/run.js index c883ce0..1b1c7d1 100644 --- a/tests/run.js +++ b/tests/run.js @@ -15,6 +15,7 @@ const suites = [ "./allowlist.test.js", "./domUtils.test.js", "./browserActions.test.js", + "./selectiveAllow.test.js", "./tabActivity.test.js", "./BrowserStorageManager.test.js", "./manifest.test.js", diff --git a/tests/selectiveAllow.test.js b/tests/selectiveAllow.test.js new file mode 100644 index 0000000..e95d425 --- /dev/null +++ b/tests/selectiveAllow.test.js @@ -0,0 +1,167 @@ +/** + * Unit tests for Selective Allow helpers (issue #57). + */ +import { + makeAllowKey, + originAllowKey, + listHasCrossOriginEntry, + validatePendingAllow, + createSelectiveAllowState, +} from "../global/selectiveAllow.js"; +import { suite, assert, assertEqual } from "./harness.js"; + +export async function run() { + suite("makeAllowKey"); + { + assertEqual( + makeAllowKey("github.com", "localhost:8080"), + "github.com|localhost:8080", + "joins origin and destination" + ); + } + + suite("originAllowKey"); + { + assertEqual( + originAllowKey(new URL("https://github.com/foo")), + "github.com", + "https host key" + ); + assertEqual( + originAllowKey(new URL("https://github.com:8443/foo")), + "github.com:8443", + "host with non-default port" + ); + assertEqual( + originAllowKey(new URL("file:///tmp/a.html")), + "file:///tmp/a.html", + "file url uses pathname" + ); + assert( + originAllowKey(new URL("file:///tmp/a.html")) !== + originAllowKey(new URL("file:///tmp/b.html")), + "file keys are not collapsed to protocol only" + ); + assertEqual( + originAllowKey(new URL("blob:https://example.com/uuid-1")), + "blob:https://example.com/uuid-1", + "blob uses full href" + ); + assert( + originAllowKey(new URL("blob:https://example.com/uuid-1")) !== + originAllowKey(new URL("blob:https://example.com/uuid-2")), + "blob keys are not collapsed to protocol only" + ); + assertEqual( + originAllowKey(new URL("about:blank")), + "about:blank", + "about:blank keyed fully" + ); + assertEqual( + originAllowKey(new URL("data:text/html,hi")), + null, + "data: URLs are not selective-allowed" + ); + assertEqual(originAllowKey(null), null, "non-URL rejected"); + } + + suite("listHasCrossOriginEntry"); + { + const list = [ + { origin: "docs.example", destination: "127.0.0.1:8000" }, + { origin: "github.com", destination: "localhost:8080" }, + ]; + assert( + listHasCrossOriginEntry(list, "github.com", "localhost:8080"), + "finds matching pair" + ); + assert( + !listHasCrossOriginEntry(list, "github.com", "localhost:9090"), + "different destination is not a match" + ); + assert( + !listHasCrossOriginEntry([], "github.com", "localhost:8080"), + "empty list has no match" + ); + assert( + !listHasCrossOriginEntry(null, "github.com", "localhost:8080"), + "null list treated as empty" + ); + } + + suite("validatePendingAllow"); + { + const valid = validatePendingAllow({ + origin: "github.com", + destination: "localhost:8080", + originalUrl: "http://localhost:8080/console", + navigationTabId: 7, + }); + assert(valid.ok, "accepts pending local navigation"); + assertEqual(valid.origin, "github.com", "origin from pending"); + assertEqual(valid.tabId, 7, "navigationTabId used"); + } + { + const result = validatePendingAllow({ + origin: "evil.example", + destination: "localhost:8080", + originalUrl: "https://evil.example/phish", + }); + assert(!result.ok, "rejects remote originalUrl"); + assertEqual(result.reason, "destination-mismatch", "destination-mismatch reason"); + } + { + const result = validatePendingAllow(null); + assert(!result.ok, "rejects missing pending"); + assertEqual(result.reason, "missing-pending", "missing-pending reason"); + } + + suite("createSelectiveAllowState"); + { + const state = createSelectiveAllowState(); + const first = state.ensurePendingPrompt({ + origin: "a.com", + destination: "localhost:1", + originalUrl: "http://localhost:1/a", + navigationTabId: 5, + }); + assert(first.created, "first ensure creates"); + assert(state.hasPendingPrompt("a.com", "localhost:1"), "pending marked"); + assertEqual(state.pendingSize, 1, "one pending"); + + const second = state.ensurePendingPrompt({ + origin: "a.com", + destination: "localhost:1", + originalUrl: "http://localhost:1/b", + navigationTabId: 9, + }); + assert(!second.created, "second ensure updates"); + assertEqual(second.pending.promptId, first.pending.promptId, "same promptId kept"); + assertEqual(second.pending.originalUrl, "http://localhost:1/b", "url updated"); + assertEqual(second.pending.navigationTabId, 9, "tab updated"); + assertEqual(state.pendingSize, 1, "still one pending"); + + assert(state.bindPromptUi(first.pending.promptId, { mode: "window", id: 42 }), "bind window ok"); + assert(!state.bindPromptUi(first.pending.promptId, { mode: "window", id: undefined }), "reject missing id"); + state.clearPendingByWindowId(42); + assert(!state.hasPendingPrompt("a.com", "localhost:1"), "window close clears pending"); + } + { + const state = createSelectiveAllowState(); + const { pending } = state.ensurePendingPrompt({ + origin: "c.com", + destination: "localhost:3", + originalUrl: "http://localhost:3/", + }); + assert(state.bindPromptUi(pending.promptId, { mode: "tab", id: 9 }), "bind tab ok"); + state.clearPendingByUiTabId(9); + assert(!state.hasPendingPrompt("c.com", "localhost:3"), "ui tab close clears pending"); + } + { + const state = createSelectiveAllowState(); + state.allowInSession("a.com", "localhost:1"); + assert(state.isSessionAllowed("a.com", "localhost:1"), "session allowed"); + state.revokeSessionAllow("a.com", "localhost:1"); + assert(!state.isSessionAllowed("a.com", "localhost:1"), "session revoked"); + } +}