A Web Page That Cannot Send Your Data: The Browser Security of Bunker

Many websites promise privacy in a policy page. The promise depends on the people who run the site. If their code changes, the promise can change with it.

Bunker makes a stronger promise: the page sends nothing. A list of crypto addresses shows how much a person holds and where. So the promise must hold even if a bug in my own code tries to break it.

You can test the promise in ten seconds. Open the page, turn on airplane mode, and scan. The result is the same.

This article shows how the browser itself enforces that promise. You will see the real security policy of the page, the code that builds it, and the tests behind it.


The first decision: everything on your device

The first decision record of the project, ADR-001, compares three designs:

OptionForAgainst
A. The page sends addresses to a NosisTech server.Automatic lookups; easy updatesThe server sees every list. NosisTech must protect a database.
B. The page calls public blockchain APIs.Automatic lookups; no NosisTech serverEach API provider sees the addresses and the user’s IP address.
C. Everything on the user’s deviceNothing leaves the device. It works with no internet.No automatic lookups. The user answers one question for each address.

Bunker uses option C. The tool exists because people are afraid of losing funds, so it must not create a new privacy risk. With no server, there is no database to protect and no personal data to store.

The tool is also one HTML file of about 300 KB. One file has one SHA-256 hash, so you can verify it in one step. The same file works from your disk and from the website.

A rule that the browser enforces

A Content Security Policy (CSP) is a list of rules that a page gives to the browser. It tells the browser which code may run and which connections the page may open. The browser blocks everything else.

The build script writes the policy into the page. This is the code, from tools/build.js:

  if (createHash('sha256').update(font).digest('hex') !== FONT_SHA256) fail('the font file does not match its pinned SHA-256 hash');
  // ...
  const csp = [
    "default-src 'none'",
    "script-src 'sha256-" + sha256Base64(script) + "'",
    "style-src 'sha256-" + sha256Base64(styles) + "'",
    "img-src data:",
    "font-src data:",
    "connect-src 'none'",
    "object-src 'none'",
    "base-uri 'none'",
    "form-action 'none'",
    "require-trusted-types-for 'script'",
    "trusted-types 'none'",
  ].join('; ');

Each line is one rule. Read them in this order:

  1. default-src 'none' starts from zero. The page may load nothing, unless a later rule allows it.
  2. script-src and style-src allow one script and one style block. The browser runs them only if their SHA-256 hash matches the hash in the policy.
  3. img-src data: and font-src data: allow images and fonts only from inside the page itself. A data: URL holds the file bytes, so no server is asked.
  4. connect-src 'none' blocks every network connection from code. This is the rule that keeps the privacy promise.
  5. object-src, base-uri and form-action close three older ways to load or send data: plugins, a changed base address and forms.
  6. The two Trusted Types rules make the browser reject HTML strings in dangerous places. The next sections explain why that matters.

The first line of the excerpt checks the font file. I explain it in the section about fonts.

This is the policy in the built page of version 1.2.1, copied from dist/nosis-bunker.html:

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-DweVoL/tZBEAHL+6brrYYUgBm0w9dHzwWHjcbNNbmro='; style-src 'sha256-L5BUUmzuLgkXV30zs8PcXA2M+sX3ouXEhmE6fTKaeuI='; img-src data:; font-src data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; require-trusted-types-for 'script'; trusted-types 'none'">

The policy names no server at all. A test fails the build if it ever does.

Hashes: only the exact code runs

The easy way to allow an inline script is 'unsafe-inline'. That setting allows any inline script, so it also allows a script that an attacker injects.

Bunker uses hashes instead. A hash is a fingerprint of the text. If one character of the script changes, the fingerprint changes, and the browser refuses to run the script.

I tested this with the build function of the project. The script of version 1.2.1 has 159,408 characters. I computed its hash, then the hash of the same script with one space added at the end:

script hash:      DweVoL/tZBEAHL+6brrYYUgBm0w9dHzwWHjcbNNbmro=
in the policy:    true
one space added:  dkWtVYgM4gVFLcAzglJ1VOtOFe0R52bhpnA4DGGowf8=
in the policy:    false

One space gives a completely different hash. A browser that loads the changed page finds no match in the policy, so it runs no script at all.

Hashes have one more benefit. They need no server, so the policy also works when you open the file from your disk.

Text is always text

The page shows text that you type: addresses and labels. If a page puts that text into HTML, a label can run code. This attack is cross-site scripting (XSS).

For example, take the label <img src=x onerror=alert(1)>. Inside HTML, it makes a broken image, and the broken image runs alert(1). A real attack would run something worse.

The prototype built its cards with innerHTML and an escape function. One missed escape can cause XSS, and the project records this risk as P-18. The fix was to build every element with DOM methods. This is the helper, from src/ui/dom.js:

export function uiEl(tag, attrs = {}, children = []) {
  const node = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs)) {
    if (k === 'text') node.textContent = v;
    else if (k === 'class') node.className = v;
    else node.setAttribute(k, v);
  }
  for (const c of children) if (c !== null) node.append(c);
  return node;
}

The function works in three steps:

  1. document.createElement(tag) makes an empty element. No HTML parser reads any user text.
  2. textContent puts text into the element. The browser shows it as characters, so <img> stays the five characters “<img>”.
  3. append() adds child elements that were built the same way.

The project tested the result in Chromium. The label <img src=x onerror=alert(1)> showed as plain text, and the browser made no image.

Trusted Types add a second layer. With require-trusted-types-for 'script', the browser itself rejects HTML strings in sinks such as innerHTML. A sink is a place where text becomes code or markup.

Links that leak nothing

Each result card can link to a block explorer, a website that shows the transactions of an address. A link is a risk in two ways. A bad link can run code, and a normal link can tell the explorer where you came from.

Every link comes from one function, in src/explorer.js:

export function explorerUrl(entry) {
  if (entry.model === MODEL.XPUB || entry.noLink) return null;
  const chain = entry.chain ? CHAINS[entry.chain] : undefined;
  const template = entry.explorer !== undefined ? entry.explorer : chain && chain.explorer;
  if (!template) return null;
  const href = template.replace('{a}', encodeURIComponent(entry.address));
  try {
    const url = new URL(href);
    return url.protocol === 'https:' ? url.href : null;
  } catch {
    return null;
  }
}

The function has four safety steps:

  1. An extended public key (xpub) gets no link. An xpub shows the public key of every address in a wallet, so the page never sends it to a website.
  2. encodeURIComponent() encodes the address, so its characters cannot change the shape of the link.
  3. new URL() parses the result. Text that is not a valid web address goes to catch and returns nothing.
  4. Only https: passes. A javascript: link can never reach the page.

I called the function with three inputs: a Bitcoin address, a fake template that starts with javascript:, and an xpub entry. These are the real results:

https://mempool.space/address/bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
null
null

The page then adds rel="noopener noreferrer" to every link, and a test checks each one. The page also sets its referrer policy to no-referrer. So the explorer does not learn where you came from.

One limit remains, and the threat model states it. If you open an explorer link, that explorer receives one address. You choose to do this, and the page never opens a link by itself.

Fonts without a request

The first page loaded its fonts from Google Fonts. The page also said “nothing leaves your device”. The two did not fit together, because a font request sends your IP address and the page address to Google. The project records this as P-12.

The first fix, ADR-015, used only system fonts. The privacy promise became true, but the page looked different on each device. ADR-024 replaced it with a better solution:

  • One open-license font, Archivo, cut down to the Latin letters, including the Spanish ones.
  • The font file is inside the page as a data: URL. The browser makes no request.
  • The file has a pinned SHA-256 hash. The first line of the build excerpt above stops the build if the font file changes.

The font is in the style block, so the style hash in the policy also covers the font bytes.

A page that remembers nothing

Most sites remember your language and your theme in a cookie or in browser storage. Bunker stores nothing, and a test fails the build if the code uses storage.

So the page starts from the settings of your system. For the language, you can also add #es to the address, for example bunker.nosistech.com/#es. The part after # stays in your browser and never goes to the server.

The cost is small. A user who picks a different theme picks it again on the next visit.

What the web host must add

Some protections do not work in a <meta> tag. One example is frame-ancestors, which stops other sites from putting the page inside a frame. The web server must send these rules as HTTP headers.

The build writes them into site/_headers, a file that Cloudflare Pages reads. I asked the live site for its headers on October 27, 2026. These are some of the real lines:

$ curl -sSI https://bunker.nosistech.com/
Cache-Control: no-cache, no-transform
Strict-Transport-Security: max-age=63072000; includeSubDomains
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=(), clipboard-read=()
referrer-policy: no-referrer
x-content-type-options: nosniff
x-frame-options: DENY

The live header also repeats the full policy, with the same two hashes, and adds frame-ancestors 'none'. The browser applies both policies.

The no-transform value solves problem P-26. Some hosts change HTML on the way to the user. They minify scripts, add analytics or rewrite email addresses. A changed script does not match its hash, so the page stops working.

One option was to allow 'unsafe-inline', which never breaks. I rejected it, because it removes the main protection against XSS. A broken page is safer than a page that runs changed code.

So the page asks the host not to change it, and the hosting guide lists the Cloudflare features to turn off. Rocket Loader and Email Address Obfuscation are two of them.

The tests that hold the rules

A rule that nobody checks can break without notice. So the rules of this article are tests, in test/security.test.js. The tests run on the built page, not on the source files. These are the first two:

test('the page has no network code (privacy promise)', () => {
  for (const banned of ['fetch(', 'XMLHttpRequest', 'WebSocket', 'EventSource', 'sendBeacon', 'import(', 'Worker(']) {
    assert.ok(!code.includes(banned), 'found ' + banned);
  }
});

test('the page stores nothing in the browser', () => {
  for (const banned of ['localStorage', 'sessionStorage', 'indexedDB', 'document.cookie', 'caches.']) {
    assert.ok(!code.includes(banned), 'found ' + banned);
  }
});

Each test is a list of banned words. The first list names the browser functions that open a connection. The second names the browser storage functions. If any banned word appears in the code, the test fails, and the release stops.

The variable code is the built script with its comments removed. Comments may name a banned function to explain a rule, so the test skips them.

I ran the file on October 27, 2026. These are the result lines:

$ node --test test/security.test.js
ok 1 - the page has no network code (privacy promise)
ok 2 - the page stores nothing in the browser
ok 3 - no dangerous DOM sinks or code evaluation (OWASP DOM XSS cheat sheet)
ok 4 - Content Security Policy is strict and its hashes match
ok 5 - the HTML loads nothing from outside and has one script and one style block
ok 6 - every link in the code gets rel="noopener noreferrer"
ok 7 - the build is reproducible
# tests 8
# pass 8
# fail 0

Test 3 bans innerHTML, eval and the other dangerous functions. Test 4 checks the main rules of the policy and recomputes both hashes. The eighth test checks the text of the documentation, so I left it out of this list.

What the browser rules cannot cover

  • Your own device. Malware or a keylogger on your computer can read what you type. No web page can protect a compromised device.
  • Browser bugs. The policy depends on the browser. A flaw in the browser itself is outside the control of any page.
  • Fake copies. A scammer can host a copy of the page with a “verify your seed” box. Compare the SHA-256 hash of your file with the published hash.

Article 1 of this series shows how to check the hash on Windows, macOS and Linux.

Read the code and the records

In the next article, I show how Bunker keeps outside code out of the build itself. The project has zero dependencies, and its build tools refuse to run on a Node.js release with known flaws.

Have a question or feedback about Bunker? Contact NosisTech.

Keep reading: Bunker by NosisTech

Previous article: Never Paste a Seed Phrase: How Bunker Blocks 19 Kinds of Secrets

Next article: Zero Dependencies: How Bunker Keeps Outside Code Out of the Build

All articles: NosisTech Writing

This post is general information, not financial, legal or security advice. Bunker is an educational tool with no warranty. Examine each address on a block explorer, and send a small test amount before you move funds.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.