DEV Community

RoamProxy
RoamProxy

Posted on

We shipped a proxy extension for Firefox and Edge. Both browsers quietly reuse the old proxy tunnel, and that almost broke it.

We run a pay-as-you-go proxy service. Until this month, using it in a browser meant the manual route: open the proxy settings, type a hostname and port, build a username by hand, sign in at the 407 prompt, and repeat the whole thing every time you wanted a different country.

So we built an extension. One popup: product line, country, fixed or rotating IP, a "new IP" button, and an on/off toggle. It is live for Firefox and Microsoft Edge. The popup took an afternoon. The part worth writing about is the two browser behaviours that made "change country" silently do nothing.

How targeting works on the gateway

Our gateway does not have a hostname per country. It has one host and port, and the username carries the targeting:

alice-country-de-session-k3j9   -> Germany, sticky exit for this session id
alice-country-jp                -> Japan, new exit on every request
Enter fullscreen mode Exit fullscreen mode

That is convenient for curl and scrapers. For a browser extension it means "switch from Japan to the US" is not "point at a different proxy". It is "keep the same proxy host and port, send a different username at the next 407 challenge". Both browsers had an opinion about that.

Trap 1: Chromium caches proxy credentials and pools tunnels per host:port

The Chromium side is the obvious design. chrome.proxy.settings.set installs a fixed proxy, and webRequest.onAuthRequired answers the 407 with the current username and password (that needs the webRequestAuthProvider permission under Manifest V3).

First test: set Japan, load an IP checker, see a Japanese exit. Change the popup to US. Reload. Still Japan. Change it back and forth a few times. Still Japan, until you restart the browser.

Two things are going on. Chromium keeps a pool of open connections to the proxy and reuses them; a CONNECT tunnel that was authenticated as the Japan username stays authenticated as the Japan username for as long as it lives. And Chromium caches proxy credentials per proxy host and port, so even a brand-new connection never asks onAuthRequired again; it replays the cached Japan credentials. Your handler with the new username is simply never called. Worse, the old username keeps working, so nothing looks broken from the extension's side.

There is no API to flush either cache from an extension. What does work is making every settings change look like a different proxy:

// background.js (Chromium)
async function applyProxy(cfg) {
  // A fresh hostname per change: new connection pool, new auth cache entry.
  const host = `x${Date.now()}.gw.roamproxy.com`;
  await chrome.storage.local.set({ proxyHost: host });
  await chrome.proxy.settings.set({
    value: { mode: "fixed_servers",
             rules: { singleProxy: { scheme: "http", host, port: 41080 },
                      bypassList: ["<local>"] } },
    scope: "regular"
  });
}
Enter fullscreen mode Exit fullscreen mode

On the DNS side that is one wildcard record, *.gw.roamproxy.com, pointing at the same gateway. The gateway does not care what hostname you connected through. The browser does, which is the whole point.

Trap 2: Firefox decides the proxy per request, and it still reuses the tunnel

Firefox has a nicer primitive: proxy.onRequest is called for every request and you return {type: "http", host, port} or {type: "direct"}. No global setting, no cache to worry about, and I wrote the first Firefox build on the assumption that the per-change hostname trick was unnecessary there.

It is necessary. Firefox keeps proxy tunnels alive too. Return the same host and port from onRequest with a different username in onAuthRequired, and it reuses the tunnel that was authenticated with the old one. The live test said Japan again after switching to the US.

Same fix, different shape:

// background.js (Firefox)
let cfg = null;
browser.storage.onChanged.addListener(async () => { cfg = await loadCfg(); });

browser.proxy.onRequest.addListener(
  () => cfg && cfg.enabled
        ? { type: "http", host: cfg.proxyHost, port: 41080 }
        : { type: "direct" },
  { urls: ["<all_urls>"] }
);

browser.webRequest.onAuthRequired.addListener(
  (details) => details.isProxy && cfg
        ? { authCredentials: { username: cfg.username, password: cfg.password } }
        : {},
  { urls: ["<all_urls>"] }, ["blocking"]
);
Enter fullscreen mode Exit fullscreen mode

cfg.proxyHost is that same x<timestamp>.gw.roamproxy.com value the popup writes on every save. The popup code is identical between the two builds; only the background script differs.

If you are building anything that changes proxy identity without changing proxy host, budget for this. Both browsers will make your first ten minutes of testing look like success.

The other small decisions

"New IP" picks the fastest of four. In fixed-IP mode, clicking new IP generates four candidate session ids, probes each through a tiny check endpoint in parallel, and keeps the one with the lowest latency. It costs four probe calls but the user gets a working, fast exit instead of a random one.

Sign-in has three doors. If the dashboard is open in the same browser, the popup calls an endpoint that returns a token for the current dashboard session. That endpoint deliberately sends no CORS headers, so a web page cannot read it; only the extension can, through its host permission. Otherwise, email and password, or paste an API token from the dashboard.

Off means off. Turning the toggle off clears the proxy setting (Chromium) or returns direct (Firefox). The extension stores the token and the chosen settings in extension storage and nothing else; it does not touch page content.

Fixed IP is the default. Rotating on every request is great for scrapers and terrible for a browser, where one page load is dozens of requests that should share an exit.

Where to get it

The install links and the sign-in walkthrough are on the extension page. The extension is free; traffic goes through the normal per-GB billing, and a new account gets 50MB to try it. If you hit a site where switching country does not take effect after a reload, I want to hear about it, because that is exactly the class of bug above.

Top comments (0)