├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── src ├── styles │ ├── index.ts │ ├── fonts.css │ ├── themeOverrides.ts │ └── main.css ├── options │ ├── main.ts │ ├── App.vue │ ├── index.html │ └── Options.vue ├── popup │ ├── main.ts │ ├── index.html │ ├── App.vue │ └── Popup.vue ├── helpers │ ├── unique.ts │ ├── socksProxy │ │ ├── constants.ts │ │ ├── getTargetHost.ts │ │ ├── addCountryCode.ts │ │ ├── sortProxiesByCountryAndCity.ts │ │ ├── groupByCountryAndCity.ts │ │ ├── socksProxies.types.ts │ │ ├── getCityCountrySocksProxy.ts │ │ ├── socksProxy.types.ts │ │ ├── getTargetHost.test.ts │ │ ├── getRandomSessionProxy.ts │ │ ├── getCityCountrySocksProxy.test.ts │ │ └── socksProxy.test.ts │ ├── pluralize.ts │ ├── permissions.ts │ ├── pluralize.test.ts │ ├── domain.ts │ ├── connCheck.types.ts │ ├── unique.test.ts │ ├── browserExtension.ts │ ├── connCheck.ts │ ├── extensions.ts │ ├── domains.test.ts │ ├── proxyListeners.ts │ ├── tabs.ts │ └── proxyBadge.ts ├── global.d.ts ├── components │ ├── Icons │ │ ├── FeDropDown.vue │ │ ├── FeElipsisV.vue │ │ ├── FeDrop.vue │ │ ├── FeLinkExternal.vue │ │ ├── FeCheck.vue │ │ ├── FeChevronDown.vue │ │ ├── FeChevronLeft.vue │ │ ├── FeChevronRight.vue │ │ ├── FeChevronUp.vue │ │ ├── FePower.vue │ │ ├── FeArrowLeft.vue │ │ ├── FeArrowRight.vue │ │ ├── FeEye.vue │ │ ├── FeLock.vue │ │ ├── FeLockOff.vue │ │ ├── FeMapPin.vue │ │ ├── FeCheckCircle.vue │ │ ├── FeInfo.vue │ │ ├── FeMenu.vue │ │ ├── FeXCircle.vue │ │ ├── FeWarning.vue │ │ ├── FeQuestion.vue │ │ ├── FeHelpCircle.vue │ │ ├── TaRoute.vue │ │ ├── FeGlobe.vue │ │ ├── FeShuffle.vue │ │ ├── FeEyeOff.vue │ │ ├── FeFileText.vue │ │ ├── FeGithub.vue │ │ ├── TaRouteBlocked.vue │ │ ├── MuSpinner.vue │ │ └── FeCog.vue │ ├── Proxy │ │ ├── InUseTag.vue │ │ ├── ProxyAutoReload.vue │ │ └── RandomProxyMode.vue │ ├── CityButton.vue │ ├── Label.vue │ ├── OptionsTabs │ │ ├── SettingsTab.vue │ │ ├── ProxyTab.vue │ │ └── AboutTab.vue │ ├── ExternalLinkIconLabel.vue │ ├── LocationDrawer.vue │ ├── SearchLocation.vue │ ├── ProxyList.vue │ ├── CityButton.test.ts │ ├── Buttons │ │ ├── __snapshots__ │ │ │ └── Button.test.ts.snap │ │ ├── Button.test.ts │ │ ├── SplitButton.vue │ │ └── Button.vue │ ├── ConnectionCheck │ │ ├── ConnectionLocation.vue │ │ ├── WebTRCDetails.vue │ │ ├── ConnectionCheck.vue │ │ ├── ConnectionLocation.test.ts │ │ └── ConnectionDetails.vue │ ├── PrivacyRecommendations │ │ ├── PrivacyRecommendations.vue │ │ ├── Instructions.vue │ │ ├── WebRTCToggle.vue │ │ └── PrivacyRecommendation.vue │ ├── TitleCategory.vue │ ├── RecentLocationButtons.vue │ ├── MostUsedLocationButtons.vue │ ├── __snapshots__ │ │ ├── MostUsedLocationButtons.test.ts.snap │ │ ├── CityButton.test.ts.snap │ │ └── ProxyList.test.ts.snap │ ├── Countries.vue │ ├── RecommendationIconWithTooltip.vue │ ├── IconLabel.vue │ ├── ProxyList.test.ts │ ├── Cities.vue │ ├── LocationTabs.vue │ ├── MostUsedLocationButtons.test.ts │ ├── Cities.test.ts │ ├── PopupHeader.vue │ ├── Location.vue │ ├── Countries.test.ts │ └── NotificationsCarousel.vue ├── background │ ├── index.html │ └── main.ts ├── composables │ ├── useOptionsTab.ts │ ├── useHttpsOnly.ts │ ├── useProxyHistory │ │ ├── HistoryEntries.types.ts │ │ └── useProxyHistory.ts │ ├── useLocations.test.ts │ ├── useRecommendations │ │ ├── Recommendation.types.ts │ │ ├── sortRecommendations.ts │ │ ├── defaultRecommendations.ts │ │ └── useRecommendations.ts │ ├── useConnection │ │ ├── useConnectionLocation.ts │ │ ├── useConnectionLocation.test.ts │ │ ├── useConnectionStatus.ts │ │ ├── useConnection.ts │ │ └── useCheckDnsLeaks.ts │ ├── useProxyPermissions.ts │ ├── useLocations.ts │ ├── useBrowserStorageLocal.ts │ ├── useRandomProxy.ts │ ├── useActiveTab.ts │ ├── useRecommendationIconTooltip.ts │ ├── useStore.ts │ ├── useWarnings │ │ ├── useWarnings.ts │ │ └── warnings.ts │ ├── useWebRtc.ts │ ├── useSocksProxies.ts │ └── useRecommendationIconTooltip.test.ts └── manifest.ts ├── extension └── assets │ ├── icons │ ├── ubo64.png │ └── default.png │ ├── fonts │ ├── SourceSansPro-Black.ttf │ ├── SourceSansPro-Bold.ttf │ ├── SourceSansPro-Regular.ttf │ └── SourceSansPro-SemiBold.ttf │ └── mullvad-logo.svg ├── .prettierrc.json ├── .lintstagedrc.js ├── .vscode ├── extensions.json └── settings.json ├── .gitignore ├── .stylelintrc.json ├── scripts ├── copyFilesToExtensionFolder.ts ├── manifest.ts ├── utils.ts └── prepare.ts ├── windi.config.ts ├── vitest.setup.ts ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── vitest.config.mts ├── eslint.config.mjs ├── vite.config.mts ├── CONTRIBUTING.md ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.11.0 -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /src/styles/index.ts: -------------------------------------------------------------------------------- 1 | import './fonts.css'; 2 | import './main.css'; 3 | import 'virtual:windi.css'; 4 | -------------------------------------------------------------------------------- /extension/assets/icons/ubo64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mullvad/browser-extension/HEAD/extension/assets/icons/ubo64.png -------------------------------------------------------------------------------- /extension/assets/icons/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mullvad/browser-extension/HEAD/extension/assets/icons/default.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "proseWrap": "always" 6 | } 7 | -------------------------------------------------------------------------------- /extension/assets/fonts/SourceSansPro-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mullvad/browser-extension/HEAD/extension/assets/fonts/SourceSansPro-Black.ttf -------------------------------------------------------------------------------- /extension/assets/fonts/SourceSansPro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mullvad/browser-extension/HEAD/extension/assets/fonts/SourceSansPro-Bold.ttf -------------------------------------------------------------------------------- /extension/assets/fonts/SourceSansPro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mullvad/browser-extension/HEAD/extension/assets/fonts/SourceSansPro-Regular.ttf -------------------------------------------------------------------------------- /extension/assets/fonts/SourceSansPro-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mullvad/browser-extension/HEAD/extension/assets/fonts/SourceSansPro-SemiBold.ttf -------------------------------------------------------------------------------- /src/options/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import '../styles'; 4 | 5 | const app = createApp(App); 6 | app.mount('#app'); 7 | -------------------------------------------------------------------------------- /src/popup/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import '../styles'; 4 | 5 | const app = createApp(App); 6 | app.mount('#app'); 7 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js,ts,vue}': ['eslint', 'prettier --write'], 3 | '*.{ts,vue}': [() => 'tsc --noEmit'], 4 | '*.{css,scss,vue}': 'stylelint', 5 | }; 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "voorjaar.windicss-intellisense", 6 | "csstools.postcss" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/unique.ts: -------------------------------------------------------------------------------- 1 | const unique = (list: T[], key: keyof T): T[] => { 2 | return [...new Map(list.map((entry) => [entry[key], entry])).values()]; 3 | }; 4 | 5 | export default unique; 6 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean; 2 | 3 | declare module '*.vue' { 4 | import { DefineComponent } from 'vue'; 5 | const component: DefineComponent; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Icons/FeDropDown.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | .vite-ssg-dist 4 | .vite-ssg-temp 5 | *.crx 6 | *.local 7 | *.log 8 | *.pem 9 | *.xpi 10 | *.zip 11 | dist 12 | dist-ssr 13 | extension/manifest.json 14 | extension/*.md 15 | node_modules 16 | coverage 17 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/constants.ts: -------------------------------------------------------------------------------- 1 | import { ProxyInfo } from '@/helpers/socksProxy/socksProxy.types'; 2 | 3 | export const baseConfig: Partial = { 4 | port: 1080, 5 | proxyDNS: true, 6 | }; 7 | 8 | export const socksIp = '10.64.0.1'; 9 | -------------------------------------------------------------------------------- /src/background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Background 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/Proxy/InUseTag.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-recommended", 4 | "stylelint-config-standard", 5 | "stylelint-config-recommended-vue" 6 | ], 7 | "rules": { 8 | "at-rule-no-deprecated": null, 9 | "selector-class-pattern": null 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/composables/useOptionsTab.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | export function useOptionsTab() { 4 | const currentTab = ref(''); 5 | 6 | const changeTab = (tab: string) => { 7 | currentTab.value = tab; 8 | }; 9 | 10 | return { 11 | currentTab, 12 | changeTab, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/pluralize.ts: -------------------------------------------------------------------------------- 1 | const pluralize = (word: string, count: number, plural?: string) => { 2 | if (count === 1) { 3 | return `${count} ${word}`; 4 | } 5 | if (plural) { 6 | return `${count} ${plural}`; 7 | } 8 | return `${count} ${word}s`; 9 | }; 10 | 11 | export default pluralize; 12 | -------------------------------------------------------------------------------- /src/components/CityButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "files.associations": { 8 | "*.css": "postcss" 9 | }, 10 | "[vue]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Popup 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/Icons/FeElipsisV.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /scripts/copyFilesToExtensionFolder.ts: -------------------------------------------------------------------------------- 1 | // copy README.md and LICENSE.md, so they will be added to package 2 | import { copy } from 'fs-extra'; 3 | 4 | const filesToCopy = ['README.md', 'LICENSE.md']; 5 | 6 | const copyFiles = async () => { 7 | filesToCopy.forEach((file) => copy(file, `extension/${file}`)); 8 | }; 9 | 10 | copyFiles(); 11 | -------------------------------------------------------------------------------- /scripts/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { getManifest } from '../src/manifest'; 3 | import { r, log } from './utils'; 4 | 5 | export async function writeManifest() { 6 | await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 }); 7 | log('PRE', 'write manifest.json'); 8 | } 9 | 10 | writeManifest(); 11 | -------------------------------------------------------------------------------- /src/components/Icons/FeDrop.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /src/components/Icons/FeLinkExternal.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/components/Label.vue: -------------------------------------------------------------------------------- 1 | 4 | 7 | 16 | -------------------------------------------------------------------------------- /src/composables/useHttpsOnly.ts: -------------------------------------------------------------------------------- 1 | import { privacy } from 'webextension-polyfill'; 2 | 3 | const useHttpsOnly = async () => { 4 | const { value: httpsOnlyMode } = await privacy.network.httpsOnlyMode.get({}); 5 | // Possible values returned are: "never", "always", "private_browsing" 6 | return httpsOnlyMode === 'always'; 7 | }; 8 | 9 | export default useHttpsOnly; 10 | -------------------------------------------------------------------------------- /src/options/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/Icons/FeCheck.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeChevronDown.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeChevronLeft.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeChevronRight.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeChevronUp.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/composables/useProxyHistory/HistoryEntries.types.ts: -------------------------------------------------------------------------------- 1 | export type HistoryEntryDetails = { 2 | country: string; 3 | countryCode: string; 4 | city: string; 5 | hostname: string; 6 | ipv4_address?: string; 7 | }; 8 | 9 | export type HistoryEntry = { count: number; timestamp: number } & HistoryEntryDetails; 10 | 11 | export type HistoryEntriesMap = { 12 | [key: string]: HistoryEntry; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/Icons/FePower.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeArrowLeft.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeArrowRight.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeEye.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/helpers/permissions.ts: -------------------------------------------------------------------------------- 1 | export const getProxyPermissions = async () => { 2 | return await browser.permissions.contains({ 3 | permissions: ['proxy', 'tabs'], 4 | origins: [''], 5 | }); 6 | }; 7 | 8 | export const requestProxyPermissions = async () => { 9 | return await browser.permissions.request({ 10 | permissions: ['proxy', 'tabs'], 11 | origins: [''], 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/Icons/FeLock.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeLockOff.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/Icons/FeMapPin.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Settings | Mullvad Browser Extension 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/Icons/FeCheckCircle.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/background/main.ts: -------------------------------------------------------------------------------- 1 | import { addExtensionsListeners } from '@/helpers/extensions'; 2 | import { initProxyListeners } from '@/helpers/proxyListeners'; 3 | 4 | // only on dev mode 5 | if (import.meta.hot) { 6 | // @ts-expect-error for background HMR 7 | import('/@vite/client'); 8 | } 9 | 10 | // Add listeners on extension actions 11 | addExtensionsListeners(); 12 | 13 | // Add listeners for proxy actions 14 | initProxyListeners(); 15 | -------------------------------------------------------------------------------- /src/composables/useLocations.test.ts: -------------------------------------------------------------------------------- 1 | import useLocations from '@/composables/useLocations'; 2 | import { it, describe, expect } from 'vitest'; 3 | 4 | describe('useLocations', () => { 5 | it('should default to false and toggle', () => { 6 | const { showLocations, toggleLocations } = useLocations(); 7 | expect(showLocations.value).toBe(false); 8 | 9 | toggleLocations(); 10 | expect(showLocations.value).toBe(true); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/Icons/FeInfo.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/Icons/FeMenu.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/Icons/FeXCircle.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/Icons/FeWarning.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/getTargetHost.ts: -------------------------------------------------------------------------------- 1 | import { checkDomain } from '@/helpers/domain'; 2 | import { ProxyDetails } from '@/helpers/socksProxy/socksProxy.types'; 3 | 4 | export const getTargetHost = (host: string, proxyDetails: Record) => { 5 | if (proxyDetails[host]) { 6 | return host; 7 | } 8 | 9 | const { hasSubdomain, domain } = checkDomain(host); 10 | return hasSubdomain && domain && proxyDetails[domain] ? domain : host; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Icons/FeQuestion.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/components/Icons/FeHelpCircle.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /src/components/OptionsTabs/SettingsTab.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /windi.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'windicss/helpers'; 3 | 4 | export default defineConfig({ 5 | darkMode: 'class', 6 | // https://windicss.org/posts/v30.html#attributify-mode 7 | attributify: true, 8 | extract: { 9 | include: [resolve(__dirname, 'src/**/*.{vue,html}')], 10 | }, 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: ['Source Sans Pro', 'Helvetica', 'Arial', 'sans-serif'], 15 | }, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/Icons/TaRoute.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/composables/useRecommendations/Recommendation.types.ts: -------------------------------------------------------------------------------- 1 | export interface Recommendation { 2 | type: 'extension' | 'setting' | 'warning'; 3 | id: string; 4 | name: string; 5 | description: string; 6 | icon?: string; 7 | iconType?: 'warning' | 'success' | 'info' | 'leak'; 8 | homeUrl?: string; 9 | warning?: string; 10 | ctaLabel: 'install' | 'enable' | 'disable' | undefined; 11 | ctaUrl?: string; 12 | installed?: boolean; 13 | enabled?: boolean; 14 | activated: boolean; 15 | ignored: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/addCountryCode.ts: -------------------------------------------------------------------------------- 1 | import { SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 2 | 3 | export const addCountryCodeToProxy = (proxy: SocksProxy) => { 4 | const { location } = proxy; 5 | const countryCode = location.code.substring(0, 2); 6 | 7 | return { 8 | ...proxy, 9 | location: { 10 | ...location, 11 | countryCode, 12 | }, 13 | }; 14 | }; 15 | 16 | export const addCountryCode = (data: SocksProxy[]) => { 17 | return data.map(addCountryCodeToProxy); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/ExternalLinkIconLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 24 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | // Mock browser API 4 | global.browser = { 5 | runtime: { 6 | getManifest: vi.fn(() => ({ 7 | version: '0.9.7', 8 | name: 'Mullvad Browser Extension', 9 | })), 10 | sendMessage: vi.fn(), 11 | onMessage: { 12 | addListener: vi.fn(), 13 | }, 14 | }, 15 | storage: { 16 | local: { 17 | get: vi.fn(), 18 | set: vi.fn(), 19 | }, 20 | }, 21 | tabs: { 22 | query: vi.fn(), 23 | }, 24 | } as unknown as typeof browser; 25 | -------------------------------------------------------------------------------- /src/components/LocationDrawer.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /src/components/Icons/FeGlobe.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/composables/useConnection/useConnectionLocation.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from '@/helpers/connCheck.types'; 2 | 3 | const useConnectionLocation = (connection: Connection) => { 4 | const { city, country } = connection; 5 | 6 | if (!city && !country) { 7 | return 'Unknown location'; 8 | } 9 | 10 | if (city && !country) { 11 | return city; 12 | } 13 | 14 | if (country && !city) { 15 | return country; 16 | } 17 | 18 | return `${city}, ${country}`; 19 | }; 20 | 21 | export default useConnectionLocation; 22 | -------------------------------------------------------------------------------- /src/components/SearchLocation.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [24.11.0] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm ci 21 | - run: npm run lint 22 | - run: npm run tsc 23 | - run: npm test 24 | - run: npm run build 25 | -------------------------------------------------------------------------------- /src/components/Icons/FeShuffle.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/components/Icons/FeEyeOff.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { resolve, dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { bgCyan, black } from 'kolorist'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | export const port = parseInt(process.env.PORT || '') || 3303; 9 | export const r = (...args: string[]) => resolve(__dirname, '..', ...args); 10 | export const isDev = process.env.NODE_ENV !== 'production'; 11 | 12 | export function log(name: string, message: string) { 13 | console.log(black(bgCyan(` ${name} `)), message); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icons/FeFileText.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/sortProxiesByCountryAndCity.ts: -------------------------------------------------------------------------------- 1 | import { City, Country, Servers } from '@/helpers/socksProxy/socksProxies.types'; 2 | 3 | export const sortProxiesByCountryAndCity = (grouped: Servers) => 4 | Object.entries(grouped) 5 | .map(([country, cityMap]) => { 6 | const cities = Object.entries(cityMap) 7 | .map(([city, proxyList]) => { 8 | return { city, proxyList } as City; 9 | }) 10 | .sort(({ city: a }, { city: b }) => a.localeCompare(b)); 11 | return { country, cities } as Country; 12 | }) 13 | .sort(({ country: a }, { country: b }) => a.localeCompare(b)); 14 | -------------------------------------------------------------------------------- /src/components/Icons/FeGithub.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/ProxyList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 24 | -------------------------------------------------------------------------------- /src/popup/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/groupByCountryAndCity.ts: -------------------------------------------------------------------------------- 1 | import { Servers, SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 2 | 3 | export const groupByCountryAndCity = (data: SocksProxy[]) => 4 | data.reduce((acc: Servers, proxy: SocksProxy) => { 5 | const { country, city } = proxy.location; 6 | 7 | // Create country object if not present 8 | if (!(country in acc)) { 9 | acc[country] = {}; 10 | } 11 | // Create a city array if not present 12 | if (!(city in acc[country])) { 13 | acc[country][city] = []; 14 | } 15 | // Add server to servers 16 | acc[country][city].push(proxy); 17 | return acc; 18 | }, {}); 19 | -------------------------------------------------------------------------------- /src/components/Icons/TaRouteBlocked.vue: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /src/components/CityButton.test.ts: -------------------------------------------------------------------------------- 1 | import CityButton from '@/components/CityButton.vue'; 2 | import { mount } from '@vue/test-utils'; 3 | import { NButton } from 'naive-ui'; 4 | 5 | describe('CityButton', () => { 6 | it('should render a CityButton', () => { 7 | const onClickCity = vi.fn(); 8 | const wrapper = mount(CityButton, { props: { city: 'Narnia', onClickCity } }); 9 | 10 | const buttons = wrapper.findAllComponents(NButton); 11 | expect(buttons).toHaveLength(1); 12 | expect(buttons[0].text()).toBe('Narnia'); 13 | 14 | buttons[0].trigger('click'); 15 | expect(onClickCity).toHaveBeenCalledWith('Narnia'); 16 | expect(wrapper.element).toMatchSnapshot(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/socksProxies.types.ts: -------------------------------------------------------------------------------- 1 | export type Location = { 2 | city: string; 3 | code: string; 4 | country: string; 5 | countryCode: string; 6 | longitude: number; 7 | latitude: number; 8 | }; 9 | 10 | export type SocksProxy = { 11 | online: boolean; 12 | hostname: string; 13 | ipv4_address: string; 14 | ipv6_address: string; 15 | port: number; 16 | location: Location; 17 | }; 18 | 19 | export interface Servers { 20 | [country: string]: { 21 | [city: string]: SocksProxy[]; 22 | }; 23 | } 24 | 25 | export type City = { 26 | city: string; 27 | proxyList: SocksProxy[]; 28 | }; 29 | 30 | export type Country = { 31 | country: string; 32 | cities: City[]; 33 | }; 34 | -------------------------------------------------------------------------------- /src/composables/useProxyPermissions.ts: -------------------------------------------------------------------------------- 1 | import { ref, readonly } from 'vue'; 2 | import { getProxyPermissions, requestProxyPermissions } from '@/helpers/permissions'; 3 | 4 | const useProxyPermissions = () => { 5 | const isGranted = ref(false); 6 | 7 | const checkProxyPermissions = async () => { 8 | isGranted.value = await getProxyPermissions(); 9 | }; 10 | 11 | const requestPermissions = async (): Promise => { 12 | isGranted.value = await requestProxyPermissions(); 13 | return isGranted.value; 14 | }; 15 | 16 | checkProxyPermissions(); 17 | 18 | return { 19 | isGranted: readonly(isGranted), 20 | requestPermissions, 21 | }; 22 | }; 23 | 24 | export default useProxyPermissions; 25 | -------------------------------------------------------------------------------- /src/components/Buttons/__snapshots__/Button.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Button > should render a blue button 1`] = ` 4 | 12 | `; 13 | 14 | exports[`Button > should render a button 1`] = ` 15 | 23 | `; 24 | 25 | exports[`Button > should render an a tag 1`] = ` 26 | 31 | 32 | Mullvad 33 | 34 | 35 | `; 36 | -------------------------------------------------------------------------------- /src/composables/useLocations.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | const showLocations = ref(false); 4 | const customProxySelect = ref(false); 5 | const customProxyHost = ref(''); 6 | 7 | const useLocations = () => { 8 | const toggleLocations = () => { 9 | showLocations.value = !showLocations.value; 10 | }; 11 | 12 | const proxySelect = (host?: string) => { 13 | // when host is not provided, it means the user is selecting a proxy for all websites 14 | customProxyHost.value = host ? host : ''; 15 | customProxySelect.value = !!host; 16 | toggleLocations(); 17 | }; 18 | 19 | return { customProxyHost, customProxySelect, proxySelect, showLocations, toggleLocations }; 20 | }; 21 | 22 | export default useLocations; 23 | -------------------------------------------------------------------------------- /src/helpers/pluralize.test.ts: -------------------------------------------------------------------------------- 1 | import pluralize from '@/helpers/pluralize'; 2 | import { it, describe, expect } from 'vitest'; 3 | 4 | describe('pluralize', () => { 5 | it('should return correct plural word', () => { 6 | expect(pluralize('metal head', 0)).toBe('0 metal heads'); 7 | expect(pluralize('metal head', 1)).toBe('1 metal head'); 8 | expect(pluralize('metal head', 2)).toBe('2 metal heads'); 9 | expect(pluralize('metal head', 15)).toBe('15 metal heads'); 10 | 11 | expect(pluralize('city', 0, 'cities')).toBe('0 cities'); 12 | expect(pluralize('city', 1, 'cities')).toBe('1 city'); 13 | expect(pluralize('city', 2, 'cities')).toBe('2 cities'); 14 | expect(pluralize('city', 15, 'cities')).toBe('15 cities'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Sans Pro'; 3 | src: url('/assets/fonts/SourceSansPro-Regular.ttf') format('truetype'); 4 | font-weight: 400; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: 'Source Sans Pro'; 10 | src: url('/assets/fonts/SourceSansPro-SemiBold.ttf') format('truetype'); 11 | font-weight: 600; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: 'Source Sans Pro'; 17 | src: url('/assets/fonts/SourceSansPro-Bold.ttf') format('truetype'); 18 | font-weight: 700; 19 | font-style: normal; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Source Sans Pro'; 24 | src: url('/assets/fonts/SourceSansPro-Black.ttf') format('truetype'); 25 | font-weight: 900; 26 | font-style: normal; 27 | } 28 | -------------------------------------------------------------------------------- /src/composables/useBrowserStorageLocal.ts: -------------------------------------------------------------------------------- 1 | import { useStorageAsync, StorageLikeAsync } from '@vueuse/core'; 2 | import { storage } from 'webextension-polyfill'; 3 | 4 | const browserStorageLocal: StorageLikeAsync = { 5 | removeItem(key: string) { 6 | return storage.local.remove(key); 7 | }, 8 | 9 | setItem(key: string, value: string) { 10 | return storage.local.set({ [key]: value }); 11 | }, 12 | 13 | async getItem(key: string): Promise { 14 | const result = await storage.local.get(key); 15 | return (result[key] as string) ?? null; 16 | }, 17 | }; 18 | 19 | const useBrowserStorageLocal = (key: string, initialValue: T) => { 20 | return useStorageAsync(key, initialValue, browserStorageLocal); 21 | }; 22 | 23 | export default useBrowserStorageLocal; 24 | -------------------------------------------------------------------------------- /src/components/ConnectionCheck/ConnectionLocation.vue: -------------------------------------------------------------------------------- 1 | 12 | 20 | -------------------------------------------------------------------------------- /src/components/PrivacyRecommendations/PrivacyRecommendations.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "es2016", 6 | "lib": ["DOM", "ESNext"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "incremental": false, 10 | "skipLibCheck": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "types": ["vite/client", "@types/firefox-webext-browser", "vitest/globals", "node"], 16 | "paths": { 17 | "@/*": ["src/*"] 18 | } 19 | }, 20 | "include": [ 21 | "shim.d.ts", 22 | "src/**/*.ts", 23 | "src/**/*.tsx", 24 | "src/**/*.d.ts", 25 | "src/**/*.types.ts", 26 | "src/**/*.test.ts", 27 | "src/**/*.vue", 28 | "scripts/**/*.ts" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import path from 'path'; 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | test: { 8 | globals: true, 9 | environment: 'happy-dom', 10 | setupFiles: ['./vitest.setup.ts'], 11 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 12 | exclude: ['node_modules', 'extension', 'scripts'], 13 | coverage: { 14 | provider: 'v8', // or 'istanbul' 15 | reporter: ['text', 'json', 'html'], 16 | exclude: ['node_modules/', 'extension/', 'scripts/', '**/*.spec.ts', '**/*.test.ts'], 17 | }, 18 | }, 19 | resolve: { 20 | alias: { 21 | '@': path.resolve(__dirname, './src'), 22 | '~': path.resolve(__dirname, './src'), 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/composables/useRandomProxy.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'vue'; 2 | 3 | import { updateCurrentTabProxyBadge } from '@/helpers/proxyBadge'; 4 | 5 | import useStore from '@/composables/useStore'; 6 | import useConnectionStatus from '@/composables/useConnection/useConnectionStatus'; 7 | 8 | const { randomProxyMode } = useStore(); 9 | const { checkStatus } = useConnectionStatus(); 10 | 11 | const useRandomProxy = () => { 12 | const toggleRandomProxyMode = () => { 13 | randomProxyMode.value = !randomProxyMode.value; 14 | updateCurrentTabProxyBadge(); 15 | }; 16 | 17 | watch( 18 | randomProxyMode, 19 | () => { 20 | checkStatus(); 21 | }, 22 | { deep: true, immediate: false }, 23 | ); 24 | 25 | return { 26 | toggleRandomProxyMode, 27 | randomProxyMode, 28 | }; 29 | }; 30 | 31 | export default useRandomProxy; 32 | -------------------------------------------------------------------------------- /src/components/TitleCategory.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 34 | -------------------------------------------------------------------------------- /src/helpers/domain.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'tldts'; 2 | 3 | export const checkDomain = (host: string) => { 4 | const parsed = parse(host); 5 | return { 6 | hasSubdomain: Boolean(parsed.subdomain), 7 | subDomain: host, 8 | domain: parsed.domain || host, 9 | }; 10 | }; 11 | 12 | export const isValidDomain = (domain: string): boolean => { 13 | // Special use domains should be allowed according to the IETF RFC 6761 14 | const publicSuffixes = ['arpa', 'test', 'localhost', 'internal']; 15 | 16 | const parsed = parse(domain, { 17 | validHosts: publicSuffixes, 18 | }); 19 | 20 | return Boolean(parsed.domain && (parsed.isIcann || publicSuffixes.includes(parsed.domain))); 21 | }; 22 | 23 | export const normalizeToFQDN = (input: string): string | null => { 24 | const parsed = parse(input); 25 | return parsed.hostname || null; 26 | }; 27 | -------------------------------------------------------------------------------- /src/composables/useConnection/useConnectionLocation.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest'; 2 | 3 | import type { Connection } from '@/helpers/connCheck.types'; 4 | import useConnectionLocation from '@/composables/useConnection/useConnectionLocation'; 5 | 6 | describe('useConnectionLocation test', function () { 7 | it('should handle empty input', () => { 8 | expect(useConnectionLocation({} as Connection)).toEqual('Unknown location'); 9 | }); 10 | 11 | it('should handle different inputs', () => { 12 | expect(useConnectionLocation({ city: 'Hellsinki' } as Connection)).toEqual('Hellsinki'); 13 | expect(useConnectionLocation({ country: 'Hell' } as Connection)).toEqual('Hell'); 14 | expect(useConnectionLocation({ city: 'Hellsinki', country: 'Hell' } as Connection)).toEqual( 15 | 'Hellsinki, Hell', 16 | ); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/helpers/connCheck.types.ts: -------------------------------------------------------------------------------- 1 | export type BlackListResult = { 2 | blacklisted: boolean; 3 | link: string; 4 | name: string; 5 | }; 6 | 7 | export interface AmIMullvadServerResponse { 8 | city?: string; 9 | country?: string; 10 | ip?: string; 11 | latitude?: number; 12 | longitude?: number; 13 | mullvad_exit_ip?: boolean; 14 | mullvad_exit_ip_hostname?: string; 15 | mullvad_server_type?: string; 16 | organization?: string; 17 | } 18 | 19 | export interface Ipv4ServerResponse extends AmIMullvadServerResponse { 20 | blacklisted?: { 21 | blacklisted: boolean; 22 | results: BlackListResult[]; 23 | }; 24 | } 25 | 26 | export type Connection = { 27 | city?: string; 28 | country?: string; 29 | countryCode?: string; 30 | ip?: string; 31 | ipv6?: string; 32 | isMullvad: boolean; 33 | protocol?: string; 34 | provider?: string; 35 | server?: string; 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/RecentLocationButtons.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | -------------------------------------------------------------------------------- /src/components/MostUsedLocationButtons.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 31 | -------------------------------------------------------------------------------- /src/composables/useRecommendations/sortRecommendations.ts: -------------------------------------------------------------------------------- 1 | import { Recommendation } from './Recommendation.types'; 2 | 3 | /** 4 | * Sort in the following order: 5 | * 1. to install 6 | * 2. disabled 7 | * 3. ignored 8 | * 4. activated 9 | * 5. by name 10 | * @param recommendations 11 | */ 12 | const sortRecommendations = (recommendations: Recommendation[]) => { 13 | // Make sure we don't mutate the original array! 14 | return [...recommendations].sort((a, b) => { 15 | if (a.activated === b.activated) { 16 | if (a.enabled === b.enabled) { 17 | if (a.ignored === b.ignored) { 18 | return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); 19 | } else { 20 | return a.ignored ? 1 : -1; 21 | } 22 | } else { 23 | return a.enabled ? 1 : -1; 24 | } 25 | } 26 | return a.activated ? 1 : -1; 27 | }); 28 | }; 29 | 30 | export default sortRecommendations; 31 | -------------------------------------------------------------------------------- /src/composables/useConnection/useConnectionStatus.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import useConnection from '@/composables/useConnection/useConnection'; 3 | import useCheckDnsLeaks from '@/composables/useConnection/useCheckDnsLeaks'; 4 | 5 | const isChecking = ref(false); 6 | 7 | export default function useConnectionStatus() { 8 | const { updateConnection, isError } = useConnection(); 9 | const { checkDnsLeaks } = useCheckDnsLeaks(); 10 | 11 | const checkStatus = async () => { 12 | // Prevent multiple simultaneous checks 13 | if (isChecking.value) { 14 | console.log('Status check already in progress, skipping'); 15 | return; 16 | } 17 | 18 | isChecking.value = true; 19 | 20 | try { 21 | await updateConnection(); 22 | 23 | if (!isError.value) { 24 | await checkDnsLeaks(); 25 | } 26 | } finally { 27 | isChecking.value = false; 28 | } 29 | }; 30 | 31 | return { 32 | checkStatus, 33 | isChecking, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/__snapshots__/MostUsedLocationButtons.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`MostUsedLocationButtons > should render one button 1`] = ` 4 |
7 | 8 | 16 | 17 |
18 | `; 19 | 20 | exports[`MostUsedLocationButtons > should render three buttons 1`] = ` 21 |
24 | 25 | 33 | 41 | 49 | 50 |
51 | `; 52 | -------------------------------------------------------------------------------- /src/components/Icons/MuSpinner.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 50 | -------------------------------------------------------------------------------- /src/components/ConnectionCheck/WebTRCDetails.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 31 | -------------------------------------------------------------------------------- /src/helpers/unique.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest'; 2 | 3 | import unique from '@/helpers/unique'; 4 | 5 | describe('unique', function () { 6 | it('should return the same input if unique', () => { 7 | const servers = [ 8 | { id: 1, name: 'One' }, 9 | { id: 2, name: 'Two' }, 10 | ]; 11 | expect(unique(servers, 'id')).toStrictEqual(servers); 12 | }); 13 | 14 | it('should remove single duplicate', () => { 15 | const servers = [ 16 | { id: 1, name: 'One' }, 17 | { id: 1, name: 'Two' }, 18 | ]; 19 | expect(unique(servers, 'id')).toStrictEqual([{ id: 1, name: 'Two' }]); 20 | }); 21 | 22 | it('should remove all duplicates', () => { 23 | const servers = [ 24 | { id: 1, name: 'One' }, 25 | { id: 2, name: 'Two' }, 26 | { id: 1, name: 'Three' }, 27 | { id: 2, name: 'Four' }, 28 | ]; 29 | expect(unique(servers, 'id')).toStrictEqual([ 30 | { id: 1, name: 'Three' }, 31 | { id: 2, name: 'Four' }, 32 | ]); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/components/Countries.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/composables/useActiveTab.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue'; 2 | import { getActiveTabDetails } from '@/helpers/tabs'; 3 | import { checkDomain } from '@/helpers/domain'; 4 | 5 | const activeTabHost = ref(''); 6 | const isAboutPage = ref(false); 7 | const isExtensionPage = ref(false); 8 | 9 | const getActiveTab = async () => { 10 | const { 11 | host, 12 | isAboutPage: isAboutPageValue, 13 | isExtensionPage: isExtensionPageValue, 14 | } = await getActiveTabDetails(); 15 | 16 | activeTabHost.value = host; 17 | isAboutPage.value = isAboutPageValue; 18 | isExtensionPage.value = isExtensionPageValue; // TODO is this value used anywhere? 19 | }; 20 | const activeTabDomain = computed(() => { 21 | const { domain, hasSubdomain, subDomain } = checkDomain(activeTabHost.value); 22 | return hasSubdomain ? subDomain : domain; 23 | }); 24 | 25 | const useActiveTab = () => { 26 | getActiveTab(); 27 | 28 | return { activeTabDomain, activeTabHost, isAboutPage, isExtensionPage }; 29 | }; 30 | 31 | export default useActiveTab; 32 | -------------------------------------------------------------------------------- /src/components/Icons/FeCog.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /src/styles/themeOverrides.ts: -------------------------------------------------------------------------------- 1 | import { GlobalThemeOverrides } from 'naive-ui'; 2 | 3 | export const themeOverrides: GlobalThemeOverrides = { 4 | common: { 5 | cardColor: 'rgba(41, 77, 115, 0.5)', 6 | fontSize: '1rem', 7 | lineHeight: '1.4', 8 | }, 9 | Avatar: { 10 | color: 'transparent', 11 | }, 12 | Card: { 13 | actionColor: 'transparent', 14 | borderRadius: '8px', 15 | }, 16 | Drawer: { 17 | color: 'var(--dark-blue)', 18 | }, 19 | Switch: { 20 | railColorActive: 'var(--success)', 21 | }, 22 | Checkbox: { 23 | colorChecked: 'var(--blue)', 24 | checkMarkColor: 'var(--success)', 25 | border: '1px solid var(--blue)', 26 | borderChecked: '1px solid var(--blue)', 27 | }, 28 | Tabs: { 29 | tabFontSizeLarge: '1.12rem', 30 | tabFontWeight: '600', 31 | tabFontWeightActive: '600', 32 | tabColor: 'var(--blue)', 33 | tabTextColorLine: '--light-grey', 34 | tabTextColorHoverLine: 'var(--white)', 35 | tabTextColorActiveLine: 'var(--white)', 36 | tabBorderColor: 'var(--blue)', 37 | barColor: 'var(--white)', 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/RecommendationIconWithTooltip.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /src/components/PrivacyRecommendations/Instructions.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/getCityCountrySocksProxy.ts: -------------------------------------------------------------------------------- 1 | import type { Country } from '@/helpers/socksProxy/socksProxies.types'; 2 | 3 | type Props = { 4 | socksProxies: Country[] | undefined; 5 | country: string; 6 | city?: string; 7 | }; 8 | 9 | const getCityCountrySocksProxy = ({ socksProxies, country, city }: Props) => { 10 | if (!socksProxies || !socksProxies.length) { 11 | throw new Error('No proxies to choose from'); 12 | } 13 | 14 | const theCountry = socksProxies.filter((c) => c.country === country)[0]; 15 | let proxies; 16 | if (!city) { 17 | const { cities } = theCountry; 18 | proxies = cities.flatMap((c) => c.proxyList); 19 | } else { 20 | const theCity = theCountry.cities.find((c) => c.city === city); 21 | const { proxyList } = theCity!; 22 | proxies = proxyList; 23 | } 24 | const index = Math.floor(Math.random() * proxies.length); 25 | const { hostname, ipv4_address, port, location } = proxies[index]; 26 | 27 | const randomSocks = { 28 | country: location.country, 29 | city: location.city, 30 | countryCode: location.countryCode, 31 | hostname, 32 | ipv4_address, 33 | port, 34 | }; 35 | 36 | return randomSocks; 37 | }; 38 | 39 | export default getCityCountrySocksProxy; 40 | -------------------------------------------------------------------------------- /src/components/Proxy/ProxyAutoReload.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 37 | -------------------------------------------------------------------------------- /src/composables/useRecommendationIconTooltip.ts: -------------------------------------------------------------------------------- 1 | import { computed, Ref } from 'vue'; 2 | import { Recommendation } from '@/composables/useRecommendations/Recommendation.types'; 3 | 4 | const useRecommendationIconTooltip = (recommendation: Ref) => { 5 | const tooltip = computed(() => { 6 | const tooltip = { text: 'Active', status: 'success' }; 7 | if (recommendation.value.ignored) { 8 | tooltip.text = 'Ignored'; 9 | tooltip.status = 'info'; 10 | } else if (recommendation.value.id === 'disable-webrtc') { 11 | tooltip.text = recommendation.value.activated ? 'WebRTC disabled' : 'WebRTC enabled'; 12 | } else if (recommendation.value.installed && !recommendation.value.enabled) { 13 | tooltip.text = 'Disabled'; 14 | tooltip.status = 'error'; 15 | } else if (!recommendation.value.activated && recommendation.value.type === 'extension') { 16 | tooltip.text = 'Not installed'; 17 | tooltip.status = 'error'; 18 | } else if (!recommendation.value.activated && recommendation.value.type === 'setting') { 19 | tooltip.text = 'Not set'; 20 | tooltip.status = 'error'; 21 | } 22 | return tooltip; 23 | }); 24 | 25 | return tooltip; 26 | }; 27 | 28 | export default useRecommendationIconTooltip; 29 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import vuePlugin from 'eslint-plugin-vue'; 2 | import ts from 'typescript-eslint'; 3 | import globals from 'globals'; 4 | import prettier from 'eslint-config-prettier'; 5 | import js from '@eslint/js'; 6 | 7 | export default [ 8 | js.configs.recommended, 9 | ...ts.configs.recommended, 10 | ...vuePlugin.configs['flat/recommended'], 11 | prettier, 12 | { 13 | languageOptions: { 14 | globals: { 15 | ...globals.browser, 16 | ...globals.node, 17 | ...globals.webextensions, 18 | }, 19 | ecmaVersion: 'latest', 20 | sourceType: 'module', 21 | }, 22 | }, 23 | { 24 | rules: { 25 | 'vue/attribute-hyphenation': 'off', 26 | 'vue/multi-word-component-names': 'off', 27 | 'vue/attributes-order': 'off', 28 | 'no-unused-vars': 'off', 29 | '@typescript-eslint/no-unused-vars': [ 30 | 'warn', 31 | { 32 | argsIgnorePattern: '^_', 33 | varsIgnorePattern: '^_', 34 | caughtErrorsIgnorePattern: '^_', 35 | }, 36 | ], 37 | }, 38 | }, 39 | { 40 | files: ['**/*.vue'], 41 | languageOptions: { 42 | parserOptions: { 43 | parser: ts.parser, 44 | }, 45 | }, 46 | }, 47 | { ignores: ['**/dist/'] }, 48 | ]; 49 | -------------------------------------------------------------------------------- /src/components/IconLabel.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | // generate stub index.html files for dev entry 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs-extra'; 4 | import chokidar from 'chokidar'; 5 | import { r, port, isDev, log } from './utils'; 6 | 7 | /** 8 | * Stub index.html to use Vite in development 9 | */ 10 | async function stubIndexHtml() { 11 | const views = ['background', 'options', 'popup']; 12 | 13 | for (const view of views) { 14 | await fs.ensureDir(r(`extension/dist/${view}`)); 15 | let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8'); 16 | data = data 17 | .replace('"./main.ts"', `"http://localhost:${port}/${view}/main.ts"`) 18 | .replace('
', '
Vite server did not start
'); 19 | await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8'); 20 | log('PRE', `stub ${view}`); 21 | } 22 | } 23 | 24 | function writeManifest() { 25 | execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' }); 26 | } 27 | 28 | writeManifest(); 29 | 30 | if (isDev) { 31 | stubIndexHtml(); 32 | chokidar.watch(r('src/**/*.html')).on('change', () => { 33 | stubIndexHtml(); 34 | }); 35 | chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => { 36 | writeManifest(); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Buttons/Button.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest'; 2 | 3 | import { mount } from '@vue/test-utils'; 4 | 5 | import Button from '@/components/Buttons/Button.vue'; 6 | 7 | describe('Button', () => { 8 | it('should render an a tag', () => { 9 | const wrapper = mount(Button, { 10 | props: { href: 'https://www.mullvad.net' }, 11 | slots: { default: 'Mullvad' }, 12 | }); 13 | const aTag = wrapper.find('a'); 14 | expect(aTag.exists()).toBe(true); 15 | expect(aTag.attributes('href')).toBe('https://www.mullvad.net'); 16 | 17 | expect(wrapper.element).toMatchSnapshot(); 18 | }); 19 | 20 | it('should render a button', () => { 21 | const wrapper = mount(Button, { 22 | slots: { default: 'Knapp' }, 23 | }); 24 | const button = wrapper.find('button'); 25 | expect(button.exists()).toBe(true); 26 | 27 | expect(wrapper.element).toMatchSnapshot(); 28 | }); 29 | 30 | it('should render a blue button', () => { 31 | const wrapper = mount(Button, { 32 | props: { color: 'blue' }, 33 | slots: { default: 'Knapp' }, 34 | }); 35 | const button = wrapper.find('button'); 36 | expect(button.exists()).toBe(true); 37 | expect(button.classes().includes('bg-blue')).toBe(true); 38 | 39 | expect(wrapper.element).toMatchSnapshot(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/Proxy/RandomProxyMode.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 37 | -------------------------------------------------------------------------------- /src/options/Options.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 42 | 43 | 51 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #app { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | :root { 9 | --blue: rgb(41 77 115 / 100%); 10 | --dark-blue: rgb(25 46 69 / 100%); 11 | --light-grey: rgb(162 189 218); 12 | --white: rgb(255 255 255 / 100%); 13 | --yellow: rgb(255 213 36 / 100%); 14 | --success: #44ad4d; 15 | --error: #e34039; 16 | } 17 | 18 | body { 19 | background-color: var(--dark-blue); 20 | color: var(--light-grey); 21 | font-size: 16px; 22 | } 23 | 24 | img { 25 | max-width: 100%; 26 | } 27 | 28 | header { 29 | background-color: var(--dark-blue); 30 | color: var(--light-grey); 31 | } 32 | 33 | input { 34 | background-color: var(--blue); 35 | color: var(--white); 36 | } 37 | 38 | em { 39 | color: rgb(157 227 255); 40 | } 41 | 42 | .text-success { 43 | color: var(--success); 44 | } 45 | 46 | .text-warning { 47 | color: var(--yellow); 48 | } 49 | 50 | .text-error { 51 | color: var(--error); 52 | } 53 | 54 | .btn { 55 | @apply px-4 py-1 rounded inline-block 56 | bg-teal-600 text-white cursor-pointer 57 | hover:bg-teal-700 58 | disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50; 59 | } 60 | 61 | .icon-btn { 62 | @apply inline-block cursor-pointer select-none 63 | opacity-75 transition duration-200 ease-in-out 64 | hover:opacity-100 hover:text-teal-600; 65 | 66 | font-size: 0.9em; 67 | } 68 | 69 | .n-drawer-mask { 70 | background-color: rgb(0 0 0 / 60%); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ProxyList.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest'; 2 | import { mount } from '@vue/test-utils'; 3 | import ProxyList from '@/components/ProxyList.vue'; 4 | import type { SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 5 | 6 | describe('ProxyList', () => { 7 | it('should render a single proxy correctly', () => { 8 | const wrapper = mount(ProxyList, { 9 | props: { 10 | cities: [{ city: 'Narnia', proxyList: [{ hostname: 'narnia-001' } as SocksProxy] }], 11 | onClickProxy: vi.fn(), 12 | }, 13 | }); 14 | 15 | const buttons = wrapper.findAll('button'); 16 | expect(buttons).toHaveLength(1); 17 | expect(wrapper.element).toMatchSnapshot(); 18 | }); 19 | 20 | it('should render multiple proxies correctly', () => { 21 | const onClickProxy = vi.fn(); 22 | const wrapper = mount(ProxyList, { 23 | props: { 24 | cities: [ 25 | { 26 | city: 'Narnia', 27 | proxyList: [{ hostname: 'narnia-002' }, { hostname: 'narnia-001' }] as SocksProxy[], 28 | }, 29 | ], 30 | onClickProxy, 31 | }, 32 | }); 33 | 34 | const buttons = wrapper.findAll('button'); 35 | expect(buttons).toHaveLength(2); 36 | expect(buttons[0].text()).toEqual('narnia-002'); 37 | expect(buttons[1].text()).toEqual('narnia-001'); 38 | 39 | buttons[0].trigger('click'); 40 | expect(onClickProxy).toHaveBeenCalledWith({ 41 | hostname: 'narnia-002', 42 | }); 43 | expect(wrapper.element).toMatchSnapshot(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/helpers/browserExtension.ts: -------------------------------------------------------------------------------- 1 | export enum Tab { 2 | SETTINGS = 'settings', 3 | PROXY = 'proxy', 4 | ABOUT = 'about', 5 | } 6 | 7 | export const { version } = browser.runtime.getManifest(); 8 | 9 | export const openPopup = () => { 10 | browser.browserAction.openPopup(); 11 | }; 12 | export const closePopup = () => { 13 | // The delay is added to stop a new empty browser window from opening 14 | // when installing the extension from the popup 15 | setTimeout(() => { 16 | window.close(); 17 | }, 100); 18 | }; 19 | 20 | export const openOptions = async (tab?: Tab) => { 21 | if (tab) { 22 | await browser.storage.local.set({ optionsActiveTab: tab }); 23 | } 24 | closePopup(); 25 | browser.runtime.openOptionsPage(); 26 | }; 27 | 28 | export const isPopupContext = () => { 29 | const views = browser.extension.getViews({ type: 'popup' }); 30 | return views.includes(window); 31 | }; 32 | 33 | const findTabIdByUrl = async (url: string): Promise => { 34 | const tabs = await browser.tabs.query({ url }); 35 | if (tabs.length > 0 && tabs[0].id !== undefined) { 36 | return tabs[0].id; 37 | } 38 | return undefined; 39 | }; 40 | 41 | export const reloadOptions = async () => { 42 | // Reload the options page if we're in the popup context 43 | if (isPopupContext()) { 44 | const optionsUrl = browser.runtime.getURL('dist/options/index.html'); 45 | const optionsTabID = await findTabIdByUrl(optionsUrl); 46 | 47 | if (optionsTabID !== undefined) { 48 | browser.tabs.reload(optionsTabID); 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/PrivacyRecommendations/WebRTCToggle.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /src/components/__snapshots__/CityButton.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`CityButton > should render a CityButton 1`] = ` 4 | 31 | `; 32 | -------------------------------------------------------------------------------- /src/components/OptionsTabs/ProxyTab.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 42 | -------------------------------------------------------------------------------- /src/popup/Popup.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 54 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/socksProxy.types.ts: -------------------------------------------------------------------------------- 1 | export enum ProxyInfoType { 2 | direct = 'direct', 3 | http = 'http', 4 | https = 'https', 5 | socks = 'socks', 6 | socks4 = 'socks4', 7 | } 8 | 9 | export type ProxyInfo = { 10 | type: ProxyInfoType; 11 | host: string; 12 | port: number; 13 | proxyDNS?: boolean; // only if type is 'socks' or 'socks4' 14 | 15 | /* 16 | This is the full type of ProxyInfo, 17 | but since we don't need it, then we can ignore it. 18 | Keeping it here for reference and to troubleshoot if needs be. 19 | 20 | username?: string; // only if type is 'socks' 21 | password?: string; // only if type is 'socks' 22 | failoverTimeout?: number; 23 | proxyAuthorizationHeader?: string; 24 | connectionIsolationKey?: string; // only if type is 'https' 25 | */ 26 | }; 27 | 28 | export type ProxyInfoMap = { 29 | [key: string]: ProxyInfo; 30 | }; 31 | 32 | export type ProxyDetails = { 33 | socksEnabled: boolean; 34 | country?: string; 35 | countryCode?: string; 36 | city?: string; 37 | server?: string; 38 | proxyDNS?: boolean; 39 | }; 40 | 41 | export type ProxyDetailsMap = { 42 | [key: string]: ProxyDetails; 43 | }; 44 | 45 | export interface ProxyOperationArgs { 46 | country: string; 47 | countryCode: string; 48 | city?: string; 49 | hostname: string; 50 | ipv4_address: string; 51 | port?: number; 52 | } 53 | 54 | export interface RequestDetails extends browser.proxy._OnRequestDetails { 55 | // frameAncestors is not documented but exists and can be reasonably relied on 56 | // See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/RequestDetails 57 | frameAncestors?: Array<{ frameId: number; url: string }>; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Cities.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | -------------------------------------------------------------------------------- /src/helpers/connCheck.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AmIMullvadServerResponse, 3 | Connection, 4 | Ipv4ServerResponse, 5 | } from '@/helpers/connCheck.types'; 6 | 7 | export const connCheckIpv4 = async (): Promise => { 8 | const controller = new AbortController(); 9 | const timeoutId = setTimeout(() => controller.abort(), 6000); 10 | 11 | try { 12 | const response = await fetch('https://ipv4.am.i.mullvad.net/json', { 13 | method: 'GET', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | signal: controller.signal, 18 | }); 19 | clearTimeout(timeoutId); 20 | const data: Ipv4ServerResponse = await response.json(); 21 | 22 | return { 23 | city: data.city, 24 | country: data.country, 25 | ip: data.ip, 26 | server: data.mullvad_exit_ip_hostname, 27 | protocol: data.mullvad_server_type, 28 | provider: data.organization, 29 | isMullvad: data.mullvad_exit_ip ?? false, 30 | }; 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | } catch (error) { 33 | throw new Error('IPv4 connection check failed.'); 34 | } 35 | }; 36 | 37 | export const connCheckIpv6 = async (): Promise => { 38 | try { 39 | const response = await fetch('https://ipv6.am.i.mullvad.net/json', { 40 | method: 'GET', 41 | headers: { 42 | 'Content-Type': 'application/json', 43 | }, 44 | }); 45 | const data: AmIMullvadServerResponse = await response.json(); 46 | return data.ip; 47 | } catch (e) { 48 | if (__DEV__) { 49 | console.log(`[conCheck IPv6]: Error trying to get ipv6 data: ${(e as Error).message}`); 50 | } 51 | return undefined; 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/OptionsTabs/AboutTab.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 54 | -------------------------------------------------------------------------------- /src/composables/useStore.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ProxyDetails, 3 | ProxyDetailsMap, 4 | ProxyInfo, 5 | ProxyInfoMap, 6 | } from '@/helpers/socksProxy/socksProxy.types'; 7 | import { Tab } from '@/helpers/browserExtension'; 8 | 9 | import useBrowserStorageLocal from '@/composables/useBrowserStorageLocal'; 10 | import { SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 11 | import { HistoryEntriesMap } from '@/composables/useProxyHistory/HistoryEntries.types'; 12 | 13 | const useStore = () => { 14 | const excludedHosts = useBrowserStorageLocal('excludedHosts', []); 15 | const flatProxiesList = useBrowserStorageLocal('flatProxiesList', []); 16 | const globalProxy = useBrowserStorageLocal('globalProxy', {} as ProxyInfo); 17 | const globalProxyDetails = useBrowserStorageLocal( 18 | 'globalProxyDetails', 19 | {} as ProxyDetails, 20 | ); 21 | const historyEntries = useBrowserStorageLocal('historyEntries', {}); 22 | const hostProxies = useBrowserStorageLocal('hostProxies', {}); 23 | const hostProxiesDetails = useBrowserStorageLocal('hostProxiesDetails', {}); 24 | const optionsActiveTab = useBrowserStorageLocal('optionsActiveTab', Tab.SETTINGS); 25 | const proxyAutoReload = useBrowserStorageLocal('proxyAutoReload', false); 26 | const randomProxyMode = useBrowserStorageLocal('randomProxyMode', false); 27 | const webRTCStatus = useBrowserStorageLocal('webRTCStatus', true); 28 | 29 | return { 30 | excludedHosts, 31 | flatProxiesList, 32 | globalProxy, 33 | globalProxyDetails, 34 | historyEntries, 35 | hostProxies, 36 | hostProxiesDetails, 37 | optionsActiveTab, 38 | proxyAutoReload, 39 | randomProxyMode, 40 | webRTCStatus, 41 | }; 42 | }; 43 | 44 | export default useStore; 45 | -------------------------------------------------------------------------------- /src/components/LocationTabs.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 44 | 45 | 52 | -------------------------------------------------------------------------------- /src/helpers/extensions.ts: -------------------------------------------------------------------------------- 1 | import { management } from 'webextension-polyfill'; 2 | 3 | import { isRecommended } from '@/composables/useRecommendations/defaultRecommendations'; 4 | import useRecommendations from '@/composables/useRecommendations/useRecommendations'; 5 | 6 | const { updateRecommendation } = useRecommendations(); 7 | 8 | type ExtensionInfo = browser.management.ExtensionInfo; 9 | 10 | export const addExtensionsListeners = () => { 11 | management.onInstalled.addListener(onInstall); 12 | management.onUninstalled.addListener(onUninstall); 13 | management.onEnabled.addListener(onEnable); 14 | management.onDisabled.addListener(onDisable); 15 | }; 16 | 17 | // Listeners 18 | const onInstall = (extensionInfo: ExtensionInfo) => { 19 | if (isRecommended(extensionInfo.id)) { 20 | updateRecommendation(extensionInfo.id, { 21 | activated: true, 22 | ctaLabel: undefined, 23 | enabled: true, 24 | ignored: false, 25 | installed: true, 26 | }); 27 | } 28 | }; 29 | 30 | const onUninstall = (extensionInfo: ExtensionInfo) => { 31 | if (isRecommended(extensionInfo.id)) { 32 | updateRecommendation(extensionInfo.id, { 33 | activated: false, 34 | ctaLabel: 'install', 35 | enabled: false, 36 | installed: false, 37 | }); 38 | } 39 | }; 40 | 41 | const onEnable = (extensionInfo: ExtensionInfo) => { 42 | if (isRecommended(extensionInfo.id)) { 43 | updateRecommendation(extensionInfo.id, { 44 | activated: true, 45 | ctaLabel: undefined, 46 | enabled: true, 47 | ignored: false, 48 | }); 49 | } 50 | }; 51 | 52 | const onDisable = (extensionInfo: ExtensionInfo) => { 53 | if (isRecommended(extensionInfo.id)) { 54 | updateRecommendation(extensionInfo.id, { 55 | activated: false, 56 | ctaLabel: 'enable', 57 | enabled: false, 58 | }); 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/ConnectionCheck/ConnectionCheck.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 53 | -------------------------------------------------------------------------------- /src/composables/useWarnings/useWarnings.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | 3 | import useCheckDnsLeaks from '@/composables/useConnection/useCheckDnsLeaks'; 4 | import useConnection from '@/composables/useConnection/useConnection'; 5 | import { warnings } from '@/composables/useWarnings/warnings'; 6 | import useWebRtc from '@/composables/useWebRtc'; 7 | 8 | const { connection } = useConnection(); 9 | const { isMullvadDoh, isMullvadDNS, isLoading, isError } = useCheckDnsLeaks(); 10 | const { webRTCLeaking } = useWebRtc(); 11 | 12 | const isMullvad = computed(() => connection.value.isMullvad); 13 | const isDNSCheckCompleted = computed(() => !isLoading.value && isError.value === false); 14 | const dohDisable = computed( 15 | () => isDNSCheckCompleted.value && isMullvad.value && isMullvadDoh.value, 16 | ); 17 | const dohEnable = computed( 18 | () => isDNSCheckCompleted.value && !isMullvad.value && !isMullvadDoh.value, 19 | ); 20 | const dohLeak = computed( 21 | () => isDNSCheckCompleted.value && isMullvadDoh.value && !isMullvadDNS.value, 22 | ); 23 | const dnsLeak = computed(() => isDNSCheckCompleted.value && isMullvad.value && !isMullvadDNS.value); 24 | 25 | const activeWarnings = computed(() => { 26 | const activeWarningsIds: string[] = []; 27 | 28 | if (dohDisable.value) { 29 | activeWarningsIds.push('doh-disable'); 30 | } 31 | if (dohEnable.value) { 32 | activeWarningsIds.push('doh-enable'); 33 | } 34 | if (dohLeak.value) { 35 | activeWarningsIds.push('doh-leak'); 36 | } 37 | if (dnsLeak.value) { 38 | activeWarningsIds.push('dns-leak'); 39 | } 40 | if (webRTCLeaking.value) { 41 | activeWarningsIds.push('webrtc-leak'); 42 | } 43 | 44 | return warnings.filter((warning) => activeWarningsIds.includes(warning.id)); 45 | }); 46 | 47 | const useWarnings = () => { 48 | return { 49 | activeWarnings, 50 | }; 51 | }; 52 | 53 | export default useWarnings; 54 | -------------------------------------------------------------------------------- /src/composables/useWebRtc.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, watch } from 'vue'; 2 | 3 | import useStore from './useStore'; 4 | 5 | const { webRTCStatus } = useStore(); 6 | 7 | const webRTCSupported = ref(true); 8 | const webRTCLeakedIPs = ref([] as string[]); 9 | const webRTCLeaking = computed(() => (webRTCLeakedIPs.value.length > 0 ? true : false)); 10 | 11 | const checkWebRTC = async () => { 12 | try { 13 | const { value } = await browser.privacy.network.peerConnectionEnabled.get({}); 14 | webRTCSupported.value = value as boolean; 15 | } catch (e) { 16 | console.log(e); 17 | } 18 | }; 19 | 20 | const checkRTCLeaks = async () => { 21 | await checkWebRTC(); 22 | // Clear the previous results 23 | webRTCLeakedIPs.value = []; 24 | 25 | if (webRTCSupported.value) { 26 | // Start the leak check 27 | const pc = new RTCPeerConnection(); 28 | const leakedHosts = new Set(); 29 | pc.onicecandidate = (e) => { 30 | if (e.candidate) { 31 | const host = e.candidate.candidate.split(' ')[4]; 32 | const isObfuscated = !host || host.endsWith('.local'); 33 | if (!isObfuscated) { 34 | leakedHosts.add(host); 35 | } 36 | } 37 | if (pc.iceGatheringState === 'complete') { 38 | // end of gathering 39 | webRTCLeakedIPs.value = [...leakedHosts]; 40 | } 41 | }; 42 | 43 | pc.createOffer({ offerToReceiveAudio: true }).then((offer) => pc.setLocalDescription(offer)); 44 | } 45 | }; 46 | 47 | const setWebRTC = (value: boolean) => { 48 | browser.privacy.network.peerConnectionEnabled.set({ value: value }); 49 | }; 50 | 51 | watch(webRTCStatus, () => { 52 | setWebRTC(webRTCStatus.value); 53 | }); 54 | 55 | const useWebRtc = () => { 56 | checkRTCLeaks(); 57 | 58 | return { 59 | setWebRTC, 60 | webRTCLeaking, 61 | webRTCLeakedIPs, 62 | webRTCStatus, 63 | }; 64 | }; 65 | 66 | export default useWebRtc; 67 | -------------------------------------------------------------------------------- /src/components/ConnectionCheck/ConnectionLocation.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest'; 2 | 3 | import { ref } from 'vue'; 4 | import { mount } from '@vue/test-utils'; 5 | import ConnectionLocation from '@/components/ConnectionCheck/ConnectionLocation.vue'; 6 | import { ConnectionKey } from '@/composables/useConnection/useConnection'; 7 | 8 | describe('ConnectionLocation.vue', function () { 9 | it('should render an Unknown location', () => { 10 | const wrapper = mount(ConnectionLocation, { 11 | global: { 12 | provide: { 13 | [ConnectionKey as symbol]: { 14 | connection: ref({}), 15 | }, 16 | }, 17 | }, 18 | }); 19 | expect(wrapper.text()).toMatch('Unknown location'); 20 | }); 21 | 22 | it('should render just a city', () => { 23 | const wrapper = mount(ConnectionLocation, { 24 | global: { 25 | provide: { 26 | [ConnectionKey as symbol]: { 27 | connection: ref({ city: 'Ulan Bator' }), 28 | }, 29 | }, 30 | }, 31 | }); 32 | expect(wrapper.text()).toMatch('Ulan Bator'); 33 | }); 34 | 35 | it('should render just a Country', () => { 36 | const wrapper = mount(ConnectionLocation, { 37 | global: { 38 | provide: { 39 | [ConnectionKey as symbol]: { 40 | connection: ref({ country: 'Mongolia' }), 41 | }, 42 | }, 43 | }, 44 | }); 45 | expect(wrapper.text()).toMatch('Mongolia'); 46 | }); 47 | 48 | it('should render both a City and a Country', () => { 49 | const wrapper = mount(ConnectionLocation, { 50 | global: { 51 | provide: { 52 | [ConnectionKey as symbol]: { 53 | connection: ref({ city: 'Ulan Bator', country: 'Mongolia' }), 54 | }, 55 | }, 56 | }, 57 | }); 58 | expect(wrapper.text()).toMatch('Ulan Bator, Mongolia'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import type { Manifest } from 'webextension-polyfill'; 3 | import type PkgType from '../package.json'; 4 | import { isDev, port, r } from '../scripts/utils'; 5 | 6 | export async function getManifest() { 7 | const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType; 8 | 9 | // update this file to update this manifest.json 10 | // can also be conditional based on your need 11 | const manifest: Manifest.WebExtensionManifest = { 12 | manifest_version: 2, 13 | name: pkg.displayName || pkg.name, 14 | version: pkg.version, 15 | description: pkg.description, 16 | browser_action: { 17 | default_icon: './assets/mullvad-logo.svg', 18 | default_popup: './dist/popup/index.html', 19 | default_area: 'navbar', 20 | }, 21 | options_ui: { 22 | page: './dist/options/index.html', 23 | open_in_tab: true, 24 | }, 25 | background: { 26 | page: './dist/background/index.html', 27 | persistent: true, 28 | }, 29 | icons: { 30 | '16': './assets/mullvad-logo.svg', 31 | '48': './assets/mullvad-logo.svg', 32 | '96': './assets/mullvad-logo.svg', 33 | }, 34 | permissions: ['management', 'privacy', 'search', 'storage', '*://*.mullvad.net/*'], 35 | optional_permissions: ['proxy', 'tabs', ''], 36 | browser_specific_settings: { 37 | gecko: { 38 | strict_min_version: '91.1.0', 39 | update_url: 'https://cdn.mullvad.net/browser-extension/updates.json', 40 | id: '{d19a89b9-76c1-4a61-bcd4-49e8de916403}', 41 | }, 42 | }, 43 | incognito: 'spanning', 44 | }; 45 | 46 | if (isDev) { 47 | // this is required on dev for Vite script to load 48 | // eslint-disable-next-line no-useless-escape 49 | manifest.content_security_policy = `script-src \'self\' http://localhost:${port}; object-src \'self\'`; 50 | } 51 | 52 | return manifest; 53 | } 54 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | import { dirname, relative } from 'path'; 3 | import { defineConfig, UserConfig } from 'vite'; 4 | import Vue from '@vitejs/plugin-vue'; 5 | import WindiCSS from 'vite-plugin-windicss'; 6 | import windiConfig from './windi.config'; 7 | import { r, port, isDev } from './scripts/utils'; 8 | 9 | export const sharedConfig: UserConfig = { 10 | root: r('src'), 11 | resolve: { 12 | alias: { 13 | '@/': `${r('src')}/`, 14 | }, 15 | }, 16 | define: { 17 | __DEV__: isDev, 18 | }, 19 | plugins: [ 20 | Vue(), 21 | 22 | // rewrite assets to use relative path 23 | { 24 | name: 'assets-rewrite', 25 | enforce: 'post', 26 | apply: 'build', 27 | transformIndexHtml(html, { path }) { 28 | return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`); 29 | }, 30 | }, 31 | ], 32 | optimizeDeps: { 33 | include: ['vue', '@vueuse/core', 'webextension-polyfill'], 34 | exclude: ['vue-demi'], 35 | }, 36 | }; 37 | 38 | export default defineConfig(({ command }) => ({ 39 | ...sharedConfig, 40 | base: command === 'serve' ? `http://localhost:${port}/` : '/dist/', 41 | server: { 42 | port, 43 | hmr: { 44 | host: 'localhost', 45 | }, 46 | cors: true, 47 | }, 48 | build: { 49 | outDir: r('extension/dist'), 50 | emptyOutDir: false, 51 | sourcemap: isDev ? 'inline' : false, 52 | // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements 53 | rollupOptions: { 54 | input: { 55 | background: r('src/background/index.html'), 56 | options: r('src/options/index.html'), 57 | popup: r('src/popup/index.html'), 58 | }, 59 | }, 60 | }, 61 | plugins: [ 62 | ...sharedConfig.plugins!, 63 | 64 | // https://github.com/antfu/vite-plugin-windicss 65 | WindiCSS({ 66 | config: windiConfig, 67 | }), 68 | ], 69 | })); 70 | -------------------------------------------------------------------------------- /src/composables/useConnection/useConnection.ts: -------------------------------------------------------------------------------- 1 | import { InjectionKey, Ref, ref } from 'vue'; 2 | import type { Connection } from '@/helpers/connCheck.types'; 3 | import { connCheckIpv4, connCheckIpv6 } from '@/helpers/connCheck'; 4 | 5 | // Keep this outside of the hook to make it a singleton 6 | const connection = ref({} as Connection); 7 | const isLoading = ref(false); 8 | const isError = ref(false); 9 | const error = ref(undefined); 10 | 11 | const useConnection = () => { 12 | const updateConnection = async () => { 13 | isLoading.value = true; 14 | isError.value = false; 15 | error.value = undefined; 16 | try { 17 | const ipv4Promise = connCheckIpv4(); 18 | const ipv6Promise = connCheckIpv6(); 19 | 20 | // Wait for IPv4 result 21 | connection.value = await ipv4Promise; 22 | 23 | // Because IPV6 is not reliably working in socks proxy, 24 | // don't wait IPv6 result so that it's non-blocking. 25 | ipv6Promise 26 | .then((ipv6) => { 27 | connection.value = { ...connection.value, ipv6 }; 28 | }) 29 | .catch((e) => { 30 | console.log(`IPv6 check error: ${e.message}`); 31 | }); 32 | } catch (e) { 33 | console.log({ useConnectionError: e }); 34 | isError.value = true; 35 | error.value = e as Error; 36 | } finally { 37 | isLoading.value = false; 38 | } 39 | }; 40 | 41 | // Don't run multiple checks at the same time 42 | if (!isLoading.value) { 43 | updateConnection(); 44 | } 45 | 46 | return { connection, error, isError, isLoading, updateConnection }; 47 | }; 48 | 49 | export default useConnection; 50 | 51 | export const ConnectionKey: InjectionKey<{ 52 | connection: Ref; 53 | isError: Ref; 54 | isLoading: Ref; 55 | }> = Symbol('Connection'); 56 | 57 | export const defaultConnection = { 58 | connection: ref({} as Connection), 59 | isLoading: ref(false), 60 | isError: ref(false), 61 | }; 62 | -------------------------------------------------------------------------------- /src/composables/useRecommendations/defaultRecommendations.ts: -------------------------------------------------------------------------------- 1 | import { Recommendation } from './Recommendation.types'; 2 | 3 | export const defaultExtensions: Recommendation[] = [ 4 | { 5 | type: 'extension', 6 | id: 'uBlock0@raymondhill.net', 7 | name: 'Install uBlock Origin', 8 | description: `uBlock Origin is not just a free and open-source “ad blocker“, it's a very efficient content blocker consuming minimal resources.`, 9 | homeUrl: 'https://github.com/gorhill/uBlock', 10 | icon: 'ubo64.png', 11 | enabled: false, 12 | installed: false, 13 | activated: false, 14 | ignored: false, 15 | ctaLabel: 'install', 16 | ctaUrl: 'https://addons.mozilla.org/firefox/addon/ublock-origin/', 17 | }, 18 | ]; 19 | 20 | export const defaultSettings: Recommendation[] = [ 21 | { 22 | type: 'setting', 23 | id: 'https-only-mode', 24 | name: 'Use HTTPS-only mode', 25 | description: 26 | 'Enabling HTTPS-only mode makes sure all of your connections are encrypted with HTTPS, and warn you if only HTTP unsafe mode is available.', 27 | homeUrl: 28 | 'https://support.mozilla.org/en-US/kb/https-only-prefs#w_enabledisable-https-only-mode', 29 | activated: false, 30 | ignored: false, 31 | ctaLabel: undefined, 32 | }, 33 | { 34 | type: 'setting', 35 | id: 'default-search', 36 | name: 'Use a privacy friendly search engine', 37 | description: 'We recommend using a privacy friendly search engine, like DuckDuckGo.', 38 | homeUrl: 39 | 'https://mullvad.net/en/blog/2021/2/24/dont-duck-the-issue-consider-your-privacy-and-search-engines/', 40 | activated: false, 41 | ignored: false, 42 | ctaLabel: undefined, 43 | }, 44 | ]; 45 | 46 | export const defaultRecommendations = [...defaultExtensions, ...defaultSettings]; 47 | 48 | export const isRecommended = (id: string) => { 49 | return defaultRecommendationsIds.includes(id); 50 | }; 51 | 52 | export const defaultRecommendationsIds = defaultRecommendations.map((rec) => rec.id); 53 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/getTargetHost.test.ts: -------------------------------------------------------------------------------- 1 | import { ProxyDetails } from '@/helpers/socksProxy/socksProxy.types'; 2 | import { getTargetHost } from '@/helpers/socksProxy/getTargetHost'; 3 | 4 | const proxyDetails: Record = { 5 | 'youtube.com': { 6 | socksEnabled: false, 7 | server: 'al-tia-wg-002', 8 | country: 'Albania', 9 | countryCode: 'al', 10 | city: 'Tirana', 11 | proxyDNS: true, 12 | }, 13 | 'mullvad.net': { 14 | socksEnabled: true, 15 | server: 'at-vie-wg-001', 16 | country: 'Austria', 17 | countryCode: 'at', 18 | city: 'Vienna', 19 | proxyDNS: true, 20 | }, 21 | 'torproject.org': { 22 | socksEnabled: true, 23 | server: 'br-sao-wg-201', 24 | country: 'Brazil', 25 | countryCode: 'br', 26 | city: 'Sao Paulo', 27 | proxyDNS: true, 28 | }, 29 | 'www.torproject.org': { 30 | socksEnabled: true, 31 | server: 'hr-zag-wg-001', 32 | country: 'Croatia', 33 | countryCode: 'hr', 34 | city: 'Zagreb', 35 | proxyDNS: true, 36 | }, 37 | }; 38 | 39 | describe('getTargetHost', () => { 40 | test('returns exact host when it exists in proxy details', () => { 41 | expect(getTargetHost('youtube.com', proxyDetails)).toBe('youtube.com'); 42 | expect(getTargetHost('mullvad.net', proxyDetails)).toBe('mullvad.net'); 43 | }); 44 | 45 | test('returns domain when subdomain exists and domain has proxy', () => { 46 | expect(getTargetHost('www.youtube.com', proxyDetails)).toBe('youtube.com'); 47 | expect(getTargetHost('sub.mullvad.net', proxyDetails)).toBe('mullvad.net'); 48 | }); 49 | 50 | test('returns exact subdomain host when it exists in proxy details', () => { 51 | expect(getTargetHost('www.torproject.org', proxyDetails)).toBe('www.torproject.org'); 52 | }); 53 | 54 | test('returns original host when no match found', () => { 55 | expect(getTargetHost('nonexistent.com', proxyDetails)).toBe('nonexistent.com'); 56 | expect(getTargetHost('test.nonexistent.com', proxyDetails)).toBe('test.nonexistent.com'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/composables/useProxyHistory/useProxyHistory.ts: -------------------------------------------------------------------------------- 1 | import { ref, watchEffect } from 'vue'; 2 | import useStore from '../useStore'; 3 | import type { 4 | HistoryEntry, 5 | HistoryEntryDetails, 6 | } from '@/composables/useProxyHistory/HistoryEntries.types'; 7 | 8 | const { historyEntries } = useStore(); 9 | 10 | const mostUsed = ref([]); 11 | const mostRecent = ref([]); 12 | 13 | const storeSocksProxyUsage = ({ 14 | country, 15 | countryCode, 16 | city, 17 | hostname, 18 | ipv4_address, 19 | }: HistoryEntryDetails) => { 20 | const keys: string[] = []; 21 | if (country) { 22 | keys.push(country); 23 | } 24 | if (city) { 25 | keys.push(city); 26 | } 27 | if (hostname) { 28 | keys.push(hostname); 29 | } 30 | if (ipv4_address) { 31 | keys.push(ipv4_address); 32 | } 33 | const key = keys.join(','); 34 | const data = historyEntries.value[key] ?? { count: 0 }; 35 | 36 | data.timestamp = Date.now(); 37 | data.count += 1; 38 | data.country = country; 39 | data.countryCode = countryCode; 40 | data.city = city; 41 | data.hostname = hostname; 42 | data.ipv4_address = ipv4_address; 43 | historyEntries.value[key] = data; 44 | }; 45 | 46 | const getLabel = (historyEntry: HistoryEntry) => { 47 | const { country, countryCode, city, hostname } = historyEntry; 48 | const servername = hostname.split('.relays.mullvad.net')[0]; 49 | 50 | return `${city ? city + `, ${countryCode.toUpperCase()}` : country} (${servername})`; 51 | }; 52 | 53 | const clearHistory = () => { 54 | historyEntries.value = {}; 55 | }; 56 | 57 | watchEffect(() => { 58 | mostUsed.value = Object.values(historyEntries.value).sort((a, b) => b.count - a.count); 59 | 60 | if (mostUsed.value) { 61 | mostRecent.value = [...mostUsed.value].sort((a, b) => b.timestamp - a.timestamp); 62 | } 63 | }); 64 | 65 | const useProxyHistory = () => { 66 | return { 67 | storeSocksProxyUsage, 68 | mostUsed, 69 | mostRecent, 70 | getLabel, 71 | clearHistory, 72 | }; 73 | }; 74 | 75 | export default useProxyHistory; 76 | -------------------------------------------------------------------------------- /src/components/Buttons/SplitButton.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 77 | -------------------------------------------------------------------------------- /src/helpers/domains.test.ts: -------------------------------------------------------------------------------- 1 | import { checkDomain, isValidDomain, normalizeToFQDN } from '@/helpers/domain'; 2 | 3 | describe('domain helpers', () => { 4 | describe('checkDomain', () => { 5 | it('should handle domain without subdomain', () => { 6 | const result = checkDomain('example.com'); 7 | expect(result).toEqual({ 8 | hasSubdomain: false, 9 | subDomain: 'example.com', 10 | domain: 'example.com', 11 | }); 12 | }); 13 | 14 | it('should handle domain with subdomain', () => { 15 | const result = checkDomain('www.torproject.org'); 16 | expect(result).toEqual({ 17 | hasSubdomain: true, 18 | subDomain: 'www.torproject.org', 19 | domain: 'torproject.org', 20 | }); 21 | }); 22 | }); 23 | 24 | describe('isValidDomain', () => { 25 | it('should return true for valid domains', () => { 26 | expect(isValidDomain('mullvad.net')).toBe(true); 27 | expect(isValidDomain('am.i.mullvad.net')).toBe(true); 28 | }); 29 | 30 | it('should return false for invalid domains', () => { 31 | expect(isValidDomain('not-a-domain')).toBe(false); 32 | expect(isValidDomain('not-a-domain.blabla')).toBe(false); 33 | expect(isValidDomain('')).toBe(false); 34 | }); 35 | 36 | it('should return true for special use domains', () => { 37 | expect(isValidDomain('home.arpa')).toBe(true); 38 | expect(isValidDomain('example.test')).toBe(true); 39 | expect(isValidDomain('server.localhost')).toBe(true); 40 | expect(isValidDomain('page.internal')).toBe(true); 41 | }); 42 | }); 43 | 44 | describe('normalizeToFQDN', () => { 45 | it('should return normalized FQDN for valid domains', () => { 46 | expect(normalizeToFQDN('https://example.com')).toBe('example.com'); 47 | expect(normalizeToFQDN('www.example.com')).toBe('www.example.com'); 48 | expect(normalizeToFQDN('sub.domain.example.com')).toBe('sub.domain.example.com'); 49 | }); 50 | 51 | it('should return null for invalid domains', () => { 52 | expect(normalizeToFQDN('')).toBeNull(); 53 | expect(normalizeToFQDN('invalid..domain')).toBeNull(); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/composables/useWarnings/warnings.ts: -------------------------------------------------------------------------------- 1 | import { Recommendation } from '../useRecommendations/Recommendation.types'; 2 | 3 | export const warnings: Recommendation[] = [ 4 | { 5 | id: 'doh-disable', 6 | type: 'warning', 7 | name: 'Disable Mullvad DoH (encrypted DNS)', 8 | description: `When you're connected to Mullvad VPN, it's better to use the DNS from the server for geolocation and performance reasons.`, 9 | ctaUrl: 'https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/#when-to-use', 10 | iconType: 'warning', 11 | activated: false, 12 | ignored: false, 13 | ctaLabel: undefined, 14 | }, 15 | { 16 | id: 'doh-enable', 17 | type: 'warning', 18 | name: 'Enable Mullvad DoH (encrypted DNS)', 19 | description: `When you're not connected to Mullvad VPN, it's better to encrypt your DNS requests with Mullvad DoH.`, 20 | ctaUrl: 'https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/#how-to-use', 21 | iconType: 'warning', 22 | activated: false, 23 | ignored: false, 24 | ctaLabel: undefined, 25 | }, 26 | { 27 | id: 'doh-leak', 28 | type: 'warning', 29 | name: 'DoH leaks have been detected', 30 | description: `Mullvad DoH (encrypted DNS) is set, but your browser still allows unencrypted DNS requests.`, 31 | ctaUrl: 'https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/#how-to-use', 32 | iconType: 'leak', 33 | activated: false, 34 | ignored: false, 35 | ctaLabel: undefined, 36 | }, 37 | { 38 | id: 'dns-leak', 39 | type: 'warning', 40 | name: 'DNS leaks have been detected', 41 | description: `You're connected to Mullvad VPN, but some DNS requests are leaking outside the VPN tunnel.`, 42 | ctaUrl: 'https://mullvad.net/en/help/dns-leaks/', 43 | iconType: 'leak', 44 | activated: false, 45 | ignored: false, 46 | ctaLabel: undefined, 47 | }, 48 | { 49 | id: 'webrtc-leak', 50 | type: 'warning', 51 | name: 'WebRTC leaks have been detected', 52 | description: `WebRTC is leaking some internal IPs.`, 53 | ctaUrl: '', 54 | iconType: 'leak', 55 | activated: false, 56 | ignored: false, 57 | ctaLabel: undefined, 58 | }, 59 | ]; 60 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/getRandomSessionProxy.ts: -------------------------------------------------------------------------------- 1 | import { addCountryCodeToProxy } from '@/helpers/socksProxy/addCountryCode'; 2 | import { baseConfig, socksIp } from '@/helpers/socksProxy/constants'; 3 | import { SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 4 | import { 5 | ProxyInfo, 6 | ProxyInfoMap, 7 | ProxyDetails, 8 | ProxyDetailsMap, 9 | ProxyInfoType, 10 | } from '@/helpers/socksProxy/socksProxy.types'; 11 | 12 | const domainProxyInfoMap: ProxyInfoMap = {}; 13 | export const domainProxyDetailsMap: ProxyDetailsMap = {}; 14 | 15 | export const browserStorage = { 16 | getLocal: async (key: string) => { 17 | return await browser.storage.local.get(key); 18 | }, 19 | }; 20 | 21 | export async function getRandomSessionProxy(domain: string) { 22 | try { 23 | const { flatProxiesList } = await browserStorage.getLocal('flatProxiesList'); 24 | const socksList: Array = JSON.parse(flatProxiesList); 25 | 26 | // Check if we've already assigned a proxy to this domain 27 | // Otherwise assign a random one 28 | if (!domainProxyInfoMap[domain]) { 29 | const randomIndex = Math.floor(Math.random() * socksList.length); 30 | const randomProxy = socksList[randomIndex]; 31 | 32 | const proxyInfo: ProxyInfo = { 33 | host: randomProxy.ipv4_address || socksIp, 34 | port: randomProxy.port, 35 | proxyDNS: true, 36 | type: ProxyInfoType.socks, 37 | }; 38 | domainProxyInfoMap[domain] = proxyInfo; 39 | 40 | const proxyDetails: ProxyDetails = { 41 | socksEnabled: true, 42 | server: randomProxy.hostname!.replace('socks5-', '')!.replace('.relays.mullvad.net', ''), 43 | country: randomProxy.location.country, 44 | countryCode: addCountryCodeToProxy(randomProxy).location.countryCode, 45 | city: randomProxy.location.city, 46 | proxyDNS: baseConfig.proxyDNS, 47 | }; 48 | domainProxyDetailsMap[domain] = proxyDetails; 49 | } 50 | 51 | return domainProxyInfoMap[domain]; 52 | } catch (e) { 53 | console.error(`Error determining proxy for ${domain}`, e); 54 | // Block all requests if something fails 55 | return { cancel: true }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/helpers/proxyListeners.ts: -------------------------------------------------------------------------------- 1 | import { getProxyPermissions } from '@/helpers/permissions'; 2 | import { updateCurrentTabProxyBadge, updateTabProxyBadge } from '@/helpers/proxyBadge'; 3 | import { handleProxyRequest } from '@/helpers/socksProxy/socksProxy'; 4 | import { getActiveProxyDetails } from '@/helpers/tabs'; 5 | 6 | export const initProxyListeners = () => { 7 | // Will init listeners on extension start if permissions are granted 8 | initListeners(); 9 | 10 | // Will monitor permissions changes and update proxy accordingly 11 | browser.permissions.onAdded.addListener(initListeners); 12 | browser.permissions.onRemoved.addListener(cleanListeners); 13 | }; 14 | 15 | const initListeners = async () => { 16 | const proxyPermissionsGranted = await getProxyPermissions(); 17 | 18 | if (proxyPermissionsGranted) { 19 | updateCurrentTabProxyBadge(); 20 | 21 | // Initialize tab listeners 22 | browser.tabs.onActivated.addListener(handleActivedTab); 23 | browser.tabs.onUpdated.addListener(handleUpdatedTab); 24 | 25 | // Initialize proxy request listener 26 | browser.proxy.onRequest.addListener(handleProxyRequest, { urls: [''] }); 27 | } 28 | }; 29 | 30 | const cleanListeners = async () => { 31 | const proxyPermissionsGranted = await getProxyPermissions(); 32 | 33 | if (!proxyPermissionsGranted) { 34 | // Clear the badge for all tabs 35 | // TODO Add a notification to warn the user the proxy has been deactivated 36 | const tabs = await browser.tabs.query({}); 37 | for (const tab of tabs) { 38 | if (tab.id !== undefined) { 39 | browser.browserAction.setBadgeText({ text: '', tabId: tab.id }); 40 | } 41 | } 42 | 43 | // Remove tab listeners 44 | browser.tabs.onActivated.removeListener(handleActivedTab); 45 | browser.tabs.onUpdated.removeListener(handleUpdatedTab); 46 | 47 | // Remove proxy request listener 48 | browser.proxy.onRequest.removeListener(handleProxyRequest); 49 | } 50 | }; 51 | 52 | const handleActivedTab = async (activeInfo: browser.tabs._OnActivatedActiveInfo) => { 53 | const tab = await browser.tabs.get(activeInfo.tabId); 54 | updateTabProxyBadge(tab, await getActiveProxyDetails()); 55 | }; 56 | 57 | const handleUpdatedTab = async ( 58 | _tabId: number, 59 | _changeInfo: browser.tabs._OnUpdatedChangeInfo, 60 | tab: browser.tabs.Tab, 61 | ) => { 62 | updateTabProxyBadge(tab, await getActiveProxyDetails()); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/MostUsedLocationButtons.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { mount } from '@vue/test-utils'; 4 | import MostUsedLocationButtons from '@/components/MostUsedLocationButtons.vue'; 5 | 6 | import useProxyHistory from '@/composables/useProxyHistory/useProxyHistory'; 7 | import Button from '@/components/Buttons/Button.vue'; 8 | 9 | vi.mock('@/composables/useProxyHistory/useProxyHistory', () => ({ 10 | default: vi.fn(), 11 | })); 12 | 13 | describe('MostUsedLocationButtons', () => { 14 | beforeEach(() => { 15 | vi.clearAllMocks(); 16 | }); 17 | 18 | it('should render one button', () => { 19 | vi.mocked(useProxyHistory).mockReturnValueOnce({ 20 | mostUsed: { 21 | value: [{ country: 'Argentina' }], 22 | }, 23 | mostRecent: { 24 | value: [{ country: 'Argentina' }], 25 | }, 26 | getLabel: ({ country }: { country: string }) => country, 27 | } as unknown as ReturnType); 28 | 29 | const wrapper = mount(MostUsedLocationButtons, { props: { selectLocation: vi.fn() } }); 30 | const buttons = wrapper.findAllComponents(Button); 31 | 32 | expect(buttons).toHaveLength(1); 33 | expect(buttons[0]?.text()).toMatch(/argentina/i); 34 | 35 | expect(wrapper.element).toMatchSnapshot(); 36 | }); 37 | 38 | it('should render three buttons', () => { 39 | vi.mocked(useProxyHistory).mockReturnValueOnce({ 40 | mostUsed: { 41 | value: [ 42 | { country: 'Sweden' }, 43 | { country: 'Norway' }, 44 | { country: 'Iceland' }, 45 | { country: 'Denmark' }, 46 | { country: 'France' }, 47 | { country: 'Spain' }, 48 | ], 49 | }, 50 | mostRecent: { 51 | value: [ 52 | { country: 'Sweden' }, 53 | { country: 'Norway' }, 54 | { country: 'Iceland' }, 55 | { country: 'Denmark' }, 56 | { country: 'France' }, 57 | { country: 'Spain' }, 58 | ], 59 | }, 60 | getLabel: ({ country }: { country: string }) => country, 61 | } as unknown as ReturnType); 62 | const wrapper = mount(MostUsedLocationButtons, { props: { selectLocation: vi.fn() } }); 63 | const buttons = wrapper.findAllComponents(Button); 64 | 65 | expect(buttons).toHaveLength(3); 66 | expect(buttons[0]?.text()).toMatch(/sweden/i); 67 | expect(buttons[1]?.text()).toMatch(/norway/i); 68 | expect(buttons[2]?.text()).toMatch(/iceland/i); 69 | 70 | expect(wrapper.element).toMatchSnapshot(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/composables/useSocksProxies.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue'; 2 | 3 | import useStore from '@/composables/useStore'; 4 | 5 | import { addCountryCode } from '@/helpers/socksProxy/addCountryCode'; 6 | import { groupByCountryAndCity } from '@/helpers/socksProxy/groupByCountryAndCity'; 7 | import { sortProxiesByCountryAndCity } from '@/helpers/socksProxy/sortProxiesByCountryAndCity'; 8 | import { SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 9 | 10 | const SOCKS_API_URL = 'https://api.mullvad.net/network/v1-beta1/socks-proxies'; 11 | const NETWORK_ERROR = `The proxy list couldn't be loaded. Please try again later.`; 12 | 13 | const { flatProxiesList } = useStore(); 14 | const query = ref(''); 15 | const isLoading = ref(false); 16 | const isError = ref(false); 17 | const error = ref(''); 18 | 19 | const clearFilter = () => { 20 | query.value = ''; 21 | }; 22 | 23 | const useSocksProxies = () => { 24 | const getSocksProxies = async () => { 25 | isLoading.value = true; 26 | isError.value = false; 27 | error.value = ''; 28 | 29 | try { 30 | const response = await fetch(SOCKS_API_URL); 31 | const data: SocksProxy[] = await response.json(); 32 | flatProxiesList.value = data.filter((proxy: SocksProxy) => { 33 | return proxy.online && proxy.ipv4_address && proxy.hostname; 34 | }); 35 | } catch (e: unknown) { 36 | isError.value = true; 37 | 38 | if (e instanceof Error) { 39 | if (e.message.includes('NetworkError')) { 40 | error.value = NETWORK_ERROR; 41 | } else { 42 | error.value = e.message; 43 | } 44 | } else { 45 | error.value = `An unknown error occurred: ${e}`; 46 | } 47 | console.log(e); 48 | } finally { 49 | isLoading.value = false; 50 | } 51 | }; 52 | 53 | const filteredData = computed(() => 54 | flatProxiesList.value.filter( 55 | (socksProxy) => 56 | !query.value || 57 | socksProxy.location.country?.toLowerCase().includes(query.value.toLowerCase()) || 58 | socksProxy.location.city?.toLowerCase().includes(query.value.toLowerCase()) || 59 | socksProxy.hostname?.toLowerCase().includes(query.value.toLowerCase()), 60 | ), 61 | ); 62 | 63 | const filteredProxies = computed(() => 64 | sortProxiesByCountryAndCity(groupByCountryAndCity(addCountryCode(filteredData.value))), 65 | ); 66 | 67 | if (!isLoading.value) { 68 | getSocksProxies(); 69 | } 70 | 71 | return { clearFilter, filteredProxies, getSocksProxies, query, isLoading, isError, error }; 72 | }; 73 | 74 | export default useSocksProxies; 75 | -------------------------------------------------------------------------------- /src/components/Buttons/Button.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 41 | 42 | 114 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to the Mullvad Browser Extension extension 2 | 3 | The Mullvad Browser Extension is open sourced for many reasons, but primarily 4 | 5 | - we believe the sharing of knowledge will advance the world more quickly and help it to become a 6 | better place 7 | - we want to allow users to verify that our extension functions as we claim it does, giving them the 8 | option to build it from source without having to trust our released extensions 9 | - we want to receive contributions from third parties. 10 | 11 | ## Submitting issues 12 | 13 | If you find a bug in the extension's code: 14 | 15 | - check first in the issue tracker if a similar bug hasn't already been reported 16 | - add it in the issue tracker. 17 | 18 | Please send all other problems or questions **not directly related to the extension's development** 19 | to [support@mullvad.net](mailto:support@mullvad.net). This includes connection issues, questions 20 | regarding your account, and problems with the Mullvad VPN infrastructure or servers. 21 | 22 | ## Submitting feature requests 23 | 24 | If you would like to suggest a feature: 25 | 26 | - check first in the issue tracker if a similar feature hasn't been already suggested 27 | - add it in the issue tracker. 28 | 29 | ## Submitting changes 30 | 31 | If you would like to contribute to the development of the Mullvad Browser Extension, please 32 | carefully read the following sections first and then feel free to submit a pull request. 33 | 34 | > While we appreciate your interest in helping us to improve Mullvad Browser Extension, please 35 | > understand that choosing which submitted changes to merge is fully at our discretion, based upon 36 | > our development plans for the extension. 37 | 38 | ### Process 39 | 40 | When you would like to contribute: 41 | 42 | - if you want to work on something already in the tracker, comment on the issue first 43 | - if what you want to work on is not in the tracker, file an issue with details. 44 | 45 | This is to verify no one else is already working on it, and to make sure we’re still interested in a 46 | given contribution. 47 | 48 | Once you receive our confirmation, you are welcome to open a pull request and start working on it. 49 | You may also want to comment and ask for help if you’re new or if you get stuck. We’re more than 50 | happy to help! When ready, we will review your pull request. 51 | 52 | ### Copyright and ownership of contributed code and changes 53 | 54 | Any code, binaries, tools, documentation, graphics, or any other material that you submit to this 55 | project will be licensed under GPL 3.0. 56 | 57 | Submitting to this project means that you are the original author of the entire contribution and 58 | grant us the full right to use, publish, change or remove the entire, or part of, your contribution 59 | under the terms defined by the GPL 3.0 license at any point in time. 60 | -------------------------------------------------------------------------------- /src/components/ConnectionCheck/ConnectionDetails.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 79 | -------------------------------------------------------------------------------- /src/helpers/tabs.ts: -------------------------------------------------------------------------------- 1 | import { checkDomain } from '@/helpers/domain'; 2 | import { ProxyDetails } from '@/helpers/socksProxy/socksProxy.types'; 3 | 4 | export const reloadMatchingTabs = async (url: string) => { 5 | const urlPattern = `*://*.${url}/*`; 6 | 7 | const tabs = await browser.tabs.query({ url: urlPattern }); 8 | tabs.forEach((tab) => { 9 | if (tab.id) { 10 | browser.tabs.reload(tab.id); 11 | } 12 | }); 13 | }; 14 | 15 | export const reloadGlobalProxiedTabs = async (excludedURLs: string[]) => { 16 | const tabs = await browser.tabs.query({ url: '*://*/*' }); 17 | 18 | const tabsToReload = tabs.filter((tab) => { 19 | if (!tab.url) { 20 | return false; 21 | } 22 | return !isExcludedURL(tab.url, excludedURLs); 23 | }); 24 | 25 | tabsToReload.forEach((tab) => { 26 | if (tab.id) { 27 | browser.tabs.reload(tab.id); 28 | } 29 | }); 30 | }; 31 | 32 | const isExcludedURL = (url: string, excludedURLs: string[]): boolean => { 33 | const { hostname } = new URL(url); 34 | return excludedURLs.some((excludedUrl) => hostname === excludedUrl); 35 | }; 36 | 37 | export const getActiveTabDetails = async () => { 38 | const activeTab = await getActiveTab(); 39 | 40 | // activeTab will be null if tabs permission has not been granted 41 | if (!activeTab?.url) { 42 | return { host: '', protocol: '', isAboutPage: false, isExtensionPage: false }; 43 | } 44 | 45 | const activeTabURL = new URL(activeTab.url); 46 | 47 | return { 48 | host: activeTabURL.hostname, 49 | protocol: activeTabURL.protocol, 50 | isAboutPage: activeTabURL.protocol === 'about:', 51 | isExtensionPage: activeTabURL.protocol === 'moz-extension:', 52 | }; 53 | }; 54 | 55 | export const getActiveProxyDetails = async () => { 56 | const globalProxyDetails = await getGlobalProxyDetails(); 57 | const { hostProxiesDetails } = await browser.storage.local.get('hostProxiesDetails'); 58 | 59 | if (hostProxiesDetails) { 60 | const hostProxiesDetailsParsed = JSON.parse(hostProxiesDetails); 61 | const activeTab = await getActiveTab(); 62 | const tabHost = new URL(activeTab.url!).hostname; 63 | const { domain } = checkDomain(tabHost); 64 | 65 | // Check subdomain proxy first 66 | if (hostProxiesDetailsParsed[tabHost]?.socksEnabled) { 67 | return hostProxiesDetailsParsed[tabHost]; 68 | } 69 | 70 | // Then check domain proxy 71 | if (hostProxiesDetailsParsed[domain]?.socksEnabled) { 72 | return hostProxiesDetailsParsed[domain]; 73 | } 74 | } 75 | 76 | return globalProxyDetails; 77 | }; 78 | 79 | const getGlobalProxyDetails = async (): Promise => { 80 | const response = await browser.storage.local.get('globalProxyDetails'); 81 | 82 | if ('globalProxyDetails' in response) { 83 | return JSON.parse(response.globalProxyDetails); 84 | } 85 | return { socksEnabled: false }; 86 | }; 87 | 88 | export const getActiveTab = async () => { 89 | const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true }); 90 | return activeTab; 91 | }; 92 | -------------------------------------------------------------------------------- /extension/assets/mullvad-logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 25 | 26 | -------------------------------------------------------------------------------- /src/components/Cities.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { NButton } from 'naive-ui'; 3 | import type { Location, SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 4 | import Cities from '@/components/Cities.vue'; 5 | 6 | describe('Cities', () => { 7 | it('should render a single city', () => { 8 | const setProxy = vi.fn(); 9 | const setRandomCountryOrCityProxy = vi.fn(); 10 | const proxy: SocksProxy = { 11 | hostname: 'na-001', 12 | location: { 13 | country: 'Narnia', 14 | city: 'Narnium', 15 | countryCode: 'na', 16 | } as Location, 17 | ipv4_address: '127.0.0.1', 18 | port: 1, 19 | } as SocksProxy; 20 | const { location, ...clickedProxy } = proxy; 21 | const expectedSetProxyProps = { 22 | ...clickedProxy, 23 | city: location.city, 24 | country: location.country, 25 | countryCode: location.countryCode, 26 | }; 27 | 28 | const wrapper = mount(Cities, { 29 | props: { 30 | cities: [{ city: 'Narnium', proxyList: [proxy] }], 31 | country: 'Narnia', 32 | setProxy, 33 | setRandomCountryOrCityProxy, 34 | }, 35 | }); 36 | 37 | const buttons = wrapper.findAllComponents(NButton); 38 | expect(buttons).toHaveLength(2); 39 | expect(buttons[0].text()).toEqual('Narnium'); 40 | expect(buttons[1].text()).toEqual('na-001'); 41 | 42 | buttons[0].trigger('click'); 43 | expect(setRandomCountryOrCityProxy).toHaveBeenCalledWith({ 44 | city: 'Narnium', 45 | country: 'Narnia', 46 | }); 47 | 48 | buttons[1].trigger('click'); 49 | expect(setProxy).toHaveBeenCalledWith(expectedSetProxyProps); 50 | }); 51 | 52 | it('should render multiple cities', () => { 53 | const setProxy = vi.fn(); 54 | const setRandomCountryOrCityProxy = vi.fn(); 55 | const proxy: SocksProxy = { 56 | hostname: 'na-001', 57 | location: { 58 | country: 'Narnia', 59 | city: 'Narnium', 60 | countryCode: 'na', 61 | } as Location, 62 | ipv4_address: '127.0.0.1', 63 | port: 1, 64 | } as SocksProxy; 65 | const proxy1: SocksProxy = { 66 | hostname: 'no-001', 67 | location: { 68 | country: 'Narnia', 69 | city: 'Nora', 70 | countryCode: 'na', 71 | } as Location, 72 | ipv4_address: '127.0.0.2', 73 | port: 2, 74 | } as SocksProxy; 75 | 76 | const wrapper = mount(Cities, { 77 | props: { 78 | cities: [ 79 | { city: 'Nora', proxyList: [proxy1] }, 80 | { city: 'Narnium', proxyList: [proxy] }, 81 | ], 82 | country: 'Narnia', 83 | setProxy, 84 | setRandomCountryOrCityProxy, 85 | }, 86 | }); 87 | 88 | const buttons = wrapper.findAllComponents(NButton); 89 | expect(buttons).toHaveLength(2); 90 | expect(buttons[0].text()).toEqual('Nora'); 91 | expect(buttons[1].text()).toEqual('Narnium'); 92 | 93 | buttons[1].trigger('click'); 94 | expect(setRandomCountryOrCityProxy).toHaveBeenCalledWith({ 95 | city: 'Narnium', 96 | country: 'Narnia', 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/components/PopupHeader.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 88 | -------------------------------------------------------------------------------- /src/components/Location.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 94 | 95 | 100 | -------------------------------------------------------------------------------- /src/composables/useConnection/useCheckDnsLeaks.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from 'vue'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | import unique from '@/helpers/unique'; 5 | import useConnection from '@/composables/useConnection/useConnection'; 6 | 7 | export type DnsServer = { 8 | hostname: string; 9 | ip: string; 10 | mullvad_dns: boolean; 11 | mullvad_dns_hostname: string; 12 | country: string; 13 | organization: string; 14 | }; 15 | 16 | const DNSLEAK_URL = 'dnsleak.am.i.mullvad.net'; 17 | 18 | const dnsLeakRequest = async () => { 19 | const uuid = uuidv4(); 20 | const response = await fetch(`https://${uuid}.${DNSLEAK_URL}`, { 21 | headers: { Accept: 'application/json' }, 22 | method: 'GET', 23 | }); 24 | if (!response.ok) { 25 | throw new Error('Network response was not ok'); 26 | } 27 | return await response.json(); 28 | }; 29 | 30 | const dnsServers = ref([] as DnsServer[]); 31 | const error = ref(); 32 | const isError = ref(false); 33 | const isLeaking = ref(false); 34 | const isLoading = ref(false); 35 | const isMullvadDNS = ref(false); 36 | const isMullvadDoh = ref(false); 37 | 38 | const { isError: isConnectionError } = useConnection(); 39 | 40 | const useCheckDnsLeaks = () => { 41 | // Watch for connection errors and abort DNS leak check if needed 42 | watch(isConnectionError, (newValue) => { 43 | if (newValue && isLoading.value) { 44 | // Connection error occurred during DNS leak check 45 | isLoading.value = false; 46 | isError.value = true; 47 | error.value = new Error('Connection error detected, aborting DNS leak check'); 48 | } 49 | }); 50 | 51 | const checkDnsLeaks = async () => { 52 | if (isLoading.value) { 53 | console.log('DNS leak check already in progress, skipping'); 54 | return; 55 | } 56 | 57 | dnsServers.value = []; 58 | error.value = undefined; 59 | isError.value = false; 60 | isLeaking.value = false; 61 | isLoading.value = true; 62 | isMullvadDNS.value = false; 63 | isMullvadDoh.value = false; 64 | try { 65 | // The returned value from Promise.all is here a list of lists, so add .flat() to make it a single level list 66 | const allDnsServers = (await Promise.all([...Array(6)].map(() => dnsLeakRequest()))).flat(); 67 | 68 | // Remove duplicates, based on DnsServer.ip 69 | const uniqueDnsServers = unique(allDnsServers, 'ip'); 70 | 71 | isLeaking.value = uniqueDnsServers.some((server) => !server.mullvad_dns); 72 | dnsServers.value = uniqueDnsServers; 73 | 74 | // If a DNS from the list is Mullvad && contains "dns", it means it's a DoH one 75 | // See: //mullvad.net/en/help/dns-over-https-and-dns-over-tls/ 76 | isMullvadDoh.value = uniqueDnsServers.some( 77 | (server) => server.mullvad_dns && server.mullvad_dns_hostname.includes('dns'), 78 | ); 79 | isMullvadDNS.value = uniqueDnsServers.some((server) => server.mullvad_dns); 80 | } catch (e) { 81 | // If the users is not connected to Mullvad, but using a Proxy we will end up here 82 | isError.value = true; 83 | error.value = e as Error; 84 | } finally { 85 | isLoading.value = false; 86 | } 87 | }; 88 | 89 | // Don't start multiple checks 90 | if (!isLoading.value) { 91 | checkDnsLeaks(); 92 | } 93 | 94 | return { 95 | checkDnsLeaks, 96 | dnsServers, 97 | error, 98 | isError, 99 | isLeaking, 100 | isLoading, 101 | isMullvadDoh, 102 | isMullvadDNS, 103 | }; 104 | }; 105 | 106 | export default useCheckDnsLeaks; 107 | -------------------------------------------------------------------------------- /src/components/PrivacyRecommendations/PrivacyRecommendation.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 102 | 103 | 108 | -------------------------------------------------------------------------------- /src/composables/useRecommendations/useRecommendations.ts: -------------------------------------------------------------------------------- 1 | import { computed, watch } from 'vue'; 2 | import { management } from 'webextension-polyfill'; 3 | 4 | import useBrowserStorageLocal from '@/composables/useBrowserStorageLocal'; 5 | import useHttpsOnly from '@/composables/useHttpsOnly'; 6 | import { Recommendation } from '@/composables/useRecommendations/Recommendation.types'; 7 | import { 8 | defaultRecommendations, 9 | defaultRecommendationsIds, 10 | } from '@/composables/useRecommendations/defaultRecommendations'; 11 | 12 | const recommendations = useBrowserStorageLocal( 13 | 'recommendations', 14 | defaultRecommendations, 15 | ); 16 | 17 | const updateRecommendation = (id: string, modification: Partial) => { 18 | recommendations.value = recommendations.value.map((recommendation) => 19 | recommendation.id === id ? { ...recommendation, ...modification } : recommendation, 20 | ); 21 | }; 22 | 23 | const cleanOutdated = (outdatedRecommendations: string[]) => { 24 | recommendations.value = recommendations.value.filter( 25 | (recommendation) => !outdatedRecommendations.includes(recommendation.name), 26 | ); 27 | }; 28 | 29 | const getRecommendationById = (id: string) => { 30 | return recommendations.value.find((rec) => rec.id === id); 31 | }; 32 | 33 | const updateExtensions = async () => { 34 | const installedAddons = (await management.getAll()).filter((extension) => 35 | defaultRecommendationsIds.includes(extension.id), 36 | ); 37 | 38 | const enabledExtensionIds = installedAddons 39 | .filter((addon) => addon.enabled) 40 | .map((addons) => addons.id); 41 | 42 | installedAddons.forEach((extension) => { 43 | const enabled = enabledExtensionIds.includes(extension.id); 44 | 45 | const partialUpdate: Partial = { 46 | ctaLabel: enabled ? undefined : 'enable', 47 | enabled, 48 | installed: true, 49 | activated: enabled, 50 | }; 51 | 52 | updateRecommendation(extension.id, partialUpdate); 53 | }); 54 | }; 55 | 56 | const updateHttpsOnly = async () => { 57 | const httpsOnly = await useHttpsOnly(); 58 | 59 | updateRecommendation('https-only-mode', { activated: httpsOnly }); 60 | }; 61 | 62 | const updateDefaultSearch = async () => { 63 | const searchEngines = await browser.search.get(); 64 | 65 | const defaultSearchEngine = searchEngines 66 | .filter((search) => search.isDefault === true)[0] 67 | .name.toLowerCase(); 68 | 69 | const isNotPrivacyFriendly = ['google', 'bing', 'yahoo', 'yandex', 'baidu', 'amazon'].some( 70 | (search) => defaultSearchEngine.includes(search), 71 | ); 72 | 73 | updateRecommendation('default-search', { activated: !isNotPrivacyFriendly }); 74 | }; 75 | 76 | const updateSettings = () => { 77 | updateHttpsOnly(); 78 | updateDefaultSearch(); 79 | }; 80 | 81 | // Update browser extensions recommendations 82 | const getCurrentUserRecommendations = () => { 83 | updateExtensions(); 84 | updateSettings(); 85 | }; 86 | 87 | // Once recommendations has been returned from localStorage, 88 | // we need to update to match what the user has installed 89 | const stop = watch(recommendations, () => { 90 | getCurrentUserRecommendations(); 91 | stop(); 92 | }); 93 | 94 | const useRecommendations = () => { 95 | const activeRecommendations = computed(() => 96 | recommendations.value.filter((rec) => !rec.activated && !rec.ignored), 97 | ); 98 | 99 | return { 100 | recommendations, 101 | activeRecommendations, 102 | updateRecommendation, 103 | getRecommendationById, 104 | cleanOutdated, 105 | }; 106 | }; 107 | 108 | export default useRecommendations; 109 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/getCityCountrySocksProxy.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest'; 2 | 3 | import getCityCountrySocksProxy from '@/helpers/socksProxy/getCityCountrySocksProxy'; 4 | import { Country } from '@/helpers/socksProxy/socksProxies.types'; 5 | 6 | const gothenburgProxies = [ 7 | { hostname: 'se3-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 8 | { hostname: 'se5-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 9 | { hostname: 'se9-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 10 | ]; 11 | 12 | const malmoProxies = [ 13 | { hostname: 'se1-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 14 | { hostname: 'se4-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 15 | ]; 16 | 17 | const stockholmProxies = [ 18 | { hostname: 'se2-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 19 | { hostname: 'se6-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 20 | { hostname: 'se7-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 21 | { hostname: 'se8-wg.socks5.mullvad.net', port: 1080, location: { countryCode: 'se' } }, 22 | ]; 23 | 24 | const mockSocksProxies = [ 25 | { 26 | country: 'Sweden', 27 | cities: [ 28 | { 29 | city: 'Gothenburg', 30 | proxyList: gothenburgProxies, 31 | }, 32 | { 33 | city: 'Malmo', 34 | proxyList: malmoProxies, 35 | }, 36 | { 37 | city: 'Stockholm', 38 | proxyList: stockholmProxies, 39 | }, 40 | ], 41 | }, 42 | ] as Country[]; 43 | 44 | describe('getCityCountrySocksProxy', () => { 45 | it('should throw error if no sockproxies list is available', () => { 46 | expect(() => 47 | getCityCountrySocksProxy({ socksProxies: undefined, country: 'Mongolia' }), 48 | ).toThrow('No proxies to choose from'); 49 | 50 | expect(() => getCityCountrySocksProxy({ socksProxies: [], country: 'Mongolia' })).toThrow( 51 | 'No proxies to choose from', 52 | ); 53 | }); 54 | 55 | it('should return a proxy in Sweden', () => { 56 | const { hostname, port } = getCityCountrySocksProxy({ 57 | socksProxies: mockSocksProxies, 58 | country: 'Sweden', 59 | }); 60 | const isSwedishProxy = [...gothenburgProxies, ...malmoProxies, ...stockholmProxies].find( 61 | (p) => p.hostname === hostname && p.port === port, 62 | ); 63 | expect(isSwedishProxy).toBeDefined(); 64 | }); 65 | 66 | it('should return a proxy in Gothenburg', () => { 67 | const { hostname, port } = getCityCountrySocksProxy({ 68 | socksProxies: mockSocksProxies, 69 | country: 'Sweden', 70 | city: 'Gothenburg', 71 | }); 72 | const isGothenburgProxy = gothenburgProxies.find( 73 | (p) => p.hostname === hostname && p.port === port, 74 | ); 75 | const isMalmoProxy = malmoProxies.find((p) => p.hostname === hostname && p.port === port); 76 | expect(isGothenburgProxy).toBeDefined(); 77 | expect(isMalmoProxy).toBeUndefined(); 78 | }); 79 | 80 | it('should return a proxy in Malmö', () => { 81 | const { hostname, port } = getCityCountrySocksProxy({ 82 | socksProxies: mockSocksProxies, 83 | country: 'Sweden', 84 | city: 'Malmo', 85 | }); 86 | const isMalmoProxy = malmoProxies.find((p) => p.hostname === hostname && p.port === port); 87 | expect(isMalmoProxy).toBeDefined(); 88 | }); 89 | 90 | it('should return a proxy in Stockholm', () => { 91 | const { hostname, port } = getCityCountrySocksProxy({ 92 | socksProxies: mockSocksProxies, 93 | country: 'Sweden', 94 | city: 'Stockholm', 95 | }); 96 | const isStockholmProxy = stockholmProxies.find( 97 | (p) => p.hostname === hostname && p.port === port, 98 | ); 99 | expect(isStockholmProxy).toBeDefined(); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/composables/useRecommendationIconTooltip.test.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { it, describe, expect } from 'vitest'; 3 | 4 | import useRecommendationIconTooltip from '@/composables/useRecommendationIconTooltip'; 5 | import { Recommendation } from '@/composables/useRecommendations/Recommendation.types'; 6 | 7 | describe('useRecommendationIconTooltip', () => { 8 | describe('Extensions', () => { 9 | it('should handle default extension', () => { 10 | const testee = ref({ 11 | enabled: false, 12 | installed: false, 13 | activated: false, 14 | ignored: false, 15 | type: 'extension', 16 | } as Recommendation); 17 | const { text, status } = useRecommendationIconTooltip(testee).value; 18 | expect(text).toMatch(/not installed/i); 19 | expect(status).toMatch(/error/); 20 | }); 21 | 22 | it('should handle not-installed, disabled, ignored extension', () => { 23 | const testee = ref({ 24 | enabled: false, 25 | installed: false, 26 | activated: false, 27 | ignored: true, 28 | type: 'extension', 29 | } as Recommendation); 30 | const { text, status } = useRecommendationIconTooltip(testee).value; 31 | expect(text).toMatch(/ignored/i); 32 | expect(status).toMatch(/info/); 33 | }); 34 | 35 | it('should handle installed, disabled, ignored extension', () => { 36 | const testee = ref({ 37 | enabled: false, 38 | installed: true, 39 | activated: false, 40 | ignored: true, 41 | type: 'extension', 42 | } as Recommendation); 43 | const { text, status } = useRecommendationIconTooltip(testee).value; 44 | expect(text).toMatch(/ignored/i); 45 | expect(status).toMatch(/info/); 46 | }); 47 | 48 | it('should handle installed, disabled extension', () => { 49 | const testee = ref({ 50 | enabled: false, 51 | installed: true, 52 | activated: false, 53 | ignored: false, 54 | type: 'extension', 55 | } as Recommendation); 56 | const { text, status } = useRecommendationIconTooltip(testee).value; 57 | expect(text).toMatch(/disabled/i); 58 | expect(status).toMatch(/error/); 59 | }); 60 | 61 | it('should handle installed, enabled extension', () => { 62 | const testee = ref({ 63 | enabled: true, 64 | installed: true, 65 | activated: true, 66 | ignored: false, 67 | type: 'extension', 68 | } as Recommendation); 69 | const { text, status } = useRecommendationIconTooltip(testee).value; 70 | expect(text).toMatch(/active/i); 71 | expect(status).toMatch(/success/); 72 | }); 73 | }); 74 | 75 | describe('settings', () => { 76 | it('should handle default setting', () => { 77 | const testee = ref({ 78 | activated: false, 79 | ignored: false, 80 | type: 'setting', 81 | } as unknown as Recommendation); 82 | const { text, status } = useRecommendationIconTooltip(testee).value; 83 | expect(text).toMatch(/not set/i); 84 | expect(status).toMatch(/error/); 85 | }); 86 | 87 | it('should handle activated setting', () => { 88 | const testee = ref({ 89 | activated: true, 90 | ignored: false, 91 | type: 'setting', 92 | } as unknown as Recommendation); 93 | const { text, status } = useRecommendationIconTooltip(testee).value; 94 | expect(text).toMatch(/active/i); 95 | expect(status).toMatch(/success/); 96 | }); 97 | 98 | it('should handle ignored setting', () => { 99 | const testee = ref({ 100 | activated: false, 101 | ignored: true, 102 | type: 'setting', 103 | } as unknown as Recommendation); 104 | const { text, status } = useRecommendationIconTooltip(testee).value; 105 | expect(text).toMatch(/ignored/i); 106 | expect(status).toMatch(/info/); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/components/Countries.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest'; 2 | import { mount } from '@vue/test-utils'; 3 | import Countries from '@/components/Countries.vue'; 4 | import { NButton } from 'naive-ui'; 5 | import type { Location, SocksProxy } from '@/helpers/socksProxy/socksProxies.types'; 6 | 7 | describe('Countries', () => { 8 | it('should render a Countries list', () => { 9 | const setProxy = vi.fn(); 10 | const setRandomCountryOrCityProxy = vi.fn(); 11 | 12 | const wrapper = mount(Countries, { 13 | props: { 14 | setProxy, 15 | setRandomCountryOrCityProxy, 16 | countries: [ 17 | { country: 'Narnia', cities: [] }, 18 | { country: 'Mordor', cities: [] }, 19 | ], 20 | }, 21 | }); 22 | 23 | const buttons = wrapper.findAllComponents(NButton); 24 | expect(buttons).toHaveLength(2); 25 | expect(buttons[0].text()).toBe('Narnia'); 26 | expect(buttons[1].text()).toBe('Mordor'); 27 | buttons[0].trigger('click'); 28 | expect(setRandomCountryOrCityProxy).toHaveBeenCalledWith({ country: 'Narnia' }); 29 | buttons[1].trigger('click'); 30 | expect(setRandomCountryOrCityProxy).toHaveBeenCalledWith({ country: 'Mordor' }); 31 | expect(setProxy).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('should render a country with cities', async () => { 35 | const setProxy = vi.fn(); 36 | const setRandomCountryOrCityProxy = vi.fn(); 37 | 38 | const wrapper = mount(Countries, { 39 | props: { 40 | setProxy, 41 | setRandomCountryOrCityProxy, 42 | countries: [ 43 | { 44 | country: 'Narnia', 45 | cities: [ 46 | { 47 | city: 'Narnium', 48 | proxyList: [ 49 | { 50 | hostname: 'na-001', 51 | location: { 52 | country: 'Narnia', 53 | city: 'Narnium', 54 | countryCode: 'na', 55 | } as Location, 56 | ipv4_address: '127.0.0.1', 57 | port: 1, 58 | }, 59 | ] as SocksProxy[], 60 | }, 61 | { 62 | city: 'Nora', 63 | proxyList: [ 64 | { 65 | hostname: 'no-001', 66 | location: { 67 | country: 'Narnia', 68 | city: 'Nora', 69 | countryCode: 'na', 70 | } as Location, 71 | ipv4_address: '127.0.0.2', 72 | port: 2, 73 | } as SocksProxy, 74 | ], 75 | }, 76 | ], 77 | }, 78 | ], 79 | }, 80 | }); 81 | 82 | let buttons = wrapper.findAllComponents(NButton); 83 | expect(buttons).toHaveLength(1); 84 | expect(buttons[0].text()).toBe('Narnia'); 85 | 86 | let arrows = wrapper.findAll('.n-collapse-item-arrow'); 87 | expect(arrows).toHaveLength(1); 88 | arrows[0].trigger('click'); 89 | await wrapper.vm.$nextTick(); 90 | 91 | buttons = wrapper.findAllComponents(NButton); 92 | expect(buttons).toHaveLength(3); 93 | expect(buttons[0].text()).toBe('Narnia'); 94 | expect(buttons[1].text()).toBe('Narnium'); 95 | expect(buttons[2].text()).toBe('Nora'); 96 | 97 | buttons[1].trigger('click'); 98 | expect(setRandomCountryOrCityProxy).toHaveBeenCalled(); 99 | 100 | arrows = wrapper.findAll('.n-collapse-item-arrow'); 101 | expect(arrows).toHaveLength(3); 102 | 103 | arrows[2].trigger('click'); 104 | await wrapper.vm.$nextTick(); 105 | buttons = wrapper.findAllComponents(NButton); 106 | expect(buttons).toHaveLength(5); 107 | expect(buttons[0].text()).toBe('Narnia'); 108 | expect(buttons[1].text()).toBe('Narnium'); 109 | expect(buttons[2].text()).toBe('na-001'); 110 | expect(buttons[3].text()).toBe('Nora'); 111 | 112 | buttons[2].trigger('click'); 113 | expect(setProxy).toHaveBeenCalled(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/components/NotificationsCarousel.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 84 | 85 | 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ci](https://github.com/mullvad/browser-extension/actions/workflows/ci.yml/badge.svg) 2 | 3 | # Mullvad Browser Extension 4 | 5 | Mullvad Browser Extension is a Firefox extension improving your browser experience while using 6 | Mullvad VPN. It also displays information about the connection, recommends optimal DNS settings, and 7 | a one-click access to [proxy servers](https://mullvad.net/en/help/socks5-proxy/). 8 | 9 | ## Download 10 | 11 | You can visit our [download page](https://mullvad.net/en/download/browser/extension) to get the 12 | latest release. 13 | 14 | The extension is also available here on Github in the 15 | [Releases](https://github.com/mullvad/browser-extension/releases). 16 | 17 | ## Development 18 | 19 | ### **Environment** 20 | 21 | Build with: 22 | 23 | - Node 24 LTS 24 | - Npm 11 25 | 26 | _If you use `nvm`, run `nvm use` to automatically set these versions._ 27 | 28 | For: 29 | 30 | - Firefox: last version (>91.1.0) 31 | 32 | ### **Developing** 33 | 34 | The first time, use `npm install` to install the necessary packages. 35 | 36 | To start the extension in a a temporary and clean browser: 37 | 38 | - use `npm run dev` to automatically rebuild the extension when changes are saved. 39 | - use `npm start` in another terminal to start a development instance of Firefox with the extension 40 | loaded. The extension will automatically reload when changes are saved. 41 | 42 | The developer tools can be started by clicking on the `inspect` in the debugging tab (automatically 43 | opened). 44 | 45 | ### **Building** 46 | 47 | - use `npm run build` to build the extension **first** 48 | - use `npm run pack:xpi` to create `.xpi` file in the root folder 49 | 50 | _There are other build options which you can view in `package.json`._ 51 | 52 | ### **Testing the extension in your browser** 53 | 54 | You can only install the extension temporarily when it is not signed by Mozilla. To do so: 55 | 56 | - open Firefox 57 | - enter "about:debugging#/runtime/this-firefox" in the URL bar 58 | - click "Load Temporary Add-on" 59 | - open `extension.xpi` file. 60 | 61 | The extension will automatically unload when Firefox is closed. 62 | 63 | ### **Testing restart and persisting features** 64 | 65 | You can use the `restart` script to test restart and persisting features (like settings saved to 66 | local storage). It will require some manual configuration: 67 | 68 | - go to `about:profiles` and create a new Firefox profile 69 | - go to `package.json` and change the `restart` script with your own Firefox profile path 70 | - go to `about:config` and set both `extensions.webextensions.keepStorageOnUninstall` and 71 | `extensions.webextensions.keepUuidOnUninstall` to `true`. 72 | 73 | [Learn more](https://extensionworkshop.com/documentation/develop/testing-persistent-and-restart-features/) 74 | 75 | ## Permissions 76 | 77 | Mullvad Browser Extension requires the following permissions: 78 | 79 | - `management` to be able to recommend third party extensions 80 | - `privacy` to disable webRTC and check HTTPS-Only status 81 | - `storage` to save preferences 82 | - `search` to recommend other search engines 83 | - `*://*.mullvad.net/*` to get proxy servers list and display your connection information (See 84 | `Network requests` for details) 85 | 86 | The following permissions are optional, but are needed to use the proxy feature: 87 | 88 | - `proxy` to configure and use Mullvad proxy servers 89 | - `tabs` to show proxy settings from active tab 90 | - `` to specify a proxy configuration per domain (each request needs to be intercepted) 91 | 92 | _Permissions are automatically accepted when testing the extension._ 93 | 94 | ## Network requests 95 | 96 | Two external network requests are made by the extension: 97 | 98 | - `api.mullvad.net` to get the lastest proxy servers (Frequency: each time the 99 | `Select proxy server location` drawer is opened) 100 | - `am.i.mullvad.net` to get the connection information (Frequency: each time the popup is started 101 | and each time the proxy is connected/disconnected) 102 | 103 | _External links are marked with this icon_ 104 | ![External Link icon](https://github.com/feathericon/feathericon/blob/master/src/svg/link-external.svg) 105 | 106 | ## Source code 107 | 108 | Source code is available in the [Github repo](https://github.com/mullvad/browser-extension). 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mullvad-browser-extension", 3 | "displayName": "Mullvad Browser Extension", 4 | "version": "0.9.7", 5 | "description": "Improve your Mullvad VPN experience, in your browser.", 6 | "private": true, 7 | "type": "module", 8 | "engines": { 9 | "node": ">=24.11.0", 10 | "npm": ">=11.6.1" 11 | }, 12 | "scripts": { 13 | "dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*", 14 | "dev:prepare": "esno scripts/prepare.ts", 15 | "dev:web": "vite", 16 | "build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:copy", 17 | "build:prepare": "esno scripts/prepare.ts", 18 | "build:copy": "esno scripts/copyFilesToExtensionFolder.ts", 19 | "build:web": "vite build", 20 | "pack": "cross-env NODE_ENV=production run-p pack:*", 21 | "pack:crx": "crx pack extension -o ./mullvad-browser-extension-$npm_package_version.crx", 22 | "pack:zip": "rimraf mullvad-browser-extension-$npm_package_version.zip && jszip-cli add extension -o ./mullvad-browser-extension-$npm_package_version.zip", 23 | "pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename mullvad-browser-extension-$npm_package_version.xpi --overwrite-dest", 24 | "start:chromium": "web-ext run --source-dir ./extension --target=chromium", 25 | "start": "web-ext run --source-dir ./extension --target=firefox-desktop --start-url=about:debugging#/runtime/this-firefox --start-url=mullvad.net/check", 26 | "start:mb": "web-ext run --source-dir ./extension --firefox ~/apps/mullvad-browser/Browser/mullvadbrowser --start-url=about:debugging#/runtime/this-firefox --start-url=mullvad.net/check", 27 | "start:firefox": "web-ext run --source-dir ./extension --firefox ~/apps/firefox/firefox --start-url=about:debugging#/runtime/this-firefox", 28 | "restart": "web-ext run --source-dir ./extension --target=firefox-desktop --firefox-profile ~/.mozilla/firefox/9eernfyp.browser-extension-testing --keep-profile-changes --start-url=about:debugging#/runtime/this-firefox --start-url=mullvad.net/check", 29 | "clear": "rimraf extension/dist extension/manifest.json extension/README.md extension/LICENSE.md mullvad-browser-extension* key.pem", 30 | "lint": "prettier --check . && npm run eslint . && npm run lint:style", 31 | "eslint": "eslint \"**/*.{ts,js,vue}\"", 32 | "eslint:fix": "npm run eslint -- --fix", 33 | "lint:style": "stylelint \"src/**/*.{css,scss,vue}\"", 34 | "lint:style:fix": "npm run lint:style -- --fix", 35 | "format": "prettier --write .", 36 | "tsc": "vue-tsc --noEmit", 37 | "test": "vitest run", 38 | "test:ui": "vitest --ui", 39 | "test:watch": "vitest test", 40 | "test:coverage": "vitest run --coverage", 41 | "prepare": "husky" 42 | }, 43 | "devDependencies": { 44 | "@eslint/eslintrc": "^3.3.1", 45 | "@eslint/js": "^9.39.1", 46 | "@ffflorian/jszip-cli": "^3.9.1", 47 | "@types/firefox-webext-browser": "^143.0.0", 48 | "@types/fs-extra": "^11.0.4", 49 | "@types/node": "^24.10.0", 50 | "@types/uuid": "^11.0.0", 51 | "@types/webextension-polyfill": "^0.12.4", 52 | "@vitejs/plugin-vue": "^6.0.1", 53 | "@vitest/coverage-v8": "^4.0.15", 54 | "@vitest/ui": "^4.0.15", 55 | "@vue/compiler-sfc": "^3.5.24", 56 | "@vue/test-utils": "^2.4.6", 57 | "@vueuse/core": "^14.0.0", 58 | "chokidar": "^4.0.3", 59 | "cross-env": "^10.1.0", 60 | "crx": "^5.0.1", 61 | "eslint": "^9.39.1", 62 | "eslint-config-prettier": "^10.1.8", 63 | "eslint-plugin-unused-imports": "^4.3.0", 64 | "eslint-plugin-vue": "^9.32.0", 65 | "esno": "^4.8.0", 66 | "fs-extra": "^11.3.2", 67 | "globals": "^16.5.0", 68 | "happy-dom": "^20.0.11", 69 | "husky": "^9.1.7", 70 | "kolorist": "^1.8.0", 71 | "lint-staged": "^16.2.6", 72 | "npm-run-all": "^4.1.5", 73 | "postcss-html": "^1.8.0", 74 | "prettier": "^3.6.2", 75 | "rimraf": "^6.1.0", 76 | "stylelint": "^16.23.0", 77 | "stylelint-config-recommended": "^17.0.0", 78 | "stylelint-config-recommended-vue": "^1.6.1", 79 | "stylelint-config-standard": "^39.0.1", 80 | "typescript": "^5.9.3", 81 | "typescript-eslint": "^8.50.1", 82 | "vite": "^7.2.2", 83 | "vite-plugin-windicss": "^1.9.4", 84 | "vitest": "^4.0.15", 85 | "vue": "^3.5.24", 86 | "vue-tsc": "^3.1.3", 87 | "web-ext": "^9.1.0", 88 | "webext-bridge": "^6.0.1", 89 | "webextension-polyfill": "^0.12.0" 90 | }, 91 | "dependencies": { 92 | "ipaddr.js": "^2.2.0", 93 | "naive-ui": "^2.43.1", 94 | "tldts": "^7.0.17", 95 | "uuid": "^13.0.0", 96 | "vue-query": "^1.26.0" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/helpers/socksProxy/socksProxy.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest'; 2 | 3 | import { isExtConnCheck, isLocalOrReservedIP } from '@/helpers/socksProxy/socksProxy'; 4 | import { RequestDetails } from './socksProxy.types'; 5 | 6 | vi.mock('@/helpers/getRandomSessionProxy', () => ({ 7 | browserStorage: { 8 | getLocal: vi.fn().mockResolvedValue({}), 9 | }, 10 | })); 11 | 12 | describe('isLocalOrReservedIP', () => { 13 | it('should return true for localhost', () => { 14 | expect(isLocalOrReservedIP('localhost:8080')).toBeTruthy(); 15 | }); 16 | 17 | it('should return true for private IP', () => { 18 | expect(isLocalOrReservedIP('192.168.1.1')).toBeTruthy(); 19 | }); 20 | 21 | it('should return true for loopback IP', () => { 22 | expect(isLocalOrReservedIP('127.0.0.1')).toBeTruthy(); 23 | expect(isLocalOrReservedIP('::1')).toBeTruthy(); 24 | }); 25 | 26 | it('should return false for public IP', () => { 27 | expect(isLocalOrReservedIP('8.8.8.8')).toBeFalsy(); 28 | }); 29 | 30 | it('should return false for invalid IP', () => { 31 | expect(isLocalOrReservedIP('invalid.ip')).toBeFalsy(); 32 | }); 33 | 34 | it('should return true for unique local addresses', () => { 35 | expect(isLocalOrReservedIP('fc00::')).toBeTruthy(); 36 | }); 37 | 38 | it('should return true for multicast addresses', () => { 39 | expect(isLocalOrReservedIP('ff00::')).toBeTruthy(); 40 | }); 41 | 42 | it('should return false when IP address is not provided', () => { 43 | expect(isLocalOrReservedIP('')).toBeFalsy(); 44 | }); 45 | }); 46 | 47 | describe('isExtConnCheck', () => { 48 | const baseDetails: RequestDetails = { 49 | requestId: '5979', 50 | url: '', 51 | method: 'GET', 52 | type: 'xmlhttprequest', 53 | fromCache: false, 54 | incognito: false, 55 | thirdParty: false, 56 | originUrl: '', 57 | documentUrl: '', 58 | frameId: 0, 59 | parentFrameId: -1, 60 | frameAncestors: [], 61 | timeStamp: 1740215207080, 62 | tabId: 4, 63 | cookieStoreId: 'firefox-default', 64 | urlClassification: { 65 | firstParty: [], 66 | thirdParty: [], 67 | }, 68 | }; 69 | 70 | it('should return true for extension IPv4 connection check', () => { 71 | const details: RequestDetails = { 72 | ...baseDetails, 73 | url: 'https://ipv4.am.i.mullvad.net/json', 74 | originUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 75 | documentUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 76 | }; 77 | expect(isExtConnCheck(details)).toBeTruthy(); 78 | }); 79 | 80 | it('should return true for extension IPv6 connection check', () => { 81 | const details: RequestDetails = { 82 | ...baseDetails, 83 | url: 'https://ipv6.am.i.mullvad.net/json', 84 | originUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 85 | documentUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 86 | }; 87 | expect(isExtConnCheck(details)).toBeTruthy(); 88 | }); 89 | 90 | it('should return true for extension DNS check', () => { 91 | const details: RequestDetails = { 92 | ...baseDetails, 93 | url: 'https://c30d3da2-fc4f-4732-86cc-f233e1692eac.dnsleak.am.i.mullvad.net/', 94 | originUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 95 | documentUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 96 | }; 97 | expect(isExtConnCheck(details)).toBeTruthy(); 98 | }); 99 | 100 | it('should return false for DNS check with the wrong base domain', () => { 101 | const details: RequestDetails = { 102 | ...baseDetails, 103 | url: 'https://example.com#am.i.mullvad.net/', 104 | originUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 105 | documentUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 106 | }; 107 | expect(isExtConnCheck(details)).toBeFalsy(); 108 | }); 109 | 110 | it('should return false for non-extension requests', () => { 111 | const details: RequestDetails = { 112 | ...baseDetails, 113 | url: 'https://ipv4.am.i.mullvad.net/json', 114 | documentUrl: 'https://example.com', 115 | originUrl: 'https://example.com', 116 | }; 117 | expect(isExtConnCheck(details)).toBeFalsy(); 118 | }); 119 | 120 | it('should return false for extension requests to other URLs', () => { 121 | const details: RequestDetails = { 122 | ...baseDetails, 123 | url: 'https://example.com', 124 | originUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 125 | documentUrl: 'moz-extension://8ad8e256-a9a0-4017-b302-1345ac426553/dist/options/index.html', 126 | }; 127 | expect(isExtConnCheck(details)).toBeFalsy(); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/helpers/proxyBadge.ts: -------------------------------------------------------------------------------- 1 | import { checkDomain } from '@/helpers/domain'; 2 | import { isLocalOrReservedIP } from '@/helpers/socksProxy/socksProxy'; 3 | import { ProxyDetails } from '@/helpers/socksProxy/socksProxy.types'; 4 | import { getActiveProxyDetails, getActiveTab } from '@/helpers/tabs'; 5 | 6 | export const updateCurrentTabProxyBadge = async () => { 7 | const activeTab = await getActiveTab(); 8 | 9 | if (activeTab) { 10 | await updateTabProxyBadge(activeTab, await getActiveProxyDetails()); 11 | } 12 | }; 13 | 14 | export const updateTabProxyBadge = async ( 15 | tab: browser.tabs.Tab, 16 | activeProxyDetails: ProxyDetails, 17 | ) => { 18 | const { id: tabId, url } = tab; 19 | const { excludedHosts } = await browser.storage.local.get('excludedHosts'); 20 | const { hostProxiesDetails } = await browser.storage.local.get('hostProxiesDetails'); 21 | const { globalProxyDetails } = await browser.storage.local.get('globalProxyDetails'); 22 | const { randomProxyMode } = await browser.storage.local.get('randomProxyMode'); 23 | 24 | const hostProxiesDetailsParsed = JSON.parse(hostProxiesDetails); 25 | const excludedHostsParsed = JSON.parse(excludedHosts); 26 | const globalProxyDetailsParsed = JSON.parse(globalProxyDetails); 27 | const randomProxyModeParsed = JSON.parse(randomProxyMode); 28 | 29 | const tabHost = new URL(url!).hostname; 30 | const { domain, subDomain, hasSubdomain } = checkDomain(tabHost); 31 | 32 | // Block for local/reserved IPs 33 | if (isLocalOrReservedIP(tabHost)) { 34 | browser.browserAction.setTitle({ tabId, title: 'Local/Reserved IP - No proxy needed' }); 35 | await setTabExtBadge(tab, false, false); 36 | return; 37 | } 38 | 39 | // 0. Check for random proxy mode 40 | if (randomProxyModeParsed) { 41 | // If it's random proxy mode, we show a shuffle icon on the badge 42 | const title = `Random proxy`; 43 | browser.browserAction.setTitle({ tabId, title }); 44 | await setTabExtBadge(tab, true, false, '🔀', true); 45 | return; 46 | } 47 | 48 | // 1. Check subdomain level 49 | if (hasSubdomain) { 50 | if (excludedHostsParsed.includes(subDomain)) { 51 | browser.browserAction.setTitle({ tabId, title: `${subDomain} is set to never be proxied` }); 52 | await setTabExtBadge(tab, false, true); 53 | return; 54 | } 55 | 56 | if (hostProxiesDetailsParsed[subDomain]?.socksEnabled) { 57 | const proxyDNSMessage = activeProxyDetails.proxyDNS ? 'DNS proxied' : 'DNS not proxied'; 58 | const title = `${activeProxyDetails.city}, ${activeProxyDetails.country}\nServer: ${activeProxyDetails.server}\n${proxyDNSMessage}`; 59 | browser.browserAction.setTitle({ tabId, title }); 60 | await setTabExtBadge(tab, true, false, activeProxyDetails.countryCode); 61 | return; 62 | } 63 | } 64 | 65 | // 2. Check domain level 66 | if (excludedHostsParsed.includes(domain)) { 67 | browser.browserAction.setTitle({ tabId, title: `${domain} is set to never be proxied` }); 68 | await setTabExtBadge(tab, false, true); 69 | return; 70 | } 71 | 72 | if (hostProxiesDetailsParsed[domain]?.socksEnabled) { 73 | const proxyDNSMessage = activeProxyDetails.proxyDNS ? 'DNS proxied' : 'DNS not proxied'; 74 | const title = `${activeProxyDetails.city}, ${activeProxyDetails.country}\nServer: ${activeProxyDetails.server}\n${proxyDNSMessage}`; 75 | browser.browserAction.setTitle({ tabId, title }); 76 | await setTabExtBadge(tab, true, false, activeProxyDetails.countryCode); 77 | return; 78 | } 79 | 80 | // 3. Check global proxy 81 | if (globalProxyDetailsParsed.socksEnabled) { 82 | const proxyDNSMessage = activeProxyDetails.proxyDNS ? 'DNS proxied' : 'DNS not proxied'; 83 | const title = `${activeProxyDetails.city}, ${activeProxyDetails.country}\nServer: ${activeProxyDetails.server}\n${proxyDNSMessage}`; 84 | browser.browserAction.setTitle({ tabId, title }); 85 | await setTabExtBadge(tab, true, false, activeProxyDetails.countryCode); 86 | return; 87 | } 88 | 89 | // 4. Default: no proxy 90 | browser.browserAction.setTitle({ tabId, title: 'Proxy not in use' }); 91 | await setTabExtBadge(tab, false, false); 92 | }; 93 | 94 | const setTabExtBadge = async ( 95 | tab: browser.tabs.Tab, 96 | proxy = true, 97 | isExcluded = false, 98 | countryCode = 'P', 99 | randomProxyMode = false, 100 | ) => { 101 | const { id: tabId } = tab; 102 | 103 | if (isExcluded) { 104 | browser.browserAction.setBadgeText({ text: '🚫', tabId }); 105 | browser.browserAction.setBadgeBackgroundColor({ color: '#ffd524', tabId }); 106 | browser.browserAction.setBadgeTextColor({ color: 'black', tabId }); 107 | } else if (proxy) { 108 | browser.browserAction.setBadgeText({ text: countryCode.toUpperCase(), tabId }); 109 | browser.browserAction.setBadgeBackgroundColor({ color: '#ffd524', tabId }); 110 | browser.browserAction.setBadgeTextColor({ color: 'black', tabId }); 111 | } else if (randomProxyMode) { 112 | browser.browserAction.setBadgeText({ text: countryCode, tabId }); 113 | } else { 114 | browser.browserAction.setBadgeText({ text: '', tabId }); 115 | } 116 | }; 117 | -------------------------------------------------------------------------------- /src/components/__snapshots__/ProxyList.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ProxyList > should render a single proxy correctly 1`] = ` 4 |
9 |
13 | 41 |
42 |
43 | `; 44 | 45 | exports[`ProxyList > should render multiple proxies correctly 1`] = ` 46 |
51 |
55 | 83 |
84 |
88 | 116 |
117 |
118 | `; 119 | --------------------------------------------------------------------------------