Files
2026-07-13 13:23:39 +08:00

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,
});
}
});
})();