This commit is contained in:
Fabian Freund
2025-02-16 23:21:44 +01:00
parent ddb0f14059
commit 6368e30479
81 changed files with 20308 additions and 88 deletions
@@ -0,0 +1,8 @@
.idea
*.iml
dist
web-ext-artifacts
source-artifacts
node_modules
tmpProfile
*.module.scss.d.ts
@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2019, Bekh-Ivanov Aleksey
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,2 @@
# Notes to Reviewer
@@ -0,0 +1,20 @@
[![Join the chat at https://gitter.im/firefox-container-proxy/community](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/firefox-container-proxy/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![CircleCI](https://circleci.com/gh/bekh6ex/firefox-container-proxy/tree/master.svg?style=svg)](https://circleci.com/gh/bekh6ex/firefox-container-proxy/tree/master)
[![Translation status](https://hosted.weblate.org/widgets/firefox-container-proxy/-/firefox-container-proxy/svg-badge.svg)](https://hosted.weblate.org/engage/firefox-container-proxy/)
[Extension page](https://addons.mozilla.org/en-US/firefox/addon/container-proxy/)
## Permissions
* **cookies**: needed to identify to which container request belongs
* **webRequest** and **webRequestBlocking**: to supply credentials for proxy authorization (but not for normal web authorization)
## Good to know
There is a known issue with DNS leak happening in non-default containers when uBlock is installed. The issue is not resolvable by this extension, but can be resolved adjusting in uBlock settings. See [comment](https://github.com/bekh6ex/firefox-container-proxy/issues/23#issuecomment-773249909)
## Translation
[![Translation status](https://hosted.weblate.org/widgets/firefox-container-proxy/-/firefox-container-proxy/multi-auto.svg)](https://hosted.weblate.org/engage/firefox-container-proxy/)
[Translate to your language](https://hosted.weblate.org/engage/firefox-container-proxy/)
@@ -0,0 +1,2 @@
// We need to tell TypeScript that when we write "import styles from './styles.scss' we mean to load a module (to look for a './styles.scss.d.ts').
declare module '*.module.scss';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
{
"name": "container-proxy",
"version": "0.1.0",
"description": "[Firefox extension] Assign a proxy to a Firefox container [Extension page](https://addons.mozilla.org/en-US/firefox/addon/container-proxy/)",
"scripts": {
"test": "pnpm run test:unit",
"bundle": "web-ext -s dist build --overwrite-dest",
"bundle:src": "V=$(cat src/manifest.json | jq -r .version) && zip -r -X source-artifacts/container-proxy-v${V}.zip src babel.config.js LICENSE package.json package-lock.json README.md tsconfig.json webpack.config.js",
"test:func": "pnpm run build && mocha test/functional/",
"test:unit": "ts-mocha --require ts-node/register test/unit/**/*.test.ts",
"codeStyle:check": "standardx && ts-standard",
"codeStyle:fix": "standardx --fix && ts-standard --fix",
"lint": "addons-linter dist",
"debug": "web-ext run -s dist --verbose --browser-console --keep-profile-changes -p ./tmpProfile --start-url about:debugging --start-url about:addons --pref=intl.locale.requested=en #--pref=intl.locale.matchOS=false",
"ci:test-unit": "pnpm run test:unit",
"ci:test-func": "HEADLESS=true pnpm run test:func",
"ci:style-check": "pnpm run codeStyle:check",
"ci:lint": "pnpm run build && pnpm run lint",
"build": "webpack",
"build:watch": "webpack --watch",
"clean": "rm -rf ./dist"
},
"repository": {
"type": "git",
"url": "git+https://github.com/bekh6ex/firefox-container-proxy.git"
},
"author": "Aleksei Bekh-Ivanov",
"license": "BSD-2-Clause",
"bugs": {
"url": "https://github.com/bekh6ex/firefox-container-proxy/issues"
},
"homepage": "https://addons.mozilla.org/en-US/firefox/addon/container-proxy/",
"devDependencies": {
"@types/chai": "^5.0.1",
"@types/firefox-webext-browser": "^120.0.4",
"@types/mocha": "^10.0.10",
"addons-linter": "^7.8.0",
"chai": "^5.1.2",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"css-modules-typescript-loader": "^4.0.1",
"esm": "^3.2.25",
"mocha": "^10.8.2",
"sass-loader": "^16.0.4",
"sinon": "^8.1.1",
"standardx": "^7.0.0",
"style-loader": "^4.0.0",
"ts-loader": "^9.2.6",
"ts-mocha": "^10.0.0",
"ts-standard": "^12.0.2",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"typescript": "^5.7.3",
"web-ext": "^8.4.0",
"webextensions-api-fake": "^1.3.0",
"webextensions-geckodriver": "^0.7.0",
"webpack": "^5.59.1",
"webpack-cli": "^6.0.1"
},
"standardx": {
"cache": false,
"parser": "@typescript-eslint/parser",
"env": {
"browser": true,
"mocha": true
},
"globals": [
"browser",
"expect",
"store"
],
"ignore": [
"dist",
"/src/lib/*",
"src/**/*.ts",
"test/**/*.ts"
]
},
"ts-standard": {
"ignore": [
"dist",
"src/**/*.js",
"test/**/*.js",
"src/options/import/FoxyProxyConverter.ts",
"test/unit/options/import/FoxyProxyConverter.test.ts"
],
"cache": false,
"env": {
"browser": true,
"mocha": true
},
"globals": [
"browser",
"expect",
"store"
]
},
"eslintConfig": {
"rules": {
"no-unused-expressions": "error"
},
"overrides": [
{
"files": [
"*.test.js"
],
"rules": {
"no-unused-expressions": "off"
}
}
]
}
}
@@ -0,0 +1,114 @@
import { Store } from '../store/Store'
import { HttpProxySettings, HttpsProxySettings, ProxySettings } from '../domain/ProxySettings'
import { ProxyInfo, Socks5ProxyInfo } from '../domain/ProxyInfo'
import { ProxyType } from '../domain/ProxyType'
import BlockingResponse = browser.webRequest.BlockingResponse
import _OnAuthRequiredDetails = browser.webRequest._OnAuthRequiredDetails
import _OnRequestDetails = browser.proxy._OnRequestDetails
const localhosts = new Set(['localhost', '127.0.0.1', '[::1]'])
const containerIdentifier = 'firefox-container-'
type DoNotProxy = never[]
export const doNotProxy: DoNotProxy = []
const emergencyBreak: Socks5ProxyInfo = {
type: ProxyType.Socks5,
host: 'emergency-break-proxy.localhost',
port: 1,
failoverTimeout: 1,
username: 'nonexistent user',
password: 'dummy password',
proxyDNS: true
}
export default class BackgroundMain {
store: Store
constructor({ store }: { store: Store }) {
this.store = store
}
initializeAuthListener(cookieStoreId: string, proxy: HttpProxySettings | HttpsProxySettings): void {
const listener: (details: _OnAuthRequiredDetails) => BlockingResponse = (details) => {
if (!details.isProxy) return {}
if (details.cookieStoreId !== cookieStoreId) return {}
// TODO: Fix in @types/firefox-webext-browser
// @ts-expect-error
const info = details.proxyInfo
if (info.host !== proxy.host || info.port !== proxy.port || info.type !== proxy.type) return {}
const result = { authCredentials: { username: proxy.username, password: proxy.password } }
browser.webRequest.onAuthRequired.removeListener(listener)
return result
}
browser.webRequest.onAuthRequired.addListener(
listener,
{ urls: ['<all_urls>'] },
['blocking']
)
}
// TODO: Fix in @types/firefox-webext-browser
async onRequest(requestDetails: Pick<_OnRequestDetails, 'cookieStoreId' | 'url' | 'tabId'>): Promise<DoNotProxy | ProxyInfo[]> {
const tab = (await browser.tabs.get(requestDetails.tabId))
if (tab.cookieStoreId?.startsWith(containerIdentifier) === true) {
try {
const cookieStoreId = tab.cookieStoreId.substring(containerIdentifier.length)
const proxies = await this.store.getProxiesForContainer(cookieStoreId)
if (proxies.length > 0) {
proxies.forEach(p => {
if (p.type === ProxyType.Http || p.type === ProxyType.Https) {
this.initializeAuthListener(cookieStoreId, p)
}
})
const result: ProxyInfo[] = proxies.filter((p: ProxySettings) => {
try {
const documentUrl = new URL(requestDetails.url)
const isLocalhost = localhosts.has(documentUrl.hostname)
if (isLocalhost && p.doNotProxyLocal) {
return false
}
} catch (e) {
console.error(e)
}
return true
}).map(p => p.asProxyInfo())
if (result.length === 0) {
return [emergencyBreak]
}
return result
}
return [emergencyBreak]
} catch (e: unknown) {
console.error(`Error in onRequest listener: ${e as string}`)
return [emergencyBreak]
}
}
return doNotProxy
}
run(browser: { proxy: any }): void {
const filter = { urls: ['<all_urls>'] }
browser.proxy.onRequest.addListener(this.onRequest.bind(this), filter)
browser.proxy.onError.addListener((e: Error) => {
console.error('Proxy error', e)
})
}
}
@@ -0,0 +1,44 @@
import { Socks5ProxySettings } from 'src/domain/ProxySettings';
import { Store } from '../store/Store'
import BackgroundMain from './BackgroundMain'
console.log('Background script started')
const store = new Store()
interface Message {
action: 'setProxyPort' | 'addContainerProxy' | 'removeContainerProxy';
args: any;
}
const port = browser.runtime.connectNative("containerProxy");
port.onMessage.addListener((raw: unknown): void => {
const message = raw as Message;
switch (message.action) {
case "setProxyPort":
store.putProxy(new Socks5ProxySettings({
id: 'tor',
type: 'socks',
host: '127.0.0.1',
port: message.args,
doNotProxyLocal: true,
title: 'Tor',
proxyDNS: true,
}))
console.log('put tor port ' + message.args)
break
case "addContainerProxy":
store.setContainerProxyRelation(message.args, "tor")
console.log('added container relation ' + message.args)
break
case "removeContainerProxy":
store.removeContainerProxyRelation(message.args, "tor")
break
}
});
const backgroundListener = new BackgroundMain({ store })
backgroundListener.run(browser)
@@ -0,0 +1,36 @@
import { ProxyType } from './ProxyType'
export type ProxyInfo = Socks4ProxyInfo | Socks5ProxyInfo | HttpProxyInfo | HttpsProxyInfo
interface ProxyInfoBase<TY extends ProxyType> {
type: TY
host: string
port: number
failoverTimeout: number
}
export interface Socks5ProxyInfo extends ProxyInfoBase<ProxyType.Socks5> {
type: ProxyType.Socks5
username: string
password: string
proxyDNS: boolean
proxyAuthorizationHeader?: undefined // MUST not present, otherwise failure that cannot be prevented/recovered from
}
export interface Socks4ProxyInfo extends ProxyInfoBase<ProxyType.Socks4> {
type: ProxyType.Socks4
proxyDNS: boolean
proxyAuthorizationHeader?: undefined // MUST not present, otherwise failure that cannot be prevented/recovered from
}
export interface HttpsProxyInfo extends ProxyInfoBase<ProxyType.Https> {
type: ProxyType.Https
proxyDNS?: undefined
proxyAuthorizationHeader: string
}
export interface HttpProxyInfo extends ProxyInfoBase<ProxyType.Http> {
type: ProxyType.Http
proxyDNS?: undefined
proxyAuthorizationHeader?: undefined // MUST not present, otherwise failure that cannot be prevented/recovered from
}
@@ -0,0 +1,170 @@
import { ProxyType } from './ProxyType'
import { ProxyDao } from '../store/Store'
import { HttpProxyInfo, HttpsProxyInfo, ProxyInfo, Socks4ProxyInfo, Socks5ProxyInfo } from './ProxyInfo'
/* eslint-disable @typescript-eslint/no-namespace,@typescript-eslint/no-redeclare */
export type ProxySettings = Socks4ProxySettings | Socks5ProxySettings | HttpProxySettings | HttpsProxySettings
const failoverTimeout = 5
export namespace ProxySettings {
export function tryFromDao(dao: ProxyDao): ProxySettings | undefined {
switch (dao.type) {
case 'socks':
return new Socks5ProxySettings(dao)
case 'socks4':
return new Socks4ProxySettings(dao)
case 'http':
return new HttpProxySettings(dao)
case 'https':
return new HttpsProxySettings(dao)
default:
return undefined
}
}
}
abstract class ProxySettingsBase<PI extends ProxyInfo> {
readonly id: string
readonly title: string
readonly host: string
readonly port: number
readonly doNotProxyLocal: boolean
protected constructor(dao: ProxyDao) {
this.id = dao.id
this.title = dao.title
this.host = dao.host
this.port = dao.port
this.doNotProxyLocal = dao.doNotProxyLocal
}
abstract get type(): PI['type']
abstract asProxyInfo(): PI
abstract asDao(): ProxyDao
get url(): string {
return `${this.type}://${this.host}:${this.port}`
}
protected baseDao(): ProxyDao {
return {
id: this.id,
title: this.title,
type: this.type,
host: this.host,
port: this.port,
doNotProxyLocal: this.doNotProxyLocal
}
}
}
export class Socks5ProxySettings extends ProxySettingsBase<Socks5ProxyInfo> {
readonly username?: string
readonly password?: string
readonly proxyDNS: boolean
constructor(dao: ProxyDao) {
super(dao)
this.username = dao.username
this.password = dao.password
this.proxyDNS = dao.proxyDNS ?? true
}
get type(): ProxyType.Socks5 {
return ProxyType.Socks5
}
asProxyInfo(): Socks5ProxyInfo {
return {
type: this.type,
host: this.host,
port: this.port,
username: this.username ?? '',
password: this.password ?? '',
proxyDNS: this.proxyDNS,
failoverTimeout
}
}
asDao(): ProxyDao {
return { ...super.baseDao(), username: this.username, password: this.password, proxyDNS: this.proxyDNS }
}
}
export class Socks4ProxySettings extends ProxySettingsBase<Socks4ProxyInfo> {
readonly proxyDNS: boolean
constructor(dao: ProxyDao) {
super(dao)
this.proxyDNS = dao.proxyDNS ?? true
}
get type(): ProxyType.Socks4 {
return ProxyType.Socks4
}
asProxyInfo(): Socks4ProxyInfo {
return {
type: this.type,
host: this.host,
port: this.port,
proxyDNS: this.proxyDNS,
failoverTimeout
}
}
asDao(): ProxyDao {
return { ...super.baseDao(), proxyDNS: this.proxyDNS }
}
}
abstract class HttpBasedProxySettings<PI extends (HttpsProxyInfo | HttpProxyInfo)> extends ProxySettingsBase<PI> {
readonly username?: string
readonly password?: string
constructor(dao: ProxyDao) {
super(dao)
this.username = dao.username
this.password = dao.password
}
asDao(): ProxyDao {
return { ...super.baseDao(), username: this.username, password: this.password }
}
}
export class HttpsProxySettings extends HttpBasedProxySettings<HttpsProxyInfo> {
get type(): ProxyType.Https {
return ProxyType.Https
}
asProxyInfo(): HttpsProxyInfo {
return {
type: this.type,
host: this.host,
port: this.port,
proxyAuthorizationHeader: '', //generateAuthorizationHeader(this.username ?? '', this.password ?? ''),
failoverTimeout
}
}
}
export class HttpProxySettings extends HttpBasedProxySettings<HttpProxyInfo> {
get type(): ProxyType.Http {
return ProxyType.Http
}
asProxyInfo(): HttpProxyInfo {
return {
type: this.type,
host: this.host,
port: this.port,
failoverTimeout
}
}
}
@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-namespace,no-redeclare,import/export */
export enum ProxyType {
Socks5 = 'socks',
Socks4 = 'socks4',
Http = 'http',
Https = 'https'
}
export namespace ProxyType {
export function tryFromString(s: string): ProxyType | undefined {
switch (s) {
case 'socks':
return ProxyType.Socks5
case 'socks4':
return ProxyType.Socks4
case 'http':
return ProxyType.Http
case 'https':
return ProxyType.Https
default:
return undefined
}
}
}
@@ -0,0 +1,28 @@
{
"manifest_version": 2,
"name": "Container proxy",
"version": "0.1.22",
"description": "Container Proxy",
"browser_specific_settings": {
"gecko": {
"id": "container-proxy@lensai.eu"
}
},
"permissions": [
"<all_urls>",
"geckoViewAddons",
"nativeMessaging",
"nativeMessagingFromContent",
"webRequest",
"webRequestBlocking",
"storage",
"contextualIdentities",
"cookies",
"proxy"
],
"background": {
"scripts": [
"background/index.js"
]
}
}
@@ -0,0 +1,164 @@
import { ProxyInfo } from '../domain/ProxyInfo'
import { ProxyType } from '../domain/ProxyType'
import { ProxySettings } from '../domain/ProxySettings'
const tryFromDao = ProxySettings.tryFromDao
/* eslint-disable @typescript-eslint/no-namespace,no-redeclare,import/export */
export interface ProxyDao {
id: string
title: string
type: string
host: string
port: number
username?: string
password?: string
proxyDNS?: boolean
doNotProxyLocal: boolean
}
export namespace ProxyDao {
export function toProxyInfo(proxy: Pick<ProxyDao, 'type' | 'host' | 'port' | 'username' | 'password' | 'proxyDNS'>): ProxyInfo | undefined {
const type = ProxyType.tryFromString(proxy.type)
if (type === undefined) {
return
}
const base = {
host: proxy.host,
port: proxy.port,
failoverTimeout: 5
}
switch (type) {
case ProxyType.Socks5:
return {
type,
...base,
username: proxy.username ?? '',
password: proxy.password ?? '',
proxyDNS: proxy.proxyDNS ?? true
}
case ProxyType.Socks4:
return {
type,
...base,
proxyDNS: proxy.proxyDNS ?? true
}
case ProxyType.Http:
return {
type,
...base
}
case ProxyType.Https:
return {
type,
...base,
proxyAuthorizationHeader: '' //generateAuthorizationHeader(proxy.username ?? '', proxy.password ?? '')
}
}
}
}
export class Store {
private proxies: ProxyDao[] = []
private relations: { [key: string]: string[] } = {}
async getAllProxies(): Promise<ProxySettings[]> {
const proxyDaos = await this.getAllProxyDaos()
const result: ProxySettings[] = proxyDaos.map(tryFromDao).filter(p => p !== undefined) as ProxySettings[]
return result
}
async getProxyById(id: string): Promise<ProxySettings | null> {
const proxies = await this.getAllProxies()
const index = proxies.findIndex(p => p.id === id)
if (index === -1) {
return null
} else {
return proxies[index]
}
}
async putProxy(proxy: ProxySettings): Promise<void> {
const proxies = await this.getAllProxyDaos()
const index = proxies.findIndex(p => p.id === proxy.id)
if (index !== -1) {
proxies[index] = proxy.asDao()
} else {
proxies.push(proxy.asDao())
}
await this.saveProxyDaos(proxies)
}
async deleteProxyById(id: string): Promise<void> {
const proxies = await this.getAllProxyDaos()
const index = proxies.findIndex(p => p.id === id)
if (index !== -1) {
proxies.splice(index, 1)
await this.saveProxyDaos(proxies)
}
}
async getRelations(): Promise<{ [key: string]: string[] }> {
return this.relations
}
async setContainerProxyRelation(cookieStoreId: string, proxyId: string): Promise<void> {
this.relations[cookieStoreId] = [proxyId]
}
async removeContainerProxyRelation(cookieStoreId: string, proxyId: string): Promise<void> {
const currentRelations = this.relations[cookieStoreId] ?? []
this.relations[cookieStoreId] = currentRelations.filter(id => id !== proxyId)
// If no relations left for this container, clean up by removing the key
if (this.relations[cookieStoreId].length === 0) {
delete this.relations[cookieStoreId]
}
}
async getProxiesForContainer(cookieStoreId: string): Promise<ProxySettings[]> {
const relations = await this.getRelations()
const proxyIds: string[] = relations[cookieStoreId] ?? []
if (proxyIds.length === 0) {
return []
}
const proxies = await this.getAllProxies()
const proxyById: { [key: string]: ProxySettings } = {}
proxies.forEach(function (p) { proxyById[p.id] = p })
return proxyIds.map(pId => proxyById[pId])
.filter(p => p !== undefined)
.map(fillInDefaults)
.map(tryFromDao)
.filter(p => p !== undefined) as ProxySettings[]
}
private async saveProxyDaos(p: ProxyDao[]): Promise<void> {
this.proxies = p
}
private async getAllProxyDaos(): Promise<ProxyDao[]> {
return this.proxies.map(fillInDefaults)
}
}
function fillInDefaults(proxy: Partial<ProxyDao>): ProxyDao {
if (proxy.title === undefined) {
proxy.title = ''
}
if (typeof proxy.doNotProxyLocal === 'undefined') {
proxy.doNotProxyLocal = true
}
if (typeof proxy.proxyDNS === 'undefined') {
if (proxy.type === 'socks' || proxy.type === 'socks4') {
proxy.proxyDNS = true
}
}
return proxy as ProxyDao
}
@@ -0,0 +1,166 @@
const PageObject = require('./page-objects/PageObject.js')
const OptionsPageObject = require('./page-objects/OptionsPageObject.js')
const path = require('path')
const assert = require('chai')
const expect = assert.expect
const webExtensionsGeckoDriver = require('webextensions-geckodriver')
const { webdriver, firefox } = webExtensionsGeckoDriver
const { until, By } = webdriver
const manifestPath = path.resolve(path.join(__dirname, '../../dist/manifest.json'))
describe('Container Proxy extension', function () {
let geckodriver
this.timeout(30000)
before(async () => {
const fxOptions = new firefox.Options()
if (process.env.HEADLESS) {
fxOptions.headless()
.windowSize({ height: 1080, width: 1920 })
}
const webExtension = await webExtensionsGeckoDriver(manifestPath, { fxOptions })
geckodriver = webExtension.geckodriver
})
it('should add a proxy', async () => {
const helper = new Helper(geckodriver)
const options = await helper.openOptionsPage()
let proxyList = await options.openProxyList()
const proxyForm = await proxyList.openAddProxyForm()
await proxyForm.selectProtocol('socks')
await proxyForm.typeInServer('localhost')
await proxyForm.typeInPort(1080)
await proxyForm.typeInUsername('user')
await proxyForm.typeInPassword('password')
await proxyForm.testSettings()
proxyList = await proxyForm.saveSettings()
const proxyLabel = 'socks://localhost:1080'
await geckodriver.wait(async () => {
const row = await geckodriver.wait(until.elementLocated(
By.css('.proxy-list-item:first-of-type')
), 2000)
const label = row.findElement(By.css('.proxy-name'))
const text = await label.getText()
return text === proxyLabel
}, 1000, 'Should show proxy in the list')
const assign = await options.openAssignProxy()
const defaultContainerSelect = await assign.defaultContainerSelect()
await defaultContainerSelect.selectByLabel(proxyLabel)
})
it.skip('should contain IP address text', async () => {
await geckodriver.setContext(firefox.Context.CONTENT)
await geckodriver.get('https://api.duckduckgo.com/?q=ip&no_html=1&format=json&t=firefox-container-proxy-extension')
const text = await geckodriver.getPageSource()
expect(text).to.include('Your IP address is')
})
it('should successfully use SOCKS5 proxy for default container', async () => {
const helper = new Helper(geckodriver)
const optionsPage = await helper.openOptionsPage()
const proxyList = await optionsPage.openProxyList()
const addProxyForm = await proxyList.openAddProxyForm()
const title = 'Valid SOCKS5 proxy'
await addProxyForm.addProxy({
title: title,
type: 'socks',
server: 'localhost',
port: 1080,
username: 'user',
password: 'password'
})
// TODO: Check if username and password are actually verified by "dante"
const assignProxy = await optionsPage.openAssignProxy()
await assignProxy.selectForDefaultContainer(title)
await helper.assertCanGetTheIpAddress()
})
it('should fail with incorrect SOCKS5 proxy settings', async () => {
const helper = new Helper(geckodriver)
const optionsPage = await helper.openOptionsPage()
const proxyList = await optionsPage.openProxyList()
const addProxyForm = await proxyList.openAddProxyForm()
const title = 'Incorrectly setup SOCKS5 proxy'
await addProxyForm.addProxy({
title: title,
type: 'socks',
server: 'localhost',
port: 999,
username: 'user',
password: 'password'
})
const assignProxy = await optionsPage.openAssignProxy()
await assignProxy.selectForDefaultContainer(title)
await helper.assertProxyFailure()
})
after(function () {
geckodriver.quit()
})
})
class Helper extends PageObject {
toolbarButton = By.id('container-proxy_bekh-ivanov_me-browser-action')
/**
* @return {Promise<OptionsPageObject>}
*/
async openOptionsPage() {
await this._driver.setContext(firefox.Context.CHROME)
await this.click(this.toolbarButton)
await this._driver.setContext(firefox.Context.CONTENT)
let windowHandle
await this._driver.wait(async () => {
const windowHandles = await this._driver.getAllWindowHandles()
windowHandle = windowHandles[windowHandles.length - 1]
try {
await this._driver.switchTo().window(windowHandle)
} catch (e) {
return false
}
const title = await this._driver.getTitle()
return title === 'Container Proxy extension settings'
}, 2000, 'Should have opened Container Proxy extension settings')
return this.createPageObject(OptionsPageObject)
}
async assertCanGetTheIpAddress() {
await this._driver.setContext(firefox.Context.CONTENT)
await this._driver.get('https://duckduckgo.com/?q=ip&ia=answer&atb=v150-1')
const text = await this._driver.getPageSource()
expect(text).to.include('Your IP address is')
}
async assertProxyFailure() {
await this._driver.setContext(firefox.Context.CONTENT)
try {
await this._driver.get('https://api.duckduckgo.com/?q=ip&no_html=1&format=json&t=firefox-container-proxy-extension')
} catch (e) {
}
const text = await this._driver.getPageSource()
expect(text).to.include('Firefox is configured to use a proxy server that is refusing connections.')
}
}
@@ -0,0 +1,10 @@
version: "3"
services:
socks:
build: ./socks
ports:
- "1080:1080"
http:
build: ./http
ports:
- "3128:3128"
@@ -0,0 +1,11 @@
FROM datadog/squid
RUN apt update && apt install -y apache2-utils
RUN htpasswd -b -c /etc/squid/passwords userhttp passwordhttp
ADD squid.conf /etc/squid/squid.conf
RUN chmod o+rw /var/run
USER proxy
@@ -0,0 +1,17 @@
auth_param basic program /usr/lib/squid3/basic_ncsa_auth /etc/squid/passwords
#auth_param basic children 5 startup = 5 idle = 1
auth_param basic realm proxy
auth_param basic credentialsttl 1 second
connect_timeout 1 second
acl authenticated proxy_auth REQUIRED
http_access allow authenticated
logformat splunk_recommended_squid %ts.%03tu logformat=splunk_recommended_squid duration=%tr src_ip=%>a src_port=%>p dest_ip=%<a dest_port=%<p user_ident="%[ui" user="%[un" local_time=[%tl] http_method=%rm request_method_from_client=%<rm request_method_to_server=%>rm url="%ru" http_referrer="%{Referer}>h" http_user_agent="%{User-Agent}>h" status=%>Hs vendor_action=%Ss dest_status=%Sh total_time_milliseconds=%<tt http_content_type="%mt" bytes=%st bytes_in=%>st bytes_out=%<st sni=ssl::>sni
logfile_rotate 0
cache_log stdio:/proc/self/fd/1 splunk_recommended_squid
access_log stdio:/proc/self/fd/1 splunk_recommended_squid
cache_store_log stdio:/proc/self/fd/1 splunk_recommended_squid
# Choose the port you want. Below we set it to default 3128.
http_port 3128
@@ -0,0 +1,4 @@
user:$apr1$L24dGKxY$xqj/npbeiBkMZEH7s9Iae/
@@ -0,0 +1,3 @@
FROM vimagick/dante
RUN useradd user && echo user:password | chpasswd
@@ -0,0 +1,117 @@
import BackgroundMain, { doNotProxy } from '../../src/background/BackgroundMain'
import { Store } from '../../src/store/Store'
import webExtensionsApiFake from 'webextensions-api-fake'
import { expect } from 'chai'
import { ProxySettings } from '../../src/domain/ProxySettings'
const tryFromDao = ProxySettings.tryFromDao
/* eslint-disable @typescript-eslint/no-unused-expressions */
const store = new Store()
describe('BackgroundMain', function () {
beforeEach(() => {
// @ts-expect-error
global.browser = webExtensionsApiFake()
})
afterEach(() => {
// @ts-expect-error
delete global.browser
})
const backgroundMain = new BackgroundMain({ store: store })
// TODO: Add test for proxyDNS property
describe('onRequest', function () {
it('should return empty array if no proxy is set up', async () => {
const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 })
expect(result).to.be.deep.equal(doNotProxy)
})
it('should return proxy if proxy is set up', async () => {
await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined })
const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 })
expect(result).to.be.an('array')
expect(result).to.be.not.empty
})
it('should remove doNotProxyLocal flag from proxy settings if proxy is set up', async () => {
await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined })
const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 })
expect((result[0] as any).doNotProxyLocal).to.be.undefined
})
it('should return proxy for the container if url is invalid', async () => {
// To be more on a safe side
await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined })
const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'np-protocol-url.com', tabId: 0 })
expect(result).to.be.an('array')
expect(result).to.be.not.empty
})
// Connections to localhost, 127.0.0.1, and ::1 are never proxied. (From FF settings)
const localAddresses = [
'http://localhost/index.html',
'https://localhost/index.html',
'http://127.0.0.1/',
'https://127.0.0.1/',
'http://[::1]/test',
'https://[::1]/test',
'http://[0:0:0:0:0:0:0:1]/test',
'https://[0:0:0:0:0:0:0:1]/test',
'https://user:password@127.0.0.1:123/',
'http://[::1]:123/test'
]
describe('proxying of local addresses is disabled', () => {
localAddresses.forEach(url => {
it(`should return empty array if the address is local: ${url}`, async () => {
await givenSomeProxyIsSetUpForContainer({ containerId: 'container1', host: undefined, doNotProxyLocal: true })
const result = await backgroundMain.onRequest({ cookieStoreId: 'container1', url, tabId: 0 })
expect(result).to.be.deep.equal(doNotProxy)
})
})
})
describe('proxying of local addresses is enabled', () => {
localAddresses.forEach(url => {
it(`should return array with proxy: ${url}`, async () => {
const host = 'proxyX.example.com'
await givenSomeProxyIsSetUpForContainer({ host, containerId: 'container1', doNotProxyLocal: false })
const result = await backgroundMain.onRequest({ cookieStoreId: 'container1', url, tabId: 0 })
expect(result[0].host).to.be.equal(host)
})
})
})
})
})
async function givenSomeProxyIsSetUpForContainer({ host, containerId, doNotProxyLocal }: any): Promise<void> {
const proxyId = 'proxy1'
const proxy: any = {
id: proxyId,
type: 'socks',
host: (host as string) ?? 'example.com',
port: 1080
}
if (typeof doNotProxyLocal !== 'undefined') {
proxy.doNotProxyLocal = doNotProxyLocal
}
await store.putProxy(tryFromDao(proxy) as ProxySettings)
await store.setContainerProxyRelation(containerId, proxyId)
}
@@ -0,0 +1,167 @@
import { ProxyDao, Store } from '../../src/store/Store'
import webExtensionsApiFake from 'webextensions-api-fake'
import { expect } from 'chai'
import { ProxySettings } from '../../src/domain/ProxySettings'
const tryFromDao = ProxySettings.tryFromDao
/* eslint-disable @typescript-eslint/no-unused-expressions */
describe('Store', () => {
const store = new Store()
beforeEach(() => {
(global as any).browser = webExtensionsApiFake()
})
afterEach(() => {
delete (global as any).browser
})
function someProxyWith(id: string, props: Partial<ProxyDao> = {}): ProxySettings {
const dao = {
id: id,
title: 'some title',
type: 'socks',
host: 'localhost',
port: 1080,
username: 'user',
password: 'password',
proxyDNS: true,
failoverTimeout: 5,
doNotProxyLocal: true
}
return tryFromDao({ ...dao, ...props }) as ProxySettings
}
describe('getAllProxies', function () {
it('should return empty array if nothing is saved', async () => {
const proxies = await store.getAllProxies()
expect(proxies).to.be.an('array')
expect(proxies).to.be.empty
})
})
it('should put and get the proxy back', async () => {
const id = 'someId'
const proxy = someProxyWith(id)
await store.putProxy(proxy)
const gotProxy = await store.getProxyById(id)
expect(gotProxy).to.be.deep.equal(proxy)
})
it('when get, returns doNotProxyLocal `true` if was not set when put', async () => {
const id = 'someId'
const proxy = someProxyWith(id)
delete (proxy as any).doNotProxyLocal
await store.putProxy(proxy)
const gotProxy = await store.getProxyById(id)
expect(gotProxy?.doNotProxyLocal).to.be.equal(true)
})
it('stores doNotProxyLocal value', async () => {
const id = 'someId'
const proxy = someProxyWith(id, { doNotProxyLocal: false })
await store.putProxy(proxy)
const gotProxy = await store.getProxyById(id)
expect(gotProxy?.doNotProxyLocal).to.be.equal(false)
})
it('should be able to delete proxy', async () => {
const id = 'someId'
await store.putProxy(someProxyWith(id))
await store.deleteProxyById(id)
const result = await store.getProxyById(id)
expect(result).to.be.null
})
// describe('getProxiesForContainer', () => {
// it('should find proxy by container id if one present', async () => {
// const cookieStoreId = 'container-1'
// const proxyId = 'proxy-1'
// // TODO Store should probably take care of this
// const relations = {
// [cookieStoreId]: [proxyId]
// }
// const givenProxy = someProxyWith(proxyId)
// await store.putProxy(givenProxy)
// await browser.storage.local.set({ relations: relations })
// const [gotProxy] = await store.getProxiesForContainer(cookieStoreId)
// expect(gotProxy).to.be.deep.equal(givenProxy)
// })
// it('doNotProxyLocal should be `true` if not set', async () => {
// const cookieStoreId = 'container-1'
// const proxyId = 'proxy-1'
// // TODO Store should probably take care of this
// const relations = {
// [cookieStoreId]: [proxyId]
// }
// const givenProxy = someProxyWith(proxyId)
// delete (givenProxy as any).doNotProxyLocal
// await store.putProxy(givenProxy)
// await browser.storage.local.set({ relations: relations })
// const [gotProxy] = await store.getProxiesForContainer(cookieStoreId)
// expect(gotProxy.doNotProxyLocal).to.be.equal(true)
// })
// it('should return empty array if proxy not set for the container', async () => {
// const cookieStoreId = 'container-1'
// const result = await store.getProxiesForContainer(cookieStoreId)
// expect(result).to.be.empty
// })
// it('should return empty array if proxy is set for the container but does not exist', async () => {
// const cookieStoreId = 'container-1'
// const proxyId = 'something-absent'
// const relations = {
// [cookieStoreId]: [proxyId]
// }
// await browser.storage.local.set({ relations: relations })
// const result = await store.getProxiesForContainer(cookieStoreId)
// expect(result).to.be.empty
// })
// })
describe('setContainerProxyRelation', function () {
it('should set the relation', async () => {
await store.setContainerProxyRelation('container1', 'proxy1')
const relations = await store.getRelations()
expect(relations.container1).to.be.deep.equal(['proxy1'])
})
it('should not remove existing relations', async () => {
await store.setContainerProxyRelation('container1', 'proxy1')
await store.setContainerProxyRelation('container2', 'proxy2')
const relations = await store.getRelations()
expect(relations.container1).to.be.deep.equal(['proxy1'])
expect(relations.container2).to.be.deep.equal(['proxy2'])
})
})
})
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"outDir": "./dist/",
"module": "ES2020",
"moduleResolution": "node",
"noEmitOnError": true,
"target": "es2019",
"sourceMap": true,
"allowJs": true,
"baseUrl": ".",
"allowSyntheticDefaultImports": true,
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"plugins": [{ "name": "typescript-plugin-css-modules" }]
},
"include": [
"src/**/*",
"test/unit/**/*",
"declarations.d.ts"
]
}
@@ -0,0 +1,60 @@
/* eslint-disable */
const path = require('path')
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')
const CopyPlugin = require('copy-webpack-plugin')
module.exports = {
entry: {
background: './src/background/index.ts',
},
devtool: 'source-map',
mode: 'development',
performance: {
hints: false,
},
node: false,
optimization: {
minimize: false,
moduleIds: 'named',
chunkIds: 'named',
concatenateModules: false,
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.module.scss'],
plugins: [new TsconfigPathsPlugin()],
},
output: {
filename: '[name]/index.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.module\.scss$/i,
use: ['style-loader', 'css-modules-typescript-loader', {
loader: 'css-loader',
options: { modules: { localIdentName: '[name]__[local]--[hash:base64:5]' } }
}, 'sass-loader'],
},
]
},
plugins: [
new CopyPlugin({
patterns: [
'README.md',
'LICENSE',
'src/manifest.json',
],
}),
],
devServer: {
hot: false,
inline: false,
writeToDisk: true,
},
}