From e5aadc3e33627aa2585986efe0e3afaa6efd86e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 01:57:08 +0000 Subject: [PATCH 1/6] Add Selective Allow for cross-origin local navigations (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an external page links to a literal local address, prompt on main_frame navigations instead of silently blocking. Allow Once is session-only; Always Allow persists origin→destination pairs. Hardens the allow path by validating that originalUrl is local and matches the displayed destination, reuses the cancelled tab via tabs.update, and dedupes in-flight prompts per origin/destination. Closes #57. Supersedes #74. Co-authored-by: Hacks and Hops --- README.md | 26 ++- background.js | 114 ++++++++++++ global/browserActions.js | 37 ++++ global/selectiveAllow.js | 153 +++++++++++++++ selectiveAllow/localRequestSelectiveAllow.js | 44 +++++ selectiveAllow/selectiveAllow.css | 106 +++++++++++ selectiveAllow/selectiveAllow.html | 33 ++++ settings/settings.html | 13 ++ settings/settings.js | 60 ++++++ tests/browserActions.test.js | 37 ++++ tests/manifest.test.js | 9 + tests/run.js | 1 + tests/selectiveAllow.test.js | 186 +++++++++++++++++++ 13 files changed, 817 insertions(+), 2 deletions(-) create mode 100644 global/selectiveAllow.js create mode 100644 selectiveAllow/localRequestSelectiveAllow.js create mode 100644 selectiveAllow/selectiveAllow.css create mode 100644 selectiveAllow/selectiveAllow.html create mode 100644 tests/selectiveAllow.test.js diff --git a/README.md b/README.md index 6d62a6a..e64ae90 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,28 @@ 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 small popup so you can decide what to do. + +**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. ## Allowlist - **Domains** (e.g. `discord.com`) match the page origin only — including an optional non-default port when present @@ -40,10 +62,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 validate that `originalUrl` is local and that its host matches the displayed destination before navigating or persisting ## 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/background.js b/background.js index 3b7947e..4d307ac 100755 --- a/background.js +++ b/background.js @@ -1,6 +1,7 @@ import { getItemFromLocal, setItemInLocal, + modifyItemInLocal, addBlockedPortToHost, addBlockedTrackingHost, increaseBadge, @@ -13,10 +14,21 @@ import { } from "./global/BrowserStorageManager.js"; import { getBadgeForTab } from "./global/tabActivity.js"; import { evaluateRequest, createDnsResultCache } from "./global/requestFilter.js"; +import { openSelectiveAllowPopup } from "./global/browserActions.js"; +import { isLocalRequestUrl } from "./global/privateAddress.js"; +import { + CROSS_ORIGIN_ALLOWLIST_KEY, + createSelectiveAllowState, + listHasCrossOriginEntry, + validateAllowDecision, +} from "./global/selectiveAllow.js"; /** Session-scoped DNS result cache — not persisted to disk. */ const dnsResultCache = createDnsResultCache(); +/** Session allow + pending-prompt dedupe for cross-origin local navigations. */ +const selectiveAllow = createSelectiveAllowState(); + async function startup() { // Defaults apply until settings are explicitly written. console.log("Startup called"); @@ -41,6 +53,49 @@ function blockPortScan(requestDetails, url) { return { cancel: true }; } +/** + * Top-level navigations to literal local addresses get a Selective Allow prompt + * instead of a silent block. Subresource port scans still use blockPortScan(). + * Prompting is skipped while a popup for the same origin→destination is open. + */ +async function handleSelectiveAllowNavigation(requestDetails, url) { + let originHost; + try { + originHost = new URL(requestDetails.originUrl).host; + } catch { + // Without a parseable origin we cannot key a permission — fall back. + return blockPortScan(requestDetails, url); + } + + const destination = url.host; + if (selectiveAllow.isSessionAllowed(originHost, destination)) { + return { cancel: false }; + } + + const crossOriginList = await getItemFromLocal(CROSS_ORIGIN_ALLOWLIST_KEY, []); + if (listHasCrossOriginEntry(crossOriginList, originHost, destination)) { + return { cancel: false }; + } + + // Dedupe: do not open another popup for the same pair while one is pending. + // Still cancel the navigation so the local target is not reached. + if (!selectiveAllow.hasPendingPrompt(originHost, destination)) { + selectiveAllow.markPendingPrompt(originHost, destination); + openSelectiveAllowPopup( + originHost, + destination, + requestDetails.url, + requestDetails.tabId + ).catch((error) => { + console.error("Failed to open selective allow popup:", error); + selectiveAllow.clearPendingPrompt(originHost, destination); + }); + } + + // Cancel without badge noise — the user already sees an explicit prompt. + return { cancel: true }; +} + async function cancel(requestDetails) { const decision = await evaluateRequest(requestDetails, { getAllowedDomains: () => getAllowedDomainListCached(), @@ -62,6 +117,15 @@ async function cancel(requestDetails) { } if (decision.reason === "portscan") { + // Only literal local main_frame navigations are prompted. DNS-rebinding + // portscans (and all subresources) stay on the silent block path. + if ( + requestDetails.type === "main_frame" && + decision.url && + isLocalRequestUrl(decision.url) + ) { + return handleSelectiveAllowNavigation(requestDetails, decision.url); + } return blockPortScan(requestDetails, decision.url); } @@ -130,6 +194,26 @@ function handleRemoved(tabId) { clearTabActivityData(tabId); } +/** + * Navigate the cancelled tab when possible; otherwise open a new tab. + * @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 }); +} + 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. @@ -144,6 +228,36 @@ async function onMessage(message, sender) { break; case "getTabActivity": return getTabActivityForTab(message.tabId); + case "selectiveAllowDismiss": { + if (typeof message.origin === "string" && typeof message.destination === "string") { + selectiveAllow.clearPendingPrompt(message.origin, message.destination); + } + break; + } + case "allowOnce": + case "alwaysAllow": { + const validated = validateAllowDecision(message); + if (!validated.ok) { + console.warn("Rejected selective allow decision:", validated.reason, message); + return; + } + + const { origin, destination, originalUrl, tabId } = validated; + selectiveAllow.allowInSession(origin, destination); + selectiveAllow.clearPendingPrompt(origin, destination); + + if (message.type === "alwaysAllow") { + 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; diff --git a/global/browserActions.js b/global/browserActions.js index 4990863..b832924 100644 --- a/global/browserActions.js +++ b/global/browserActions.js @@ -1,3 +1,5 @@ +import { sanitizeSelectiveAllowPage } from "./selectiveAllow.js"; + async function notify(id, title, message) { return browser.notifications.create(id, { type: "basic", @@ -49,3 +51,38 @@ export async function getActiveTabId() { }); return tabs[0]?.id; } + +/** + * Opens a Selective Allow decision popup. + * @param {string} origin Host of the page that initiated the request + * @param {string} destination Host:port being navigated to + * @param {string} originalUrl Full URL the user was trying to reach + * @param {number} [tabId] Tab whose navigation was cancelled (for reuse on allow) + * @param {string} [page="selectiveAllow.html"] Page under selectiveAllow/ + * @returns {Promise} + */ +export async function openSelectiveAllowPopup( + origin, + destination, + originalUrl, + tabId, + page = "selectiveAllow.html" +) { + const safePage = sanitizeSelectiveAllowPage(page); + if (!safePage) { + console.error("Refusing to open selective allow popup with unsafe page:", page); + return; + } + + const params = new URLSearchParams({ origin, destination, originalUrl }); + if (Number.isInteger(tabId) && tabId >= 0) { + params.set("tabId", String(tabId)); + } + + return browser.windows.create({ + url: browser.runtime.getURL(`selectiveAllow/${safePage}?${params}`), + type: "popup", + width: 500, + height: 280, + }); +} diff --git a/global/selectiveAllow.js b/global/selectiveAllow.js new file mode 100644 index 0000000..854d155 --- /dev/null +++ b/global/selectiveAllow.js @@ -0,0 +1,153 @@ +/** + * 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"; + +/** Only allow files that live directly under selectiveAllow/. */ +const SELECTIVE_ALLOW_PAGE = /^[A-Za-z0-9._-]+\.html$/; + +/** + * @param {string} origin URL.host of the initiating page + * @param {string} destination URL.host of the local target + * @returns {string} + */ +export function makeAllowKey(origin, destination) { + return `${origin}|${destination}`; +} + +/** + * Restrict popup page names to a basename under selectiveAllow/. + * @param {string} [page] + * @returns {string|null} + */ +export function sanitizeSelectiveAllowPage(page = "selectiveAllow.html") { + if (typeof page !== "string" || !SELECTIVE_ALLOW_PAGE.test(page)) { + return null; + } + return page; +} + +/** + * 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 + ); +} + +/** + * Validate an Allow Once / Always Allow decision from the popup. + * Rejects non-local targets and mismatched host / originalUrl pairs. + * + * @param {{ + * origin?: unknown, + * destination?: unknown, + * originalUrl?: unknown, + * tabId?: unknown, + * }} message + * @returns {{ + * ok: true, + * origin: string, + * destination: string, + * originalUrl: string, + * tabId: number|undefined, + * parsedUrl: URL, + * } | { ok: false, reason: string }} + */ +export function validateAllowDecision(message) { + if (!message || typeof message !== "object") { + return { ok: false, reason: "invalid-message" }; + } + + const { origin, destination, originalUrl, tabId } = message; + if ( + typeof origin !== "string" || + origin.length === 0 || + typeof destination !== "string" || + destination.length === 0 || + typeof originalUrl !== "string" || + originalUrl.length === 0 + ) { + return { ok: false, reason: "invalid-fields" }; + } + + 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" }; + } + + let normalizedTabId; + if (tabId === undefined || tabId === null) { + normalizedTabId = undefined; + } else if (typeof tabId === "number" && Number.isInteger(tabId) && tabId >= 0) { + normalizedTabId = tabId; + } else if (typeof tabId === "string" && /^\d+$/.test(tabId)) { + normalizedTabId = Number(tabId); + } else { + return { ok: false, reason: "invalid-tabId" }; + } + + return { + ok: true, + origin, + destination, + originalUrl, + tabId: normalizedTabId, + parsedUrl, + }; +} + +/** + * In-memory session allow + in-flight prompt dedupe for one background page. + * Cleared automatically when the browser / extension process restarts. + */ +export function createSelectiveAllowState() { + /** @type {Set} */ + const sessionAllowSet = new Set(); + /** @type {Set} */ + const pendingPrompts = new Set(); + + return { + isSessionAllowed(origin, destination) { + return sessionAllowSet.has(makeAllowKey(origin, destination)); + }, + allowInSession(origin, destination) { + sessionAllowSet.add(makeAllowKey(origin, destination)); + }, + hasPendingPrompt(origin, destination) { + return pendingPrompts.has(makeAllowKey(origin, destination)); + }, + markPendingPrompt(origin, destination) { + pendingPrompts.add(makeAllowKey(origin, destination)); + }, + clearPendingPrompt(origin, destination) { + pendingPrompts.delete(makeAllowKey(origin, destination)); + }, + /** @returns {number} */ + get sessionSize() { + return sessionAllowSet.size; + }, + /** @returns {number} */ + get pendingSize() { + return pendingPrompts.size; + }, + }; +} diff --git a/selectiveAllow/localRequestSelectiveAllow.js b/selectiveAllow/localRequestSelectiveAllow.js new file mode 100644 index 0000000..33f914f --- /dev/null +++ b/selectiveAllow/localRequestSelectiveAllow.js @@ -0,0 +1,44 @@ +// 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 tabIdParam = params.get("tabId"); +const tabId = tabIdParam !== null && /^\d+$/.test(tabIdParam) ? Number(tabIdParam) : undefined; + +// Extract the protocol from the original URL for display +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; + +function sendDecision(type) { + const message = { type, origin, destination, originalUrl }; + if (tabId !== undefined) { + message.tabId = tabId; + } + return browser.runtime.sendMessage(message); +} + +document.getElementById("btn-block").addEventListener("click", () => { + browser.runtime.sendMessage({ type: "selectiveAllowDismiss", origin, destination }); + window.close(); +}); + +document.getElementById("btn-allow-once").addEventListener("click", () => { + sendDecision("allowOnce"); + window.close(); +}); + +document.getElementById("btn-always-allow").addEventListener("click", () => { + sendDecision("alwaysAllow"); + window.close(); +}); 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..dbc82d5 100644 --- a/tests/browserActions.test.js +++ b/tests/browserActions.test.js @@ -8,6 +8,7 @@ export async function run() { const created = []; const badgeUpdates = []; const tabQueries = []; + const windowsCreated = []; globalThis.browser = { notifications: { @@ -28,6 +29,12 @@ export async function run() { return [{ id: 42, url: "https://active.example/" }]; }, }, + windows: { + create: async (details) => { + windowsCreated.push(details); + return { id: 1, ...details }; + }, + }, }; const actions = await import("../global/browserActions.js"); @@ -91,4 +98,34 @@ export async function run() { const id = await actions.getActiveTabId(); assertEqual(id, undefined, "no active tab returns undefined"); } + + suite("openSelectiveAllowPopup"); + { + windowsCreated.length = 0; + await actions.openSelectiveAllowPopup( + "github.com", + "localhost:8080", + "http://localhost:8080/app", + 9 + ); + assertEqual(windowsCreated.length, 1, "popup window created"); + assertEqual(windowsCreated[0].type, "popup", "window type popup"); + const url = new URL(windowsCreated[0].url); + assert(url.pathname.endsWith("selectiveAllow/selectiveAllow.html"), "popup path"); + assertEqual(url.searchParams.get("origin"), "github.com", "origin query"); + assertEqual(url.searchParams.get("destination"), "localhost:8080", "destination query"); + assertEqual(url.searchParams.get("originalUrl"), "http://localhost:8080/app", "originalUrl query"); + assertEqual(url.searchParams.get("tabId"), "9", "tabId query"); + } + { + windowsCreated.length = 0; + await actions.openSelectiveAllowPopup( + "github.com", + "localhost:8080", + "http://localhost:8080/", + 1, + "../evil.html" + ); + assertEqual(windowsCreated.length, 0, "unsafe page does not open a window"); + } } diff --git a/tests/manifest.test.js b/tests/manifest.test.js index 228b02b..2abd07b 100644 --- a/tests/manifest.test.js +++ b/tests/manifest.test.js @@ -50,6 +50,10 @@ 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("validateAllowDecision"), "validates allow Once/Always messages"); + assert(background.includes("allowOnce"), "handles allowOnce messages"); + assert(background.includes("alwaysAllow"), "handles alwaysAllow messages"); suite("requestFilter.js ThreatMetrix remediation surface"); const requestFilter = readText("global/requestFilter.js"); @@ -72,17 +76,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..677a912 --- /dev/null +++ b/tests/selectiveAllow.test.js @@ -0,0 +1,186 @@ +/** + * Unit tests for Selective Allow helpers (issue #57). + */ +import { + makeAllowKey, + sanitizeSelectiveAllowPage, + listHasCrossOriginEntry, + validateAllowDecision, + 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("sanitizeSelectiveAllowPage"); + { + assertEqual( + sanitizeSelectiveAllowPage(), + "selectiveAllow.html", + "default page allowed" + ); + assertEqual( + sanitizeSelectiveAllowPage("localRequest.html"), + "localRequest.html", + "simple basename allowed" + ); + assertEqual( + sanitizeSelectiveAllowPage("../settings/settings.html"), + null, + "path traversal rejected" + ); + assertEqual( + sanitizeSelectiveAllowPage("foo/bar.html"), + null, + "nested path rejected" + ); + assertEqual( + sanitizeSelectiveAllowPage("not-html.js"), + null, + "non-html 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("validateAllowDecision"); + { + const valid = validateAllowDecision({ + origin: "github.com", + destination: "localhost:8080", + originalUrl: "http://localhost:8080/console", + tabId: 7, + }); + assert(valid.ok, "accepts local navigation with matching host"); + assertEqual(valid.origin, "github.com", "origin preserved"); + assertEqual(valid.destination, "localhost:8080", "destination preserved"); + assertEqual(valid.tabId, 7, "tabId preserved"); + assertEqual(valid.parsedUrl.host, "localhost:8080", "parsed host"); + } + { + const valid = validateAllowDecision({ + origin: "docs.example", + destination: "192.168.1.10", + originalUrl: "https://192.168.1.10/", + tabId: "12", + }); + assert(valid.ok, "accepts string tabId digits"); + assertEqual(valid.tabId, 12, "string tabId coerced"); + } + { + const result = validateAllowDecision({ + origin: "evil.example", + destination: "localhost:8080", + originalUrl: "https://evil.example/phish", + tabId: 1, + }); + assert(!result.ok, "rejects remote originalUrl that does not match destination"); + assertEqual(result.reason, "destination-mismatch", "destination-mismatch reason"); + } + { + const result = validateAllowDecision({ + origin: "evil.example", + destination: "example.com", + originalUrl: "https://example.com/", + tabId: 1, + }); + assert(!result.ok, "rejects non-local destination even when hosts match"); + assertEqual(result.reason, "not-local", "not-local reason"); + } + { + const result = validateAllowDecision({ + origin: "github.com", + destination: "localhost:8080", + originalUrl: "http://localhost:8080/", + // destination host would match if URL were local:8080 — craft mismatch via host + }); + // Force mismatch: destination claims localhost:9090 + const mismatched = validateAllowDecision({ + origin: "github.com", + destination: "localhost:9090", + originalUrl: "http://localhost:8080/", + }); + assert(!mismatched.ok, "rejects when destination host differs from originalUrl"); + assertEqual(mismatched.reason, "destination-mismatch", "host mismatch reason"); + assert(result.ok, "control case without tabId still valid"); + assertEqual(result.tabId, undefined, "missing tabId is ok"); + } + { + const result = validateAllowDecision({ + origin: "github.com", + destination: "localhost:8080", + originalUrl: "not a url", + }); + assert(!result.ok, "rejects unparseable originalUrl"); + assertEqual(result.reason, "unparseable-url", "unparseable reason"); + } + { + const result = validateAllowDecision({ + origin: "", + destination: "localhost:8080", + originalUrl: "http://localhost:8080/", + }); + assert(!result.ok, "rejects empty origin"); + assertEqual(result.reason, "invalid-fields", "invalid-fields reason"); + } + { + const result = validateAllowDecision({ + origin: "github.com", + destination: "localhost:8080", + originalUrl: "http://localhost:8080/", + tabId: -1, + }); + assert(!result.ok, "rejects negative tabId"); + assertEqual(result.reason, "invalid-tabId", "invalid-tabId reason"); + } + + suite("createSelectiveAllowState"); + { + const state = createSelectiveAllowState(); + assert(!state.isSessionAllowed("a.com", "localhost:1"), "starts empty"); + assert(!state.hasPendingPrompt("a.com", "localhost:1"), "no pending yet"); + + state.markPendingPrompt("a.com", "localhost:1"); + assert(state.hasPendingPrompt("a.com", "localhost:1"), "pending marked"); + assertEqual(state.pendingSize, 1, "one pending"); + + state.allowInSession("a.com", "localhost:1"); + state.clearPendingPrompt("a.com", "localhost:1"); + assert(state.isSessionAllowed("a.com", "localhost:1"), "session allowed"); + assert(!state.hasPendingPrompt("a.com", "localhost:1"), "pending cleared"); + assertEqual(state.sessionSize, 1, "one session allow"); + + // Different destination is independent + assert(!state.isSessionAllowed("a.com", "localhost:2"), "other destination not allowed"); + } +} From 4d894071c6ba5c6b52df7ac611dc97858e9bb915 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 01:59:36 +0000 Subject: [PATCH 2/6] Add Selective Allow test links to TestPortScans.html Document the manual check in the README so reviewers can exercise the main_frame prompt from a non-local origin. Co-authored-by: Hacks and Hops --- README.md | 2 ++ TestPortScans.html | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/README.md b/README.md index e64ae90..29faeb8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Saved "Always Allow" entries can be reviewed and removed from the extension sett > **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 - **IP addresses** (e.g. `127.0.0.1`) without a port match that address on any port for both the page origin and local request destinations diff --git a/TestPortScans.html b/TestPortScans.html index 4bfca13..4af8592 100644 --- a/TestPortScans.html +++ b/TestPortScans.html @@ -140,6 +140,26 @@ Port Authority addon for Firefox + +

Selective Allow (cross-origin local navigation)

+

+ Port Authority blocks top-level navigations from internet pages to local addresses by default, + then asks whether to allow the request. To exercise that prompt, open this page from a + non-local origin (for example serve it over LAN or host it on the public web), + then click: +

+

+ Open http://localhost:8080/ +

+

+ A same-tab variant (no target="_blank"): + Open http://127.0.0.1:8080/ +

+

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

+ From 5bb91fb7b24d4e57071f22263142ca086299f8e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 02:04:35 +0000 Subject: [PATCH 3/6] Make Selective Allow prompt more discoverable and reliable Defer opening the decision UI outside the blocking webRequest stack, fall back to a tab if the popup window fails, and show a notification so the prompt is not easy to miss. Clarify in README/TestPortScans that this UI is not the toolbar popup. Co-authored-by: Hacks and Hops --- README.md | 4 +- TestPortScans.html | 26 ++++++++---- background.js | 52 ++++++++++++++++++------ global/browserActions.js | 78 +++++++++++++++++++++++++++++------- tests/browserActions.test.js | 38 +++++++++++++++++- 5 files changed, 161 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 29faeb8..97ded7a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ This addon blocks websites from using javascript to port scan your computer/inte ## 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 small popup so you can decide what to do. +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`) diff --git a/TestPortScans.html b/TestPortScans.html index 4af8592..16d1caa 100644 --- a/TestPortScans.html +++ b/TestPortScans.html @@ -143,21 +143,31 @@

Selective Allow (cross-origin local navigation)

- Port Authority blocks top-level navigations from internet pages to local addresses by default, - then asks whether to allow the request. To exercise that prompt, open this page from a - non-local origin (for example serve it over LAN or host it on the public web), - then click: + 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.

- Open http://localhost:8080/ + Requirements to see it:

+
    +
  • Load this temporary add-on build (or a release that includes Selective Allow).
  • +
  • Open this HTML page from a host that is not the local target — e.g. serve it from + another machine, or open the raw file via a non-local site. Opening it as + http://localhost/… and then linking to http://localhost:8080/ can + still work (different port), but linking from the same local host:port will not prompt.
  • +
  • Click one of the full-page links below (not the port-scan button).
  • +

- A same-tab variant (no target="_blank"): + Open http://localhost:8080/ (new tab) +

+

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

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