Files
chrome-extenstions/foxyproxy/content/proxy.js
2026-01-20 21:53:59 +03:30

350 lines
11 KiB
JavaScript

// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/proxy.json
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/schemas/proxy.json
// https://bugzilla.mozilla.org/show_bug.cgi?id=1804693
// Setting single proxy for all fails
// https://bugs.chromium.org/p/chromium/issues/detail?id=1495756
// Issue 1495756: Support bypassList for PAC scripts in the chrome.proxy API
// https://chromium-review.googlesource.com/c/chromium/src/+/5227338
// Implement bypassList for PAC scripts in chrome.proxy API
// Chrome bypassList applies to 'fixed_servers', not 'pac_script' or URL
// Firefox passthrough applies to all set in proxy.settings.set, i.e. PAC URL
// manual bypass list:
// Chrome: pac_script data, not possible for URL
// Firefox proxy.onRequest
// https://searchfox.org/mozilla-central/source/toolkit/components/extensions/parent/ext-proxy.js#236
// throw new ExtensionError("proxy.settings is not supported on android.");
// https://bugzilla.mozilla.org/show_bug.cgi?id=1725981
// Support proxy.settings API on Android
import {App} from './app.js';
import {Authentication} from './authentication.js';
import {OnRequest} from './on-request.js';
import {Location} from './location.js';
import {Pattern} from './pattern.js';
import {Action} from './action.js';
import {Menus} from './menus.js';
export class Proxy {
static {
// from popup.js & options.js
browser.runtime.onMessage.addListener((...e) => this.onMessage(...e));
}
static onMessage(message) {
// noDataChange comes from popup.js & test.js
const {id, pref, host, proxy, dark, tab, noDataChange} = message;
switch (id) {
case 'setProxy':
Action.dark = dark;
this.set(pref, noDataChange);
break;
case 'includeHost':
case 'excludeHost':
// proxy object reference to pref is lost in chrome when sent from popup.js
const pxy = pref.data.find(i => i.active && host === `${i.hostname}:${i.port}`);
this.includeHost(pref, pxy, tab, id);
break;
case 'setTabProxy':
OnRequest.setTabProxy(tab, proxy);
break;
case 'getTabProxy':
// need to return a promise for 'getTabProxy' from popup.js
return Promise.resolve(OnRequest.tabProxy[tab.id]);
case 'getIP':
this.getIP();
break;
}
}
static async set(pref, noDataChange) {
// check if proxy.settings is controlled_by_this_extension
const conf = await this.getSettings();
// not controlled_by_this_extension
if (!conf) { return; }
// --- update authentication data
noDataChange || Authentication.init(pref.data);
// --- update menus
noDataChange || Menus.init(pref);
// --- check mode
switch (true) {
// no proxy, set to disable
case !pref.data[0]:
pref.mode = 'disable';
break;
// no include pattern, set proxy to the first entry
case pref.mode === 'pattern' && !pref.data.some(i => i.include[0] || i.exclude[0]):
const pxy = pref.data[0];
pref.mode = pxy.type === 'pac' ? pxy.pac : `${pxy.hostname}:${pxy.port}`;
break;
}
App.firefox ? Firefox.set(pref, conf) : Chrome.set(pref);
Action.set(pref);
}
static async getSettings() {
if (App.android) { return {}; }
const conf = await browser.proxy.settings.get({});
// https://developer.chrome.com/docs/extensions/mv3/manifest/icons/
// https://bugs.chromium.org/p/chromium/issues/detail?id=29683
// Issue 29683: Extension icons should support SVG (Dec 8, 2009)
// SVG is not supported by Chrome
// Firefox: If each one of imageData and path is one of undefined, null or empty object,
// the global icon will be reset to the manifest icon
// Chrome -> Error: Either the path or imageData property must be specified.
// check if proxy.settings is controlled_by_this_extension
const control = ['controlled_by_this_extension', 'controllable_by_this_extension'].includes(conf.levelOfControl);
const path = control ? `/image/icon.png` : `/image/icon-off.png`;
browser.action.setIcon({path});
!control && browser.action.setTitle({title: browser.i18n.getMessage('controlledByOtherExtensions')});
// return null if Chrome and no control, allow Firefox to continue regardless
return !App.firefox && !control ? null : conf;
}
// ---------- Include/Exclude Host ----------------------
static includeHost(pref, proxy, tab, inc) {
// not for storage.managed
if (pref.managed) { return; }
const url = this.getURL(tab.url);
if (!url) { return; }
const pattern = url.origin + '/';
const pat = {
active: true,
pattern,
title: url.hostname,
type: 'wildcard',
};
inc === 'includeHost' ? proxy.include.push(pat) : proxy.exclude.push(pat);
browser.storage.local.set({data: pref.data});
// update Proxy, noDataChange
pref.mode === 'pattern' && proxy.active && this.set(pref, true);
}
static getURL(str) {
const url = new URL(str);
// unacceptable URLs
if (!['http:', 'https:'].includes(url.protocol)) { return; }
return url;
}
// from popup.js
static getIP() {
fetch('https://getfoxyproxy.org/webservices/lookup.php')
.then(response => response.json())
.then(data => {
if (!Object.keys(data)) {
App.notify(browser.i18n.getMessage('error'));
return;
}
const [ip, {cc, city}] = Object.entries(data)[0];
const text = [ip, city, Location.get(cc)].filter(Boolean).join('\n');
App.notify(text);
})
.catch(error => App.notify(browser.i18n.getMessage('error') + '\n\n' + error.message));
}
}
class Firefox {
static async set(pref, conf) {
// update OnRequest
OnRequest.init(pref);
if (App.android) { return; }
// incognito access
if (!await browser.extension.isAllowedIncognitoAccess()) {
return;
}
// retain settings as Network setting is partially customisable
const value = conf.value;
switch (true) {
// https://github.com/foxyproxy/browser-extension/issues/47
// Unix domain socket SOCKS proxy support
// regard file:///run/user/1000/proxy.socks:9999 as normal proxy (not PAC)
// sanitizeNoProxiesPref() "network.proxy.no_proxies_on"
// https://searchfox.org/mozilla-central/source/browser/components/preferences/dialogs/connection.js#338
// --- Proxy Auto-Configuration (PAC) URL
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode):
value.proxyType = 'autoConfig';
value.autoConfigUrl = pref.mode;
// convert to standard comma-separated
value.passthrough = pref.passthrough.split(/[\s,;]+/).join(', ');
value.proxyDNS = pref.proxyDNS;
// no error if levelOfControl: "controlled_by_other_extensions"
browser.proxy.settings.set({value});
break;
// --- disable, direct, pattern, or single proxy
default:
browser.proxy.settings.clear({});
}
}
}
class Chrome {
static async set(pref) {
// https://developer.chrome.com/docs/extensions/reference/types/
// Scope and life cycle: regular | regular_only | incognito_persistent | incognito_session_only
const config = {value: {}, scope: 'regular'};
switch (true) {
case pref.mode === 'disable':
case pref.mode === 'direct':
config.value.mode = 'system';
break;
// --- Proxy Auto-Configuration (PAC) URL
case pref.mode.includes('://') && !/:\d+$/.test(pref.mode):
config.value.mode = 'pac_script';
config.value.pacScript = {mandatory: true};
config.value.pacScript.url = pref.mode;
break;
// --- single proxy
case pref.mode.includes(':'):
const pxy = this.findProxy(pref);
if (!pxy) { return; }
config.value.mode = 'fixed_servers';
config.value.rules = this.getSingleProxyRule(pref, pxy);
break;
// --- pattern
default:
config.value.mode = 'pac_script';
config.value.pacScript = {mandatory: true};
config.value.pacScript.data = this.getPacString(pref);
}
browser.proxy.settings.set(config);
// --- incognito
this.setIncognito(pref);
}
static findProxy(pref, host = pref.mode) {
return pref.data.find(i =>
i.active && i.type !== 'pac' && i.hostname && host === `${i.hostname}:${i.port}`);
}
static async setIncognito(pref) {
// incognito access
if (!await browser.extension.isAllowedIncognitoAccess()) {
return;
}
const pxy = pref.container?.incognito && this.findProxy(pref, pref.container?.incognito);
if (!pxy) {
// unset incognito
browser.proxy.settings.clear({scope: 'incognito_persistent'});
return;
}
const config = {value: {}, scope: 'incognito_persistent'};
config.value.mode = 'fixed_servers';
config.value.rules = this.getSingleProxyRule(pref, pxy);
browser.proxy.settings.set(config);
}
static getSingleProxyRule(pref, pxy) {
return {
singleProxy: {
scheme: pxy.type,
host: pxy.hostname,
// must be number, prepare for augmented port
port: parseInt(pxy.port),
},
bypassList: pref.passthrough ? pref.passthrough.split(/[\s,;]+/) : []
};
}
static getProxyString(proxy) {
let {type, hostname, port} = proxy;
switch (type) {
case 'direct':
return 'DIRECT';
// chrome PAC doesn't support HTTP
case 'http':
type = 'PROXY';
break;
default:
type = type.toUpperCase();
}
// prepare for augmented port
return `${type} ${hostname}:${parseInt(port)}`;
}
static getPacString(pref) {
// --- proxy by pattern
const [passthrough, net] = Pattern.getPassthrough(pref.passthrough);
// filter data
let data = pref.data.filter(i => i.active && i.type !== 'pac' && i.hostname);
data = data.filter(i => i.include[0] || i.exclude[0]).map(item => {
return {
str: this.getProxyString(item),
include: item.include.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type)),
exclude: item.exclude.filter(i => i.active).map(i => Pattern.get(i.pattern, i.type))
};
});
// add PAC rules from pacString
let pacData = pref.data.filter(i => i.active && i.type === 'pac' && i.pacString);
pacData = pacData.map((i, idx) => i.pacString.replace('FindProxyForURL', '$&' + idx) +
`\nconst find${idx} = FindProxyForURL${idx}(url, host);
if (find${idx} !== 'DIRECT') { return find${idx}; }`).join('\n\n');
pacData &&= `\n${pacData}\n`;
// https://developer.chrome.com/docs/extensions/reference/proxy/#type-PacScript
// https://github.com/w3c/webextensions/issues/339
// Chrome pacScript doesn't support bypassList
// https://issues.chromium.org/issues/40286640
// isInNet(host, "192.0.2.172", "255.255.255.255")
const pacString =
String.raw`function FindProxyForURL(url, host) {
const data = ${JSON.stringify(data)};
const passthrough = ${JSON.stringify(passthrough)};
const net = ${JSON.stringify(net)};
const match = array => array.some(i => new RegExp(i, 'i').test(url));
const inNet = () => net[0] && /^[\d.]+$/.test(host) && net.some(([ip, mask]) => isInNet(host, ip, mask));
if (match(passthrough) || inNet()) { return 'DIRECT'; }
for (const proxy of data) {
if (!match(proxy.exclude) && match(proxy.include)) { return proxy.str; }
}
${pacData}
return 'DIRECT';
}`;
return pacString;
}
}