I built a thing: Web Viewer UA Override
I spent the weekend writing my first Obsidian plugin. It’s a small one — Web Viewer UA Override, now in the community plugin directory — and it does exactly one thing: it makes Google sign-in work inside Obsidian’s Web Viewer.
The short version: if you’ve ever opened Gmail or Google Docs in an Obsidian Web Viewer tab and hit a 401 or a “this browser is not supported” wall, that’s not Google being difficult. That’s Obsidian handing Google a user agent string that isn’t a user agent string. The plugin routes web views through a clean Electron session with a real Chrome UA instead, and the wall goes away.
The bug is one line
Obsidian is an Electron app, and Electron apps get to rewrite their own HTTP headers. Obsidian does. Here’s the relevant handler, deminified out of obsidian.asar (1.13.7):
entry.session.webRequest.onBeforeSendHeaders({ urls: ["https://*/*", "http://*/*"] },
(d, cb) => {
let { requestHeaders: h } = d;
for (let k in h) {
if (k.toLowerCase() === "sec-fetch-dest" || k.toLowerCase() === "sec-ch-ua")
delete h[k];
else if (k.toLowerCase() === "user-agent"
&& d.url.startsWith("https://accounts.google.com/"))
h[k] = "Chrome"; // <-- this
}
cb({ requestHeaders: h });
});
Every request to accounts.google.com goes out with a User-Agent header of exactly Chrome. Not a Chrome user agent — the seven-character word “Chrome”. Google looks at that, decides it’s malformed, and returns a 401. Two security headers get deleted on the way out too, which doesn’t help.
The obvious fix doesn’t work. You can set a useragent attribute on the <webview> element all you like; this hook rewrites the header afterward. You can’t register your own onBeforeSendHeaders to undo it either, because a few lines later Obsidian does this:
let noop = () => false;
r.onBeforeRequest = noop; r.onBeforeSendHeaders = noop; r.onHeadersReceived = noop;
It doesn’t just install its handlers, it overwrites the registration methods themselves. Plugins are locked out of that pipeline on purpose.
The fix is a loophole
The way out is the if (!entry) guard sitting above all of that. Those hooks get installed once per Electron partition, and only when something sends the create-browser-session IPC message naming that partition. A partition nobody ever names stays clean forever.
So while the plugin is enabled, it does four things:
app.getWebviewPartition()returns a different partition —persist:vault-<appId>-cleaninstead of the real one.ipcRenderer.sendswallowscreate-browser-sessionfor that partition, so the main process never initializes it and the hooks never land.- Every
<webview>gets an explicit user agent. This one was fiddly: Electron requires theuseragentattribute to be set beforepartitionandsrcand before the element attaches to the DOM, so the plugin wrapsDocument.prototype.createElementper window realm to catch each element the instant it exists. - Every
<webview>denies permission requests, standing in for the session-level permission sandbox a fresh partition doesn’t have.
Both Web Viewer tabs and Canvas web embeds go through getWebviewPartition(), so both are covered. Disabling the plugin unwinds all four patches and drops you back on the original partition with your original cookies untouched.
What you give up
Everything has trade-offs, and this is no exception.
You lose ad blocking in web views. Obsidian’s EasyList and EasyPrivacy filtering rides on the same IPC handler that breaks Google sign-in. Skipping one skips the other. It’s a single handler so there’s no way to keep just the good half.
You get a fresh cookie jar. The clean partition starts empty, so every site wants a new login the first time. (This turns out to be a feature: changing the partition suffix in settings is the fastest way to sign out of everything at once.)
Permission coverage is partial. The element-level permissionrequest event catches what Electron routes through it — media, geolocation, notifications, MIDI, pointer lock, fullscreen, open-external — and the plugin denies all of it. Synchronous permission checks never reach that event, and a fresh partition has no setPermissionCheckHandler, so Electron falls back to its own defaults there.
That last one is a little tricky. The obvious idea is to install real session handlers over @electron/remote. Don’t. setPermissionCheckHandler has to return a boolean synchronously to the main process, and a remote proxy stub returns before the renderer has run anything, so it would hard-deny every check, including the clipboard permissions Obsidian itself grants. Silent, total, mysterious failure. And even worse: Electron emits the webview permissionrequest event from the default permission request handler it installs for guest contents, so calling setPermissionRequestHandler replaces that outright and the element event stops firing. You’d trade the path that works for a proxied one that hangs.
Building on someone else’s diagnosis
I didn’t find this bug. Bryan Monge did, in forum thread 117394, including the key observation that a custom partition sidesteps the hooks entirely. This plugin is an implementation of that finding. I’d have spent a lot longer than a weekend staring at header dumps without it.
The whole thing is plain CommonJS with no build step, committed as-is, so you can clone the repo directly into your plugins folder and read every line before you trust it. Which you should — it patches Document.prototype.createElement and intercepts IPC messages. That’s not nothing, and you shouldn’t take my word for it.
It also leans on Obsidian internals that aren’t part of the public API: App.getWebviewPartition, the create-browser-session channel, the webviewer view type. It refuses to patch anything if getWebviewPartition is missing at load, and it logs a loud warning if the IPC call shape changes underneath it — because the failure mode I care most about avoiding is the one where it silently stops protecting you and you never find out.
It should stop existing
This plugin is a workaround, and workarounds should have a shelf life. The real fix belongs in Obsidian: send a valid user agent, stop deleting security headers, and expose a supported way for plugins to create web views with clean sessions. If that ships, I’ll be happy to tell everyone to disable this thing and carry on.
Until then: Web Viewer UA Override is in the community plugin directory, the source is on GitHub, and it’s MIT-licensed. Desktop only, since it touches Electron. Developed against Obsidian 1.13.7 on Windows, but it should work anywhere the desktop app runs.
As always, I’d love to hear if it’s useful — or if it breaks something I didn’t anticipate.