ffwebapps — Link Routing & Scope

This page documents how an ffwebapps window keeps the app’s own pages in-window while sending out-of-scope links to your real browser: the two-layer interception, the in-app allow-list, and the auth/SSO carve-outs that keep logins working.

Table of Contents

  1. The problem
  2. The in-app allow-list
  3. Default domains: scope, auth, Microsoft
  4. Two layers of interception
  5. Layer 1: the content router
  6. Layer 2: the request backstop
  7. Opening the default browser correctly
  8. Why window.open is left alone

1. The problem

A native app window should behave like a native app: the application’s own pages open inside it, and a link to anywhere else opens in your normal browser — not in a stray, chromeless app window with no address bar. Electron apps get this with shell.openExternal plus a setWindowOpenHandler that denies. ffwebapps has to reconstruct the same behaviour inside a real Firefox.

The difficulty is telling “in scope” from “out of scope” precisely, and doing it at the right moment. Route too aggressively and an SSO redirect or an in-app sub-page bounces out to the browser mid-flow; route too late and Firefox has already opened a blank popup window before the link is cancelled. ffwebapps solves this with a per-app allow-list consulted by two cooperating interceptors living in _autoconfig.cfg.

2. The in-app allow-list

Two prefs in the profile’s user.js drive everything (written by taskbartabs::write_profile_prefs):

Pref Meaning
ffwebapps.externalLinks.enabled Master switch; from SiteConfig::external_links (unwrap_or(true))
ffwebapps.allowedDomains Comma-separated wildcard host patterns kept in-window

A host is “in scope” if it matches any pattern in allowedDomains; anything else is “out of scope” and routed to the browser. Matching is wildcard-based: a pattern like *.cloud.microsoft becomes an anchored regex (* → .*), so teams.cloud.microsoft matches but evil.com does not. The same matcher is implemented twice — once in the chrome script and once, slightly simplified, in the content frame script — so both layers agree on what is in scope.

3. Default domains: scope, auth, Microsoft

When a user hasn’t set an explicit allowed_domains list, ffwebapps derives a sensible default from the site’s scope (default_allowed_domains, taskbartabs.rs:148-173):

  1. The scope host itself (e.g. teams.microsoft.com).
  2. Its parent-domain wildcard (e.g. *.microsoft.com and the bare microsoft.com).
  3. A shared auth/SSO bundleAUTH_DOMAINS.
  4. For Microsoft apps, a Microsoft 365 service bundleMICROSOFT_DOMAINS.

The auth carve-out is the subtle, important part. Logins routinely bounce through identity providers on different domains than the app, and if those bounced mid-flow to the external browser the sign-in would break. So a fixed set of identity domains is always kept in-window:


AUTH_DOMAINS:  login.microsoftonline.com, login.microsoft.com, login.live.com,
               login.windows.net, *.msftauth.net, *.msauth.net, *.b2clogin.com,
               accounts.google.com, *.okta.com, *.auth0.com,
               *.duosecurity.com, *.onelogin.com

A site is treated as “Microsoft” (and gets the broader *.office.com, *.sharepoint.com, *.teams.microsoft.com, … bundle) when its host ends with microsoft, contains .microsoft., or contains office/teams. This is why Teams and Outlook web apps keep their whole multi-domain experience in-window while a link to an external blog still opens in the browser.

4. Two layers of interception

ffwebapps intercepts out-of-scope navigation at two points, with different responsibilities. The content router is the primary, native-app-style handler; the request observer is a backstop for anything the content layer can’t catch.

two-layer out-of-scope routing a navigation begins click / target=_blank / window.open / redirect Layer 1 — content frame script (PRIMARY) click capture on <a> with target=_blank / _new / middle-click / ctrl / meta out of scope? preventDefault + stopPropagation → sendAsyncMessage("ffwebapps:open-external") Firefox never opens a window at all Layer 2 — http-on-modify-request (BACKSTOP) observes top-level GET main-document loads catches window.open / SSO / SafeLinks out of scope? channel.cancel(NS_BINDING_ABORTED) + dedupe rapid repeats (600ms) + close any blank popup it spawned ffwaOpenExternal(spec) — shared env -u <Firefox context vars> xdg-open <url> strips only this app's Firefox identity; keeps display vars → opens as a TAB in the user's running browser

5. Layer 1: the content router

The primary handler runs in content, the same place an Electron app would intercept (_autoconfig.cfg:217-297). A frame script, loaded into every content process, captures click events and walks up to the nearest <a>. If the link would open a new window or tab — target="_blank"/"_new", a middle-click, or a ctrl/meta-click — and the URL is out of scope, it calls preventDefault() + stopPropagation() and posts an async message to the chrome process, which opens it externally.

The win here is that the link is stopped before Firefox creates anything. There is no popup window to flash and close, no about:blank flicker — the out-of-scope link simply never produces an app window, exactly like a native app’s openExternal. This is why it is the primary path and the request observer is only a backstop.

6. Layer 2: the request backstop

Some out-of-scope navigations don’t originate from a clickable <a> the content script can see — window.open calls, SSO interstitials, Microsoft SafeLinks redirects. For those, a chrome-side observer on http-on-modify-request (_autoconfig.cfg:145-210) acts as a net. It only fires for top-level, main-document, GET loads whose host is out of scope, and then:

  1. Hands the URL to the shared external opener.
  2. channel.cancel(NS_BINDING_ABORTED) to stop the in-app load.
  3. De-duplicates rapid repeats of the same URL within 600 ms — a single click can produce more than one top-level request, and we don’t want two browser tabs.
  4. If the app spawned an extra window just for this link (i.e. not the main taskbar-tab window), moves it off-screen and closes it, so the user never sees a blank popup.

The observer deliberately acts on any window in the app’s runtime, not only the main taskbar-tab window. The runtime is exclusive to one web app, so the extra popup windows it spawns for a link are themselves in scope for routing — and requiring a taskbar-tab window was exactly the bug that let out-of-scope links leak into stray app windows.

7. Opening the default browser correctly

Handing a URL to the OS browser sounds trivial but isn’t, inside Firefox. Two naive approaches both fail:

The shared ffwaOpenExternal (_autoconfig.cfg:90-139) threads the needle. It runs xdg-open through /usr/bin/env -u …, stripping only the app’s unique Firefox contextMOZ_APP_REMOTINGNAME, XRE_PROFILE_PATH, the crash-reporter restart args, and so on — so the launched browser starts with its own identity and remotes the link in as a tab in the user’s already-running browser. It deliberately keeps the session/display variables (MOZ_ENABLE_WAYLAND, etc.): stripping those put the spawned browser on a different display backend, which again forced a new window. If xdg-open is unavailable it falls back to the external-protocol service.

This same opener is exported on _ffwaShared and reused by the tray’s “Open page in browser” command, so there is one correct implementation, not two.

8. Why window.open is left alone

A tempting “complete” solution would override window.open in the content script to catch programmatic navigations directly. ffwebapps deliberately does not (_autoconfig.cfg:279-282). Tampering with that global can make Microsoft’s authentication library (MSAL) abort sign-in — the auth flow checks and uses window.open itself. So window.open-based out-of-scope navigations are left to the Layer 2 request backstop, which routes them just as well without breaking auth.

This restraint is the through-line of the whole subsystem: keep logins working, route only what is genuinely out of scope, and never break a page’s own navigation to do it.