Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions TestPortScans.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,36 @@
<a id="plugin-button" href="https://addons.mozilla.org/firefox/addon/port-authority/">
<img src="https://blog.mozilla.org/addons/files/2020/04/get-the-addon-fx-apr-2020.svg" alt="Port Authority addon for Firefox" height="60px">
</a>

<h2>Selective Allow (cross-origin local navigation)</h2>
<p>
Port Authority blocks top-level navigations from other sites to local addresses, then opens a
<strong>separate decision window</strong> (plus a notification) with Block / Allow Once / Always Allow.
That window is <em>not</em> the toolbar popup.
</p>
<p>
Requirements to see it:
</p>
<ul>
<li>Load this temporary add-on build (or a release that includes Selective Allow).</li>
<li>Open <em>this HTML page</em> 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
<code>http://localhost/…</code> and then linking to <code>http://localhost:8080/</code> can
still work (different port), but linking from the same local host:port will not prompt.</li>
<li>Click one of the full-page links below (not the port-scan button).</li>
</ul>
<p>
<a href="http://localhost:8080/" target="_blank" rel="noopener">Open http://localhost:8080/ (new tab)</a>
</p>
<p>
Same-tab variant:
<a href="http://127.0.0.1:8080/">Open http://127.0.0.1:8080/</a>
</p>
<p>
Subresource scans from the button above stay silently blocked; only these full-page navigations
should show the Selective Allow prompt.
</p>

<h2 id="results-label" hidden>
Requests Made:
</h2>
Expand Down
217 changes: 206 additions & 11 deletions background.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
getItemFromLocal,
setItemInLocal,
modifyItemInLocal,
addBlockedPortToHost,
addBlockedTrackingHost,
increaseBadge,
Expand All @@ -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");
Expand All @@ -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(),
Expand All @@ -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") {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Loading
Loading