← Back to Blog

Covering Every Tab Without Navigating: A Closed Shadow-DOM Veil in MV3

AnnMax · November 4, 202613 min read

Two Ways to Hide a Browser

When you build a "lock my browser while I step away" extension, there are exactly two architectures, and every product on the store picks one of them:

VKT VeilMate, my tab-privacy extension, is the second kind. The header comment of its content script (content/veil.js) is the whole design brief in four lines:

 * 1. Only block — never host the password field: it is typed on the
 *    extension's own page, so no page script can sniff it.
 * 2. Never destroy a page: mount a shadow-DOM overlay; unlock removes it;
 *    forms, video and SPA state survive untouched.
 * 3. Self-heal: if the site deletes the overlay, put it back.
 * 4. Cosmetic layer (wallpaper / big clock / message) is painted AFTER
 *    blocking — cover first, decorate second.

"Cover first, decorate second" sounds obvious until you watch the fetch for the user's wallpaper delay the veil by a frame. The overlay attaches synchronously; everything pretty loads afterwards.

The Veil Is a Closed Shadow Root

The overlay is one div with a closed shadow root hung at z-index: 2147483647. From content/veil.js:

function buildNode(kind, style) {
  const node = document.createElement('div');
  node.id = HOST_ID;               // 'veilmate-veil-root'
  node.tabIndex = -1;
  assertHostStyles(node);
  shadow = node.attachShadow({ mode: 'closed' });

mode: 'closed' buys two things. The page cannot call host.shadowRoot to walk inside the tree — it gets null — and the page's stylesheet cannot select anything behind the boundary, because shadow DOM is a real encapsulation border, not a convention. The styles live inside the root itself:

:host { all: initial; }
.veil {
  position: fixed; inset: 0; z-index: 2147483647;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  background: radial-gradient(1200px 760px at 50% 16%, #2a2350 0%, #14122b 55%, #0b0a18 100%);
  color: #ece9ff;
  user-select: none; cursor: default;
}

The :host { all: initial } line matters more than it looks: it kills anything the document would otherwise pass down the tree (inherited fonts, letter-spacing, directions), so the veil renders identically on a site that sets html { direction: rtl } and one that doesn't.

Here is the honest part: closed does not mean invisible. The host element is a plain div with a guessable id, sitting in the page's DOM, visible to every querySelector. A site script can call host.remove(), or hide it with CSS. Closed only protects the inside. The outside needs its own defense, and I initially got that wrong.

The Host Element Is the Leak

The first version set the host's position and size as ordinary inline styles. A site only needed one rule in its author stylesheet — #veilmate-veil-root { display: none !important } — and an author-level !important beats a non-important inline declaration. The veil was gone, and the self-heal didn't fire, because the self-heal only watched isConnected, and a hidden node is still connected.

The fix is belt-and-braces in three layers. First, every load-bearing property is written as important (HOST_STYLE in content/veil.js, trimmed):

const HOST_STYLE = {
  all: 'initial',        // first: wipe inherited junk
  display: 'block',
  position: 'fixed',
  top: '0px', right: '0px', bottom: '0px', left: '0px',
  width: '100%', height: '100%',
  'z-index': '2147483647',
  visibility: 'visible',
  opacity: '1',
  'pointer-events': 'auto',
  transform: 'none', filter: 'none', 'clip-path': 'none',
  /* …and the min/max/overflow siblings */
};

function assertHostStyles(node) {
  if (!node) return;
  for (const [prop, value] of Object.entries(HOST_STYLE)) {
    try {
      if (node.style.getPropertyValue(prop) === value &&
          node.style.getPropertyPriority(prop) === 'important') continue;
      node.style.setProperty(prop, value, 'important');
    } catch { /* ignore */ }
  }
}

Second, healing checks visibility, not just presence:

function hostHidden(node) {
  try {
    const cs = getComputedStyle(node);
    return (
      cs.display === 'none' ||
      cs.visibility === 'hidden' ||
      cs.opacity === '0' ||
      cs.pointerEvents === 'none'
    );
  } catch {
    return false;
  }
}

Third, a MutationObserver plus a one-second ticker put things back:

observer = new MutationObserver(() => {
  if (!locked || !host) return;
  if (!host.isConnected) {
    (document.body || document.documentElement).appendChild(host);
    assertHostStyles(host);
    return;
  }
  if (hostHidden(host)) assertHostStyles(host);
});
observer.observe(document.documentElement, { childList: true, subtree: true });

Note the subtree: true. The earlier version observed only direct children of <html> — so when a site replaced its <body> wholesale, the observer never fired and "self-healing" was decorative. The ticker exists for the same reason: SPAs rename document.title from inside route transitions, and a locked tab whose title still reads "Inbox — Alice's secret project" defeats the point. The title is re-forced to a neutral string every second, and the ticker runs even when the user turns the big clock off, because its real job is re-asserting title and host styles.

Eating Every Event Before the Page Sees One

An overlay that only looks like it covers is a screensaver. If a click passes through gaps or a keystroke lands in a focused input underneath, the page behind the veil is still live. The guard set from content/veil.js:

const BLOCKED = [
  'keydown', 'keyup', 'keypress', 'mousedown', 'mouseup', 'click', 'dblclick',
  'contextmenu', 'wheel', 'touchstart', 'touchmove', 'touchend',
  'pointerdown', 'pointerup', 'dragstart', 'selectstart', 'copy', 'cut', 'paste'
];

function attachGuards() {
  if (guardsAttached) return;   // attach once; lock/unlock cycles don't stack
  guardsAttached = true;
  for (const type of BLOCKED) {
    window.addEventListener(type, block, { capture: true, passive: false });
  }
  window.addEventListener('focusin', stealFocus, true);
  window.addEventListener('focus', stealFocus, true);
}

Four deliberate details:

Keyboard has one more hole: a canvas, iframe or rich-text editor that already holds focus dispatches keystrokes into its own document, where the top window's capture listener never sees them — the typing lands on the page behind the curtain. The fix is to not let anything else keep focus:

function stealFocus(e) {
  if (!locked || !host) return;
  if (typeof e.composedPath === 'function' && e.composedPath().includes(host)) return;
  try {
    if (e.target && typeof e.target.blur === 'function') e.target.blur();
  } catch { /* ignore */ }
  try {
    host.focus({ preventScroll: true });
  } catch { /* ignore */ }
}

The host has tabIndex = -1 precisely so it can hold focus as a sink. Combined with user-select: none on the veil and copy/cut/paste in the blocked set, a covered page is inert: clicks, scroll, typing, drag and text selection all stop at the veil. Nothing is encrypted or paused — the video behind the curtain keeps buffering — but nothing the passer-by does reaches the page.

Injection Timing and the One Percent the Veil Cannot Reach

The content script is cheap and always on:

"content_scripts": [{
  "matches": ["http://*/*", "https://*/*"],
  "js": ["content/veil.js"],
  "run_at": "document_start",
  "all_frames": false
}]

document_start matters for reloads: refresh a covered page while locked and the script boots before the site's own JS, asks the background for state, and re-covers immediately:

chrome.runtime
  .sendMessage({ type: 'veil:state', url: location.href })
  .then((r) => {
    if (r && r.cover) show('lock', r.style);
  })
  .catch(() => {});

The pageshow listener repeats the question when a tab returns from bfcache. The background answers with the top-level URL it read from the sender — the page's self-reported URL is only a fallback — and a per-domain exempt list decides whether that tab gets covered at all.

all_frames: false is deliberate: the veil is one full-viewport fixed layer, so pointer input aimed at any embedded frame hits the veil instead of the frame; a per-frame overlay would multiply injection cost for no extra coverage. One click can also blur a focused frame because host.focus() takes focus across the frame boundary.

The genuine blind spots are pages a content script cannot exist on: chrome://, edge://, about:, file://, other extensions' pages, and tabs that were already open before the extension was installed. For exactly those, the background falls back to the ugly-but-correct strategy it refuses to use on normal pages — replace the page, remember the URL, restore on unlock (background.js, guardTab/unlock):

await rememberReplaced(tab.id, url).catch(() => {});
chrome.tabs.update(tab.id, { url: page }).catch(() => {
  if (windowId) chrome.tabs.create({ windowId, url: page, active: true }).catch(() => {});
});

// …and in unlock(), per tab:
if (isLockPage && original) {
  chrome.tabs.update(tab.id, { url: original }).catch(() => {});   // restore in place
} else if (isLockPage) {
  chrome.tabs.remove(tab.id).catch(() => {});                       // pure lock tab: close
} else {
  chrome.tabs.sendMessage(tab.id, { type: 'veil:unlock' }).catch(() => {});
}

Yes — a replaced tab reloads, and its form state is gone. That's the honest trade: navigation is only used where overlay is impossible, so the loss is confined to pages that were never live documents to begin with (a chrome:// settings page has nothing to lose). The one page that must never be replaced or covered is the extensions management page for this extension itself — it is the exit path if the user forgets the password, since there is no recovery code and no reset endpoint. isEscapeUrl allows only chrome://extensions filtered to the extension's own id; other extensions' pages stay behind the lock.

One Click, Every Window, Everything New

Locking is a single broadcast in background.js. chrome.tabs.query({}) — no filter — returns every tab in every window, http or not:

async function paintTabs() {
  const tabs = await chrome.tabs.query({});
  const st = await getState();
  if (!st.locked) return;
  const style = await currentLockStyle();

  for (const tab of tabs) {
    /* …skip the lock page itself and the escape url… */
    if (/^https?:/i.test(url)) {
      sendLock(tab.id, style).catch(() => recordAndSwap(tab.id, url));
    } else {
      await recordAndSwap(tab.id, url);
    }
  }
}

The toolbar button, the right-click menu and every auto-lock trigger funnel into this. There is no commands block in the manifest and no keyboard shortcut is registered anywhere — a deliberate small decision: shortcut collisions with sites and the browser are guaranteed, and a lock you trigger by accident while a popup is open is worse than two clicks.

While locked is true, three listeners make sure nothing new slips past the lock screen: tabs.onCreated (a fresh tab), tabs.onUpdated filtered to info.url changes only (a typed address — title and favicon updates are ignored on purpose so a chatty YouTube tab doesn't turn the callback into a hot path), and windows.onCreated with two delayed sweeps at 300 ms and 1200 ms, because a brand-new window may not have a tab yet. That includes private windows — the manifest declares "incognito": "spanning", so if the user enables it, a new incognito window opens onto the lock page too.

The lock state itself lives in chrome.storage.local, which means closing the browser does not unlock: onStartup reads the persisted state and calls paintTabs() as the first act of the new session. Auto-lock defaults to the browser's own idle detection at 10 minutes. The lock screen shows a big clock with an optional date — six language locales ship with it, so "浏览器已锁定" is "Browser locked" in English, 中文, 日本語, Deutsch, Español and Français.

Unlock Removes; It Does Not Rebuild

The veil's unlock button does not contain a password field — fields inside injected pages are sniffable in principle, so the button only asks the background to focus the extension's own lock page:

function requestUnlockPage() {
  try {
    chrome.runtime.sendMessage({ type: 'veil:unlock-request' });
  } catch { /* ignore */ }
}

The lock page verifies the password in the extension context: PBKDF2-SHA256 with 310,000 iterations and a random salt, stored as { salt, iter, hash }, compared in constant time. The password itself is never written to storage, never synced, never leaves the device — the only outbound request in the whole extension is license activation. After unlock, remove() in each page is the mirror image of mount: node out, observer disconnected, overflow restored, saved title restored. No reload, no restore protocol, nothing to checkpoint.

The two Pro features built on the same plumbing are worth a paragraph each. The privacy curtain reuses the overlay in a non-locking mode — right-click any page to blur it with backdrop-filter: blur(26px) and a one-line label; a click lifts it. The browser is not locked, nothing is recorded, and it is the polite version of "close this tab" when a colleague walks over. Scheduled lock is a one-shot chrome.alarms re-armed after every fire (a periodic 1440-minute alarm drifts across daylight saving), with a five-minute lateness cutoff so a missed alarm from a closed browser doesn't lock you out at boot.

What This Does Not Protect You From

The README's one-sentence product statement, which is also printed inside the settings page: VeilMate hides your tabs from people. It is not encryption, and it does not defend against someone who controls the machine. Concretely:

Saying these out loud costs installs among people who wanted a safe. It buys reviews from people who understood what they bought — the table above is the difference between "browser lock" as a category and a tool with a claim you can actually keep.

The Performance Bill

Small and honest. The veil is one fixed-position div with a gradient background: layout cost is one full-viewport paint, no canvas, no animation; the clock ticker rewrites two textContent values per second. The real costs are subtler: setting overflow: hidden on the document removes the scrollbar, which reflows the page's layout once at lock and once at unlock (content that used to be 100% wide becomes 15px wider) — harmless visually because the veil covers the transition. The observer on subtree: true fires callbacks on every DOM mutation site apps make constantly; React-style apps under the veil do pay a few observer invocations per second, and this is why the handler is two cheap checks and nothing else. backdrop-filter: blur(26px) in curtain mode is the one genuinely expensive primitive here — a full-viewport blur per frame on large displays — which is fine for a feature that covers one page and is removed on click.

If You Build the Same Thing

  1. Decide early between cover and navigate — mixing them is fine, but only as primary and fallback, never as equals.
  2. Closed shadow DOM protects the inside; the host element is still page-visible and needs important inline styles, a visibility-aware MutationObserver observing the whole subtree, and a slow timer as final backstop.
  3. Block at capture with passive: false, or wheel/touch prevention is silently ignored; whitelist your own overlay via composedPath().
  4. Focus is a side channel: blur whatever holds it and park focus on the host, or keystrokes land behind the curtain.
  5. Persist lock state in storage.local; "closing the browser unlocks" is a feature bug wearing a disguise.
  6. Leave exactly one escape hatch (your own extension-management page), or a forgotten password becomes a hostage situation.
  7. Publish what the tool cannot defend against — it is the cheapest trust you can buy.

References

Related Articles