434 lines
13 KiB
JavaScript
434 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
/* eslint-disable no-undef */
|
|
|
|
const isFirefox = navigator.userAgent.includes('Firefox');
|
|
|
|
const webRequestPermissions = {
|
|
blockingRequest: isFirefox ? ['blocking', 'requestHeaders'] : ['blocking', 'requestHeaders', 'extraHeaders'],
|
|
blockingResponse: isFirefox ? ['blocking', 'responseHeaders'] : ['blocking', 'responseHeaders', 'extraHeaders'],
|
|
};
|
|
|
|
chrome.privacy.network.networkPredictionEnabled.set({ value: false });
|
|
|
|
const translator = new Map();
|
|
const counter = new Map();
|
|
|
|
const getOpenerId = (id) => {
|
|
if (typeof id !== 'number' || !Number.isFinite(id)) {
|
|
throw new Error('Expected `id` to be a number');
|
|
}
|
|
|
|
if (translator.has(id)) {
|
|
const opener = translator.get(id);
|
|
|
|
if (translator.has(opener)) {
|
|
throw new Error('Opener is not the most ascendent');
|
|
}
|
|
|
|
// console.log(`getopener ${id} -> ${opener}`);
|
|
return opener;
|
|
}
|
|
|
|
return id;
|
|
};
|
|
|
|
const keyFromTabId = (tabId) => `.${tabId}.`;
|
|
|
|
const getCookieURL = (cookie) => {
|
|
const protocol = cookie.secure ? 'https:' : 'http:';
|
|
const fixedDomain = cookie.domain[0] === '.' ? cookie.domain.slice(1) : cookie.domain;
|
|
const url = `${protocol}//${fixedDomain}${cookie.path}`;
|
|
|
|
return url;
|
|
};
|
|
|
|
// Rewrite cookies that were programmatically set to tabId instead of openerId.
|
|
// This is required because we cannot reliably get openerId inside Playwright.
|
|
chrome.cookies.onChanged.addListener(async (changeInfo) => {
|
|
if (!changeInfo.removed) {
|
|
const { cookie } = changeInfo;
|
|
|
|
if (cookie.name[0] !== '.') {
|
|
return;
|
|
}
|
|
|
|
const dotIndex = cookie.name.indexOf('.', 1);
|
|
if (dotIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
const tabId = Number(cookie.name.slice(1, dotIndex));
|
|
|
|
if (!Number.isFinite(tabId)) {
|
|
return;
|
|
}
|
|
|
|
const realCookieName = cookie.name.slice(dotIndex + 1);
|
|
const opener = getOpenerId(tabId);
|
|
|
|
if (tabId !== opener) {
|
|
console.log(`${realCookieName} -> ${keyFromTabId(opener)}`);
|
|
|
|
await chrome.cookies.remove({
|
|
name: cookie.name,
|
|
url: getCookieURL(cookie),
|
|
storeId: cookie.storeId,
|
|
});
|
|
|
|
delete cookie.hostOnly;
|
|
delete cookie.session;
|
|
|
|
await chrome.cookies.set({
|
|
...cookie,
|
|
name: `${keyFromTabId(opener)}${realCookieName}`,
|
|
url: getCookieURL(cookie),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
chrome.webRequest.onBeforeSendHeaders.addListener(
|
|
(details) => {
|
|
for (const header of details.requestHeaders) {
|
|
if (header.name.toLowerCase() === 'cookie') {
|
|
const id = keyFromTabId(getOpenerId(details.tabId));
|
|
|
|
const fixedCookies = header.value
|
|
.split('; ')
|
|
.filter((x) => x.startsWith(id))
|
|
.map((x) => x.slice(id.length))
|
|
.join('; ');
|
|
header.value = fixedCookies;
|
|
}
|
|
|
|
// Sometimes Chrome makes a request on a ghost tab.
|
|
// We don't want these in order to prevent cluttering cookies.
|
|
// Yes, `webNavigation.onCommitted` is emitted and `webNavigation.onCreatedNavigationTarget` is not.
|
|
if (header.name.toLowerCase() === 'purpose' && header.value === 'prefetch' && !counter.has(details.tabId)) {
|
|
console.log(details);
|
|
return {
|
|
cancel: true,
|
|
};
|
|
}
|
|
|
|
// This one is for Firefox
|
|
if (header.name.toLowerCase() === 'x-moz' && header.value === 'prefetch' && !counter.has(details.tabId)) {
|
|
console.log(details);
|
|
return {
|
|
cancel: true,
|
|
};
|
|
}
|
|
|
|
if (['beacon', 'csp_report', 'ping', 'speculative'].includes(details.type)) {
|
|
console.log(details);
|
|
return {
|
|
cancel: true,
|
|
};
|
|
}
|
|
|
|
if (details.tabId === -1) {
|
|
console.log(details);
|
|
}
|
|
}
|
|
|
|
return {
|
|
requestHeaders: details.requestHeaders.filter(
|
|
(header) => header.name.toLowerCase() !== 'cookie' || header.value !== '',
|
|
),
|
|
};
|
|
},
|
|
{ urls: ['<all_urls>'] },
|
|
webRequestPermissions.blockingRequest,
|
|
);
|
|
|
|
// Firefox Bug: doesn't catch https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri
|
|
chrome.webRequest.onHeadersReceived.addListener(
|
|
(details) => {
|
|
for (const header of details.responseHeaders) {
|
|
if (header.name.toLowerCase() === 'set-cookie') {
|
|
const parts = header.value.split('\n');
|
|
|
|
// `details.tabId` === -1 when Chrome is making internal requests, such downloading a service worker.
|
|
|
|
const openerId = getOpenerId(details.tabId);
|
|
|
|
header.value = parts
|
|
.map((part) => {
|
|
const equalsIndex = part.indexOf('=');
|
|
if (equalsIndex === -1) {
|
|
return `${keyFromTabId(openerId)}=${part.trimStart()}`;
|
|
}
|
|
return keyFromTabId(openerId) + part.trimStart();
|
|
})
|
|
.join('\n');
|
|
}
|
|
}
|
|
|
|
return {
|
|
responseHeaders: details.responseHeaders,
|
|
};
|
|
},
|
|
{ urls: ['<all_urls>'] },
|
|
webRequestPermissions.blockingResponse,
|
|
);
|
|
|
|
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
|
const opener = getOpenerId(tabId);
|
|
translator.delete(tabId);
|
|
|
|
if (counter.has(opener)) {
|
|
counter.set(opener, counter.get(opener) - 1);
|
|
|
|
if (counter.get(opener) < 1) {
|
|
counter.delete(opener);
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const id = keyFromTabId(opener);
|
|
|
|
chrome.cookies.getAll({}, async (cookies) => {
|
|
await Promise.allSettled(
|
|
cookies
|
|
.filter((cookie) => cookie.name.startsWith(id))
|
|
.map((cookie) => {
|
|
return chrome.cookies.remove({
|
|
name: cookie.name,
|
|
url: getCookieURL(cookie),
|
|
storeId: cookie.storeId,
|
|
});
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// Proxy per tab
|
|
const getProxyConfiguration = (scheme, host, port) => {
|
|
return {
|
|
mode: 'fixed_servers',
|
|
rules: {
|
|
proxyForHttp: {
|
|
scheme,
|
|
host,
|
|
port,
|
|
},
|
|
proxyForHttps: {
|
|
scheme,
|
|
host,
|
|
port,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
const localhostIpCache = new Map();
|
|
const localHostIp = [127, 0, 0, 1];
|
|
const getNextLocalhostIp = (openerId) => {
|
|
if (localhostIpCache.has(openerId)) {
|
|
return localhostIpCache.get(openerId);
|
|
}
|
|
|
|
const result = localHostIp.join('.');
|
|
|
|
localhostIpCache.set(openerId, result);
|
|
|
|
if (localHostIp[3] === 254) {
|
|
if (localHostIp[2] === 255) {
|
|
if (localHostIp[1] === 255) {
|
|
localHostIp[1] = 0;
|
|
} else {
|
|
localHostIp[1]++;
|
|
}
|
|
|
|
localHostIp[2] = 0;
|
|
} else {
|
|
localHostIp[2]++;
|
|
}
|
|
|
|
localHostIp[3] = 1;
|
|
} else {
|
|
localHostIp[3]++;
|
|
}
|
|
|
|
// [127.0.0.1 - 127.255.255.254] = 1 * 255 * 255 * 254 = 16 516 350
|
|
while (localhostIpCache.length >= 1 * 255 * 255 * 254) {
|
|
localhostIpCache.delete(localhostIpCache.keys().next().value);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
let proxyPort;
|
|
|
|
// Clear extension's proxy settings on reload
|
|
if (isFirefox) {
|
|
browser.proxy.settings.clear({});
|
|
} else {
|
|
chrome.proxy.settings.clear({});
|
|
}
|
|
|
|
// Proxy per tab
|
|
if (isFirefox) {
|
|
// On Firefox, we could use the `dns` permission to enforce DoH
|
|
// but then the extension would not be compatible with Chrome.
|
|
// Therefore users need to manually set the DNS settings.
|
|
|
|
browser.proxy.onRequest.addListener(
|
|
(details) => {
|
|
const openerId = getOpenerId(details.tabId);
|
|
|
|
if (typeof proxyPort === 'number') {
|
|
return {
|
|
type: 'http',
|
|
host: getNextLocalhostIp(openerId),
|
|
port: proxyPort,
|
|
};
|
|
}
|
|
return {
|
|
type: 'direct',
|
|
};
|
|
},
|
|
{ urls: ['<all_urls>'] },
|
|
);
|
|
} else {
|
|
// The connection is not yet created with `onBeforeSendHeaders`, but is with `onSendHeaders`.
|
|
chrome.webRequest.onBeforeSendHeaders.addListener(
|
|
(details) => {
|
|
const openerId = getOpenerId(details.tabId);
|
|
|
|
if (typeof proxyPort === 'number') {
|
|
chrome.proxy.settings.set({
|
|
value: getProxyConfiguration('http', getNextLocalhostIp(openerId), proxyPort),
|
|
scope: 'regular',
|
|
});
|
|
} else {
|
|
chrome.proxy.settings.clear({});
|
|
}
|
|
},
|
|
{ urls: ['<all_urls>'] },
|
|
webRequestPermissions.blockingRequest,
|
|
);
|
|
}
|
|
|
|
// External communication. Note: the JSON keys are lowercased by the browser.
|
|
const routes = Object.assign(Object.create(null), {
|
|
async tabid(details) {
|
|
return { tabid: details.tabId, proxyip: getNextLocalhostIp(details.tabId) };
|
|
},
|
|
async proxy(details, body) {
|
|
proxyPort = body.port;
|
|
|
|
return '';
|
|
},
|
|
});
|
|
|
|
const onCompleted = async (details) => {
|
|
const textPlain = 'data:text/plain,';
|
|
|
|
if (details.frameId === 0 && details.url.startsWith(textPlain)) {
|
|
try {
|
|
const url = new URL(details.url);
|
|
const route = url.pathname.slice('text/plain,'.length);
|
|
|
|
if (route in routes) {
|
|
const hash = url.hash.slice(1);
|
|
|
|
let body = {};
|
|
|
|
if (hash !== '') {
|
|
try {
|
|
body = JSON.parse(decodeURIComponent(hash));
|
|
} catch {
|
|
// Empty on purpose.
|
|
}
|
|
}
|
|
|
|
// Different protocols are required, otherwise `onCompleted` won't be emitted.
|
|
const result = await routes[route](details, body);
|
|
if (result !== undefined) {
|
|
await chrome.tabs.update(details.tabId, {
|
|
url: `about:blank#${encodeURIComponent(JSON.stringify(result))}`,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid URL, ignore.
|
|
}
|
|
}
|
|
};
|
|
|
|
chrome.webNavigation.onCompleted.addListener(onCompleted);
|
|
|
|
// Load content scripts.
|
|
void (async () => {
|
|
const contentResponse = await fetch(chrome.runtime.getURL('content.js'));
|
|
const contentText = await contentResponse.text();
|
|
|
|
// `tabs.onCreated` doesn't work here when manually creating new tabs,
|
|
// because the opener is the current tab active.
|
|
//
|
|
// This events only fires when the page opens something.
|
|
chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
|
translator.set(details.tabId, getOpenerId(details.sourceTabId));
|
|
|
|
const opener = getOpenerId(details.tabId);
|
|
|
|
if (counter.has(opener)) {
|
|
counter.set(opener, counter.get(opener) + 1);
|
|
} else {
|
|
counter.set(opener, 2); // the current one + opener = 2
|
|
}
|
|
});
|
|
|
|
chrome.webNavigation.onCommitted.addListener(async (details) => {
|
|
if (details.url.startsWith('chrome')) {
|
|
return;
|
|
}
|
|
|
|
const executeCodeInPageContext = `
|
|
const script = document.createElement('script');
|
|
script.textContent = code;
|
|
|
|
const destination = document.head ?? document.documentElement;
|
|
|
|
if (document instanceof HTMLDocument) {
|
|
destination.append(script);
|
|
script.remove();
|
|
}
|
|
`;
|
|
|
|
// Race condition: website scripts may run first
|
|
await chrome.tabs.executeScript(details.tabId, {
|
|
code: `'use strict';
|
|
(() => {
|
|
if (window.totallyRandomString) {
|
|
return;
|
|
}
|
|
|
|
window.totallyRandomString = true;
|
|
|
|
const code = "'use strict'; const tabId = '${getOpenerId(
|
|
details.tabId,
|
|
)}'; (() => {\\n" + ${JSON.stringify(contentText)} + "\\n})();\\n";
|
|
${executeCodeInPageContext}
|
|
})();
|
|
`,
|
|
matchAboutBlank: true,
|
|
allFrames: true,
|
|
runAt: 'document_start',
|
|
});
|
|
});
|
|
|
|
chrome.tabs.query({}, async (tabs) => {
|
|
for (const tab of tabs) {
|
|
await onCompleted({
|
|
frameId: 0,
|
|
url: tab.url,
|
|
tabId: tab.id,
|
|
});
|
|
}
|
|
});
|
|
})();
|