feat(theseus): DNS over HTTPS and Global Privacy Control

DNS over HTTPS through Chromium's secure DNS (app.configureHostResolver),
under Privacy › Network: Default protection (encrypted via the chosen
provider, plain if that fails — the default), Increased protection
(always the provider, never plain) or Off, with Quad9, Cloudflare,
Mullvad, AdGuard or a custom resolver URL. Any DoH mode also turns on
Chromium's built-in resolver, as Chrome does. Silent Mode names never
touch DNS, and Tor resolves remotely through the SOCKS proxy, so
neither path goes around it.

Global Privacy Control, on by default, under Tracking protection: the
Sec-GPC header on every request (added in the one request-header hook
beside the client hints) and navigator.globalPrivacyControl in pages.
This commit is contained in:
Local Dev 2026-09-27 22:10:06 +02:00
parent f1b1de5a9e
commit 27819684d5
2 changed files with 90 additions and 2 deletions

41
main.js
View file

@ -394,6 +394,13 @@ const SETTINGS_DEFAULTS = {
dockOrder: [], dockOrder: [],
dockHidden: [], dockHidden: [],
dockAutoHidden: [], // add-ons already started hidden once (manifest dock:"hidden") dockAutoHidden: [], // add-ons already started hidden once (manifest dock:"hidden")
// DNS over HTTPS: off | automatic (encrypt when the system resolver has a
// known DoH endpoint, otherwise plain) | secure (always the chosen provider,
// no plain fallback). Provider is a preset id or "custom" + a URL.
dohMode: "automatic", dohProvider: "quad9", dohCustom: "",
// Global Privacy Control: the Sec-GPC header + navigator.globalPrivacyControl,
// a legally meaningful "do not sell or share" signal in several jurisdictions.
gpc: true,
// Sidebar width in px. Adjusted by dragging the grip on the panel's left // Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load. // edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth: 340, sidebarWidth: 340,
@ -842,6 +849,8 @@ async function applyFingerprint(wc) {
if (settings.hideMediaDevices) { if (settings.hideMediaDevices) {
src += `try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){}`; src += `try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){}`;
} }
// Global Privacy Control's JavaScript half; the header is added in applyClientHintsSpoof.
if (settings.gpc) src += `try{Object.defineProperty(navigator,'globalPrivacyControl',{get:()=>true,configurable:true})}catch(e){}`;
// Always on: Chrome-shaped window.chrome (see CHROME_SHIM_SRC). // Always on: Chrome-shaped window.chrome (see CHROME_SHIM_SRC).
src += CHROME_SHIM_SRC; src += CHROME_SHIM_SRC;
if (src) { if (src) {
@ -983,6 +992,32 @@ function applyAcceptLanguage() {
// (so the story stays consistent — no version straddling to fingerprint). // (so the story stays consistent — no version straddling to fingerprint).
// sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to // sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to
// the actual OS name so a Linux user still looks like a Linux user. // the actual OS name so a Linux user still looks like a Linux user.
// ---- DNS over HTTPS ----
// Chromium's secure DNS lives in its built-in resolver, so any DoH mode
// also turns that resolver on (as Chrome does). BCNR names never touch DNS
// (bns:// is served in-process), and with Tor on the SOCKS proxy resolves
// remotely, so neither path leaks around this.
const DOH_PROVIDERS = {
quad9: { name: "Quad9", url: "https://dns.quad9.net/dns-query" },
cloudflare: { name: "Cloudflare", url: "https://cloudflare-dns.com/dns-query" },
mullvad: { name: "Mullvad", url: "https://dns.mullvad.net/dns-query" },
adguard: { name: "AdGuard", url: "https://dns.adguard-dns.com/dns-query" },
};
function dohServerUrl() {
const p = String(settings.dohProvider || "quad9");
const url = p === "custom" ? String(settings.dohCustom || "").trim() : (DOH_PROVIDERS[p] || DOH_PROVIDERS.quad9).url;
return /^https:\/\/[^\s]+$/i.test(url) ? url : "";
}
function applyDoh() {
const mode = ["off", "automatic", "secure"].includes(settings.dohMode) ? settings.dohMode : "automatic";
const url = dohServerUrl();
const opts = mode === "off"
? { secureDnsMode: "off", secureDnsServers: [] }
: mode === "secure" && url ? { enableBuiltInResolver: true, secureDnsMode: "secure", secureDnsServers: [url] }
: { enableBuiltInResolver: true, secureDnsMode: "automatic", secureDnsServers: url ? [url] : [] };
try { app.configureHostResolver(opts); console.log(`[dns] secure DNS ${opts.secureDnsMode}${opts.secureDnsServers.length ? " via " + opts.secureDnsServers[0] : ""}`); }
catch (e) { console.warn("[dns] configureHostResolver failed:", e?.message); }
}
function applyClientHintsSpoof() { function applyClientHintsSpoof() {
try { try {
const meta = chromeBrandMetadata(); const meta = chromeBrandMetadata();
@ -1005,6 +1040,9 @@ function applyClientHintsSpoof() {
h["sec-ch-ua-full-version-list"] = fullList; h["sec-ch-ua-full-version-list"] = fullList;
h["sec-ch-ua-mobile"] = "?0"; h["sec-ch-ua-mobile"] = "?0";
h["sec-ch-ua-platform"] = platform; h["sec-ch-ua-platform"] = platform;
// Global Privacy Control (read live so the switch applies at once).
for (const k of Object.keys(h)) if (k.toLowerCase() === "sec-gpc") delete h[k];
if (settings.gpc) h["Sec-GPC"] = "1";
callback({ requestHeaders: h }); callback({ requestHeaders: h });
}); });
} catch (e) { console.warn("client-hints spoof setup failed:", e?.message); } } catch (e) { console.warn("client-hints spoof setup failed:", e?.message); }
@ -5811,6 +5849,8 @@ ipcMain.handle("settings-get", () => settings);
ipcMain.handle("settings-set", (_e, key, val) => { ipcMain.handle("settings-set", (_e, key, val) => {
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); } if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
if (key === "webrtcMode") applyWebRTCPolicy(); if (key === "webrtcMode") applyWebRTCPolicy();
if (key === "dohMode" || key === "dohProvider" || key === "dohCustom") applyDoh();
if (key === "gpc") applyFingerprintAll();
if (key === "theme") applyTheme(); if (key === "theme") applyTheme();
if (key === "backgroundThrottle") applyThrottle(); if (key === "backgroundThrottle") applyThrottle();
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue", if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
@ -6439,6 +6479,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
applyEmbedCookieShim(); applyEmbedCookieShim();
applyAcceptLanguage(); applyAcceptLanguage();
applyClientHintsSpoof(); applyClientHintsSpoof();
applyDoh();
// Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView // Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView
// preloads (home/settings/popover/etc.), which stack on top of it. Must be // preloads (home/settings/popover/etc.), which stack on top of it. Must be
// called before any tab is created; whenReady runs before createWindow(). // called before any tab is created; whenReady runs before createWindow().

View file

@ -469,7 +469,11 @@
<div class="txt"><div class="t">Manage exceptions</div><div class="d" id="exSummary">Sites where Shield is allowed through or cookie pop-ups are left alone.</div></div> <div class="txt"><div class="t">Manage exceptions</div><div class="d" id="exSummary">Sites where Shield is allowed through or cookie pop-ups are left alone.</div></div>
<span class="chev">›</span> <span class="chev">›</span>
</div> </div>
<p class="note">Both also sit in the toolbar's extension row if you want them there: right-click the row and choose "Show hidden".</p> <div class="row">
<div class="txt"><div class="t">Tell sites not to sell or share my data</div><div class="d">Sends the Global Privacy Control signal with every request. Sites in California, Colorado, Connecticut and other places with a privacy law must honour it.</div></div>
<label class="sw"><input type="checkbox" id="gpc"><span class="track"><span class="knob"></span></span></label>
</div>
<p class="note">Shield and Cookie Pop-ups also sit in the toolbar's extension row if you want them there: right-click the row and choose "Show hidden".</p>
<h2 class="sub">Device access</h2> <h2 class="sub">Device access</h2>
<div class="row"> <div class="row">
<div class="txt"><div class="t">WebRTC IP policy</div> <div class="txt"><div class="t">WebRTC IP policy</div>
@ -650,6 +654,27 @@
<div class="txt"><div class="t">VPN</div><div class="d">Route everything through a Silent Mode exit, or your own. Managed in its panel.</div></div> <div class="txt"><div class="t">VPN</div><div class="d">Route everything through a Silent Mode exit, or your own. Managed in its panel.</div></div>
<span class="chev">›</span> <span class="chev">›</span>
</div> </div>
<div class="row">
<div class="txt"><div class="t">DNS over HTTPS</div><div class="d">Encrypts name lookups so your network cannot see or alter which sites you are about to visit. Silent Mode names never use DNS; this covers the rest of the web.</div></div>
<div class="ctl"><select id="dohMode">
<option value="automatic">Default protection</option>
<option value="secure">Increased protection</option>
<option value="off">Off</option>
</select></div>
</div>
<div class="row" id="dohProviderRow">
<div class="txt"><div class="t">Provider</div><div class="d" id="dohHelp"></div></div>
<div class="ctl">
<select id="dohProvider">
<option value="quad9">Quad9</option>
<option value="cloudflare">Cloudflare</option>
<option value="mullvad">Mullvad</option>
<option value="adguard">AdGuard</option>
<option value="custom">Custom…</option>
</select>
<input id="dohCustom" type="text" placeholder="https://…/dns-query" hidden style="margin-top:6px;width:260px">
</div>
</div>
<h2 class="sub">Browsing data</h2> <h2 class="sub">Browsing data</h2>
<p class="subd">By default Theseus keeps <b>nothing</b> across sessions — everything toggled on here is wiped when you quit. Untoggle a bucket to keep it (e.g. cookies to stay signed in on trusted sites).</p> <p class="subd">By default Theseus keeps <b>nothing</b> across sessions — everything toggled on here is wiped when you quit. Untoggle a bucket to keep it (e.g. cookies to stay signed in on trusted sites).</p>
<div class="row"> <div class="row">
@ -820,7 +845,29 @@
document.addEventListener("section", (e) => { if (String(e.detail).startsWith("privacy")) refreshAll(); }); document.addEventListener("section", (e) => { if (String(e.detail).startsWith("privacy")) refreshAll(); });
refreshAll(); refreshAll();
})(); })();
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", // DNS over HTTPS controls: mode select, provider select (+ custom URL) shown
// unless the mode is Off; the help line explains the current mode.
(function () {
const el = (i) => document.getElementById(i);
const HELP = {
automatic: "Encrypted when your network's resolver offers it, plain otherwise. The provider below is used when the system's resolver has no encrypted endpoint.",
secure: "Always encrypted through the chosen provider, never plain. If the provider is unreachable, sites will not load.",
off: "Lookups go to the system resolver in the clear.",
};
function show(mode, provider) {
el("dohProviderRow").hidden = mode === "off";
el("dohCustom").hidden = provider !== "custom";
el("dohHelp").textContent = HELP[mode] || HELP.automatic;
}
C.get().then((s) => {
el("dohMode").value = s.dohMode || "automatic"; el("dohProvider").value = s.dohProvider || "quad9"; el("dohCustom").value = s.dohCustom || "";
show(el("dohMode").value, el("dohProvider").value);
el("dohMode").addEventListener("change", () => { C.set("dohMode", el("dohMode").value); show(el("dohMode").value, el("dohProvider").value); });
el("dohProvider").addEventListener("change", () => { C.set("dohProvider", el("dohProvider").value); show(el("dohMode").value, el("dohProvider").value); if (el("dohProvider").value === "custom") el("dohCustom").focus(); });
el("dohCustom").addEventListener("change", () => C.set("dohCustom", el("dohCustom").value.trim()));
});
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"]; "clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];
C.get().then((s) => { C.get().then((s) => {
for (const k of TOGGLES) { for (const k of TOGGLES) {