3 Commits

Author SHA1 Message Date
103c2d1e09 feat(1.0.10): experimental chrome.* spoof for Google embedded-browser detection
Inject chrome.app, chrome.runtime, chrome.csi, chrome.loadTimes,
navigator.permissions.query wrapper via executeJavaScript on dom-ready
for every view. Goal: pass Google's JS-side embedded-browser detector
("Возможно, этот браузер небезопасны") by exposing the same chrome.*
shape real Chrome does.

Caveats acknowledged upfront:
- dom-ready fires AFTER <head> scripts, so detection scripts there have
  already seen the un-spoofed environment. Helps only if Google re-checks
  on form submit / later events.
- TLS fingerprint (JA3/JA4) is server-side. If Google flags us there,
  no client-side spoof works. This is a best-effort attempt.

No webPreferences changes — keeps contextIsolation:true and
sandbox-equivalent isolation intact. If this fails we lose nothing
architecturally and revert is trivial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:45:49 +03:00
82f7fa7545 fix(1.0.9): revert OAuth popup BrowserWindow — Google detects embedded popups
1.0.1 ("trusted-domains OAuth popups") changed setWindowOpenHandler to
return action:'allow' with overrideBrowserWindowOptions for trusted
domains (Google, Yandex, etc.), opening a real Electron BrowserWindow
as popup. The reasoning was that OAuth flows need window.opener +
postMessage. That's correct for some flows but wrong for YouTube-style
login, which uses straight redirect.

Worse: Google specifically detects popup-style embedded browsers
(Electron BrowserWindow has distinct fingerprint vs real Chrome popup)
and blocks them with "Возможно, этот браузер небезопасны". The user
reported this stopped working after 1.0.0 — that's why.

Restore the 1.0.0 behavior for trusted domains: deny the popup and call
view.webContents.loadURL(newUrl) in the same view. The OAuth flow now
happens as a normal in-place navigation: YouTube → accounts.google.com
→ (user logs in) → redirect back to YouTube. No popup, no fingerprint
mismatch. The only UX loss is the popup window aesthetic; behavior is
functionally identical and matches what worked in 1.0.0.

Untrusted cross-domain still asks for confirmation, same-origin popups
still navigate in-place — unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:31:37 +03:00
b5e1296a7a fix(1.0.8): strip cliqz adblocker preload — breaks CSP-strict sites
cliqz/adblocker-electron registers its preload at session level via
session.setPreloads([...preloads, PRELOAD_PATH]) inside
enableBlockingInSession. That preload injects inline <script> elements
via doc.createElement('script') + script.appendChild(textNode) +
parent.appendChild(script). On modern strict-CSP sites this breaks:

- Trusted Types (YouTube, Gmail): "An HTMLScriptElement was directly
  modified and will not be executed" — 52+ console errors.
- Nonce-required CSP (kinogo via Cloudflare): "Refused to execute inline
  script ... script-src 'nonce-...'" — competing with Cloudflare's
  challenge JS, likely the proximate cause of the 403 we see on kinogo
  (CF treats the broken page as bot).

Remove the cliqz preload from each session immediately after
enableBlockingInSession. The network/CSP/blockers attached via
webRequest hooks remain active — only the script-injection layer for
anti-anti-adblock scriptlets is lost, which is a niche feature that
breaks more sites than it fixes.

The 1.0.7 Blink TrustedDOMTypes disable stays (defense in depth, no
cost). The 1.0.6 CSP-header strip stays removed (adblocker overwrites
the webRequest listener anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:24:47 +03:00
2 changed files with 100 additions and 21 deletions

119
main.js
View File

@@ -40,6 +40,61 @@ const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, t
let blockerPromise = null;
let cachedTrustedDomains = DEFAULT_TRUSTED_DOMAINS;
// chrome.* spoof: injected via executeJavaScript on every page's dom-ready.
// Goal is to look like real Chrome to JS-based "embedded browser" detectors
// (Google login, etc.). Cannot fix TLS-fingerprint detection — that's server-side.
const CHROME_SPOOF_JS = `(function(){
try {
if (!window.chrome) window.chrome = {};
var c = window.chrome;
if (!c.app) c.app = {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
getDetails: function(){ return null; },
getIsInstalled: function(){ return false; },
runningState: function(){ return 'cannot_run'; }
};
if (!c.runtime) c.runtime = {
PlatformOs: { MAC:'mac', WIN:'win', ANDROID:'android', CROS:'cros', LINUX:'linux', OPENBSD:'openbsd' },
PlatformArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
PlatformNaclArch: { ARM:'arm', X86_32:'x86-32', X86_64:'x86-64' },
RequestUpdateCheckStatus: { NO_UPDATE:'no_update', THROTTLED:'throttled', UPDATE_AVAILABLE:'update_available' },
OnInstalledReason: { CHROME_UPDATE:'chrome_update', INSTALL:'install', SHARED_MODULE_UPDATE:'shared_module_update', UPDATE:'update' },
OnRestartRequiredReason: { APP_UPDATE:'app_update', OS_UPDATE:'os_update', PERIODIC:'periodic' },
sendMessage: function(){},
connect: function(){
return {
postMessage: function(){}, disconnect: function(){},
onDisconnect: { addListener: function(){}, removeListener: function(){} },
onMessage: { addListener: function(){}, removeListener: function(){} }
};
}
};
if (!c.csi) c.csi = function(){ return { startE: Date.now()-1000, onloadT: Date.now()-500, pageT: 1000, tran: 15 }; };
if (!c.loadTimes) c.loadTimes = function(){
var t = performance.timing;
return {
commitLoadTime: t.responseStart/1000, connectionInfo: 'http/1.1',
finishDocumentLoadTime: t.domContentLoadedEventEnd/1000,
finishLoadTime: (t.loadEventEnd/1000) || 0,
firstPaintAfterLoadTime: 0, firstPaintTime: t.responseEnd/1000,
navigationType: 'Other', npnNegotiatedProtocol: 'h2',
requestTime: t.requestStart/1000, startLoadTime: t.fetchStart/1000,
wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true
};
};
// navigator.permissions.query: Notification permission must agree with Notification.permission
if (navigator.permissions && navigator.permissions.query) {
var origQuery = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = function(p){
if (p && p.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null });
return origQuery(p);
};
}
} catch (_) {}
})();`;
function loadTrustedDomainsFromDisk() {
try {
if (fs.existsSync(CONFIG_PATH)) {
@@ -111,7 +166,27 @@ function getBlocker() {
function enableBlockingInSession(sess) {
getBlocker()
.then(b => { b.enableBlockingInSession(sess); console.log('[adblock] enabled for session'); })
.then(b => {
b.enableBlockingInSession(sess);
// Remove the cliqz preload script that the blocker just registered on this
// session. The preload injects inline <script> elements (via createTextNode +
// appendChild) to neutralize anti-adblock scripts, but:
// • Strict-CSP sites (kinogo via Cloudflare, etc.) reject inline scripts
// without a matching nonce → "Refused to execute inline script".
// • Trusted-Types sites (YouTube, Gmail) reject `script.appendChild(text)`
// → "HTMLScriptElement was directly modified" (52 errors).
// We keep the adblocker's network blocking and CSP filtering (via the still-
// attached webRequest hooks), losing only the niche scriptlet/cosmetic-DOM
// injection layer that breaks more sites than it helps.
const before = sess.getPreloads();
const after = before.filter(p => !/adblocker-electron-preload/i.test(p));
if (after.length !== before.length) {
sess.setPreloads(after);
console.log('[adblock] enabled for session (preload script disabled)');
} else {
console.log('[adblock] enabled for session');
}
})
.catch(e => console.warn('[adblock] failed to enable:', e.message));
}
@@ -588,6 +663,17 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
view.setBounds(getViewBounds());
attachDevToolsShortcut(view.webContents);
// Experimental: spoof chrome.* JS objects on every page so Google's
// "embedded browser" detector sees a real-Chrome-shaped global. Runs on
// dom-ready which is AFTER <head> scripts, so detection scripts that ran
// there have already seen the un-spoofed environment — this fix only
// helps if Google's gate is re-checked on form submit / later events.
// TLS fingerprint (JA3) is server-side and unaffected; if Google flags us
// there, no client-side spoof helps. Best-effort attempt only.
view.webContents.on('dom-ready', () => {
view.webContents.executeJavaScript(CHROME_SPOOF_JS).catch(() => {});
});
view.webContents.on('did-finish-load', () => {
removeLoader();
addChild(view);
@@ -639,37 +725,30 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
trackNavigation(newUrl);
});
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
view.webContents.setWindowOpenHandler(({ url: newUrl, frameName, features }) => {
view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
// Trusted domain → open as real popup BrowserWindow with same session.
// This is what OAuth flows need: window.opener.postMessage() works,
// popup can close itself when done, parent stays on the original page.
// Trusted domain (Google, Yandex, etc.) → navigate IN-PLACE, no popup.
// 1.0.1 tried opening a real popup BrowserWindow here for OAuth postMessage
// flows — turns out Google specifically detects popup-style embedded
// browsers and blocks OAuth ("Возможно, этот браузер небезопасны").
// YouTube-style login uses standard redirect flow, so in-place navigation
// works AND avoids the popup fingerprint. 1.0.0 behavior, restored.
if (newHostname && isTrustedDomain(newHostname)) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
width: 520, height: 640,
parent: mainWindow,
autoHideMenuBar: true,
webPreferences: {
session: view.webContents.session,
contextIsolation: true,
nodeIntegration: false,
},
},
};
trackNavigation(newUrl);
view.webContents.loadURL(newUrl);
return { action: 'deny' };
}
// Untrusted cross-domain → ask the user (original behavior).
// Untrusted cross-domain → ask the user.
if (origHostname && newHostname && newHostname !== origHostname) {
pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
return { action: 'deny' };
}
// Same-origin popup → just navigate the current view.
// Same-origin popup → navigate the current view.
trackNavigation(newUrl);
view.webContents.loadURL(newUrl);
return { action: 'deny' };

View File

@@ -1,6 +1,6 @@
{
"name": "ESH-Media",
"version": "1.0.7",
"version": "1.0.10",
"private": true,
"main": "main.js",
"scripts": {