Is it safe? The simple version

> safe --verbose

No cloud.
Everything in the tab.

Your data is nobody’s business. Not even mine.

No cloud means: Your files and entries do not leave your device. Only the page itself comes from the server.

Server Page + Code File no upload bnkz.de 255044 462D31 %PDF-1
fig.00 The code comes to you, your file stays there

$ safe --tldr

  1. Your files are not uploaded. The tab reads them directly from your device.
  2. PDFs, invoices, letters, images and codes are created in the tab and reach you as a blob: download.
  3. Your browser forbids this page to use fetch, XHR, WebSocket, EventSource and sendBeacon, through the CSP. Below, you trigger the block yourself.

01Threat model

What the design protects against and what it does not

Network Hostme Device Malware Browser Extensions CSP Tab sendFormsScriptsImages
fig.01 Where the CSP applies and where it does not

Your browser draws the innermost boundary. The Content Security Policy (CSP) defines what this page may do, and the browser enforces it against every piece of code in the document, including mine (web workers: section 07). What happens outside the tab, it does not control.

The CSP stops

  • Sending via fetch, XHR, WebSocket, EventSource or sendBeacon
  • Scripts that are injected or come from foreign servers, plus eval
  • Forms that submit to a foreign address
  • Images and files from foreign servers, so also trackers

Lies outside

  • Extensions with access to all websites
  • Malware or screen capture on your device
  • The hosting provider sees your IP address and keeps server logs
  • I and what I upload tomorrow: Code and rule both come from me

Where the CSP itself has gaps is explained in section 07.

02Data flow

From the file dialog to the download

usual

your device file goes up result server

here

your device tool no upload bnkz.de
fig.02 Usual versus here

A usual online tool sends your file to a foreign computer, does the work there and sends the result back. Here it is built differently: The code comes to your file, not the file to the code. Every step below runs in your tab.

Your browser tabNone of these steps sends your data to the network

  1. <input type="file">You choose the file or drag it in. The page gets a File object with name and size, not a path on your disk.
  2. await file.arrayBuffer()The bytes land in the memory of the tab. A few tools read with FileReader instead.
  3. PDFDocument.load(bytes)The actual work: pdf-lib, canvas, WebCrypto or own code. The processor of your device does the calculating.
  4. new Blob([bytes], { type })The result is again a piece of memory, this time with a file type.
  5. URL.createObjectURL(blob)This becomes an address like blob:https://bnkz.de/4ef6…. It points into the memory of your browser, not into the network.
  6. <a href="blob:…" download>One click, and your browser puts the file in the downloads folder or asks you where.
  7. URL.revokeObjectURL(url)The address is released again. If you close the tab, nothing of it is left in the browser.

tools/pdf-merge-split.js · merge PDF · shortened, otherwise verbatim (the strings and comments are German in the original)

item.doc = await PDFDocument.load(await file.arrayBuffer());
…
const out = await PDFDocument.create();
for (const f of st.files) {
  const copied = await out.copyPages(f.doc, f.doc.getPageIndices());
  copied.forEach((p) => out.addPage(p));
  …
}
const bytes = await out.save();
finish(new Blob([bytes], { type: 'application/pdf' }), 'bnkz-zusammengefuegt.pdf', `${plural(total, 'Seite', 'Seiten')} in einer Datei`, 'PDF herunterladen');
…
dl.href = urls.make(blob);
dl.download = name;

ui.js · urlBag() · shortened

const u = URL.createObjectURL(blob);
…
urls.forEach((u) => URL.revokeObjectURL(u));
When the blob: address disappearsWith the fixed download button: at the next result, at “Start over” or when you leave the page. With an instant download: after 1 to 10 seconds. Whether the file was really saved, the page does not know. Your browser decides that.

03Documents in the tab

How an invoice, a photo and a QR code come about on your device

Three real examples from the code. No service in the background renders anything here: Your tab writes every file byte by byte itself, with libraries that come from bnkz.de.

y x 0|0 595.28 × 841.89 pt ∑ in cents QR: re + f
fig.03a A4 in PDF points, origin at the bottom left

> /beleg/rechnung/

Invoice as a PDF with pdf-lib

  • A page is doc.addPage([SEITE.b, SEITE.h]) with 595.28 × 841.89 points: A4, 1 point = 1/72 inch.
  • The origin is at the bottom left. The code plans from the top and converts every line with Y = SEITE.h - oben.
  • The code calculates totals and tax in whole cents: Inputs are read as a sequence of digits (BigInt). Only for the text in the EPC QR code (GiroCode) does this become a euro amount with two decimal places.
  • The EPC QR code is not an image but a path of rectangles (PDF command re), filled once (f). Sharp at any size.
  • Font: Helvetica, one of the standard fonts of PDF. No font file, nothing from the network.
View code: lib/beleg.js

lib/beleg.js · baueBelegPdf() · shortened, otherwise verbatim (the comments are German in the original)

const doc = await PDFDocument.create();
const fonts = { n: await doc.embedFont(StandardFonts.Helvetica), b: await doc.embedFont(StandardFonts.HelveticaBold) };
…
  const page = doc.addPage([SEITE.b, SEITE.h]);
  const Y = (oben) => SEITE.h - oben;
  …
    if (o.t === 'text') page.drawText(o.s, { x: o.x, y: Y(o.y), size: o.size, font: fonts[o.fett ? 'b' : 'n'], color: farbe(o.farbe) });
    …
    else if (o.t === 'qr') {
      // Vektor-Rechtecke: ein Pfad, eine Füllung. Kein Bild, in jeder Größe scharf, keine Nähte zwischen den Modulen.
      const n = o.matrix.length;
      const m = o.groesse / n;
      const ops = [pushGraphicsState(), setFillingRgbColor(0, 0, 0)];
      for (const l of qrLaeufe(o.matrix)) ops.push(rectangle(o.x + l.c * m, Y(o.y + (l.r + 1) * m), l.len * m, m));
      ops.push(fill(), popGraphicsState());
      page.pushOperators(...ops);
    }
…
const bytes = await doc.save({ useObjectStreams: false });

On request, the same tool writes the e-invoice (E-Rechnung) as an XRechnung (CII XML), also in the tab.

÷2 ÷2 drawImage toBlob GPS · EXIF dropped
fig.03b Reduced in half steps, metadata gone

> /en/images/resize/

Photo recalculated on the canvas

  • createImageBitmap(file, { imageOrientation: 'from-image' }) decodes the photo and rotates it the way the camera stored it. If that does not work, an <img> with a short-lived blob: address steps in.
  • It is reduced in half steps on a <canvas> with imageSmoothingQuality = 'high'. For strong reductions, this gives visibly sharper results.
  • For JPEG, the code first paints a white area, because JPEG has no transparency.
  • canvas.toBlob(res, type, q) writes a completely new file. The canvas only knows pixels: GPS and camera data of the original are dropped.
View code: tools/bilder.js

tools/bilder.js · decode(), draw(), convert() · shortened, otherwise verbatim (the comments are German in the original)

return await createImageBitmap(file, { imageOrientation: 'from-image' });
…
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(src, 0, 0, w, h);
…
// In Schritten halbieren: gibt beim starken Verkleinern sichtbar schärfere Ergebnisse
let cur = src, cw = sw, ch = sh;
while (cw / 2 > w) {
  cw = Math.round(cw / 2);
  ch = Math.round(ch / 2);
  cur = draw(cur, cw, ch, false);
}
const cv = draw(cur, w, h, type === 'image/jpeg');
if (src.close) src.close();
const blob = await new Promise((res) => cv.toBlob(res, type, q));
1 row: M20 20h9 v1h-9z Version 2, level M Margin: 4
fig.03c Real code for https://bnkz.de

> /en/qr/code/

QR code as SVG, row by row

  • qrcode(0, 'M') from qrcode-generator: automatic size, error correction level M. Special characters such as umlauts arrive correctly through stringToBytesFuncs['UTF-8'].
  • matrixToPath() merges each run of dark modules in a row into one rectangle: M x y h length v1 h-length z. A single path for the whole code, plus a 4 module margin.
  • The SVG file is text that is created in the tab as a blob of type image/svg+xml. The PNG next to it draws the same matrix on a <canvas>, with an edge length of at least 1024 px.
  • The graphic is real: the code for https://bnkz.de, made with the same library and the same path. Version 2 means 25 × 25 modules.
View code: tools/qr.js

tools/qr.js · render() · shortened, otherwise verbatim (the comments are German in the original)

qr = qrcode(0, 'M'); // Größe automatisch, Fehlerkorrektur mittel
qr.addData(data, 'Byte');
qr.make();
…
const { d, size } = matrixToPath(n, (r, c) => qr.isDark(r, c));
…
dlSvg.href = urls.make(new Blob([svgText], { type: 'image/svg+xml' }));

lib/qr-payloads.js · matrixToPath() · shortened, otherwise verbatim

while (e < count && isDark(r, e)) e++;
d += `M${c + quiet} ${r + quiet}h${e - c}v1h-${e - c}z`;
Tab pdf-libpdf.jsCanvas 2DWebCrypto qrcodejsQRDeflateWorker
fig.03d What else works in the tab

The building blocks in the tab

  • pdf-lib 1.17.1 (MIT) writes PDFs. Patched in one place, as stated in LICENSES.txt.
  • pdf.js 4.2.67 (Apache-2.0, from Mozilla) displays PDFs. Every call sets isEvalSupported: false and gets the file as bytes, never as an address.
  • qrcode-generator 1.4.4 (MIT) writes QR codes, jsQR 1.4.0 (Apache-2.0) reads them.
  • Canvas 2D and createImageBitmap carry the image tools.
  • WebCrypto: encryption with AES-GCM 256, key via PBKDF2-SHA-256 with 600,000 rounds, randomness from crypto.getRandomValues.
  • Deflate for ZIP, XLSX and Office files comes from the browser (CompressionStream('deflate-raw')), and the framework around it is written by own code without a library. pdf-lib brings its own Deflate for PDF streams.
  • Worker: /en/text/regex/ calculates in a web worker and ends it when a job runs longer than 1.5 seconds. A pattern that keeps trying forever then only ties up the worker, not the page.

Versions and licenses are listed in LICENSES.txt, and all libraries are in /assets/vendor/ on bnkz.de.

04Headers word for word

The rules, line by line

fetch() XHR WebSocket sendBeacon EventSource CSP net this page connect-src 'none'
fig.04 connect-src 'none' against all five

The rule appears twice: as a <meta> in the head of every page and as an HTTP header from the .htaccess. If both arrive, the browser enforces both, and the stricter one applies in each case.

Line 6 of this page (Ctrl+U shows it to you)

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
  • default-src 'self'Everything without its own rule, such as frames and fonts, may only come from bnkz.de.
  • script-src 'self'Only script files from here. No inline script, no onclick attribute, no eval, no new Function, no WebAssembly ('wasm-unsafe-eval' is missing for that). Workers from blob: fall under it as well.
  • connect-src 'none'No fetch, no XMLHttpRequest, no WebSocket, no EventSource, no sendBeacon. Not even to bnkz.de itself. The only exception is /en/speedtest/ with 'self': A speed test has to exchange data with my server. Foreign hosts stay blocked there too.
  • img-src 'self' blob: data:Images from bnkz.de or straight from memory: this is how the preview of your file appears without it going anywhere. Image requests to bnkz.de itself remain allowed this way (section 07).
  • style-src 'self'Only CSS files from here. The browser blocks <style> blocks and style attributes, but not element.style from a script.
  • object-src 'none'No <object> and no <embed>, not even with a file of its own.
  • base-uri 'none'No <base> that redirects all links. It does not fall back to default-src and is therefore listed explicitly.
  • form-action 'none'No form may submit. The only exception is /en/wish/ with 'self': the suggestion form that you send yourself. This directive also has no fallback to default-src.
  • frame-ancestors 'none'Only in the header: No foreign page may embed this one. In the <meta>, browsers do not evaluate it.
.htaccess: the headers word for word

site/.htaccess · lines 24 to 28

Header always set Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "no-referrer"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
  • X-Content-Type-Optionsnosniff: The browser takes the stated file type seriously and does not guess whether a text file might be a script.
  • Referrer-Policyno-referrer, plus the same as a <meta> in every page: Whoever leaves from here takes no origin address along.
  • Permissions-PolicyIs meant to block camera, microphone and location. This only works if the header arrives (see the note below). There is a single exception: On /dateien/bildschirm/ the page may ask for the microphone, but only if you choose “With microphone” there. For that, this folder has its own .htaccess with microphone=(self). A voice goes only into the recording on your device, nothing is sent. getUserMedia appears exactly once in the whole code, only for sound, never for the camera. Everywhere else the code never requests these permissions: navigator.geolocation does not appear. /en/safe/browser/ only reads the state of these permissions with permissions.query, without asking. /pdf/scannen/ opens the camera only through <input capture>, the photo arrives like a chosen file. interest-cohort concerns the discontinued FLoC of Google and is only listed for completeness.
  • Strict-Transport-SecurityHTTPS only for one year. Without includeSubDomains, without preload and only if the request already came via HTTPS.
Known limit at the hosting providerAs far as I know, the hosting provider delivers HTML, CSS, JS and images directly through an nginx, bypassing the .htaccess. Then the headers do not arrive for exactly these files. The CSP still works in the document, because it is in the <meta>, but not in web workers (section 07). frame-ancestors, HSTS, Permissions-Policy and nosniff cannot be set via <meta>, though. What really arrives is shown by curl -I (section 08).

05Live self-test

Trigger the block yourself

The button tries five times to send something: with fetch, XMLHttpRequest, WebSocket, sendBeacon and EventSource. The target is a path on this address (/sicher/technik/probe/…), and no content is sent. Even without a CSP, no file and no input of yours would go along, only an empty request to the own server, as when the page loads.

XHR fetch WebSocket sendBeacon EventSource ready 5 attempts
fig.05 Every dot is one attempt to send

The messages come from the securitypolicyviolation event, which your browser fires when it discards an attempt. In Chrome, sendBeacon still returns true: That only means “accepted”, not “sent”. Counter-check without this page: In the console (F12), your browser itself writes a line for every attempt, in Chrome something like “Connecting to … violates the following Content Security Policy directive: connect-src 'none'”.

06Storage

What this page stores on your device: no cookie, no web storage

document.cookielocalStoragesessionStorageIndexedDBCache StorageService Worker 000000
fig.06 Six compartments, all empty
  • Cookies: Visitors get none. document.cookie is empty.
  • localStorage and sessionStorage: No script writes to them, and on bnkz.de none reads from them either. The only line of code that touches localStorage at all is a test switch for development that is only read on localhost.
  • IndexedDB, Cache Storage, Service Worker do not appear in the code. Measured: indexedDB.databases() and caches.keys() return [], serviceWorker.getRegistrations() returns no entry.
  • That is why there is no offline mode either: a web app manifest yes, a service worker no.
  • What still sits on your device: the normal HTTP cache of your browser with the files of the page (how long is determined by the headers of the server) and of course your downloads.

Check for yourself, in the console (F12)

// returns ''
document.cookie
// returns []
await indexedDB.databases()
// returns []
await caches.keys()
// returns 0
(await navigator.serviceWorker.getRegistrations()).length

An earlier version of the page remembered the light or dark mode under localStorage["bnkz-theme"]. If you still find this entry, it dates from back then. Today no code touches it anymore.

07Limits

What this CSP does not prevent

Tab CSP outside fetch & co. NavigationWebRTCWorker, no headerImage to bnkz.de
fig.07 The gaps in this rule

connect-src blocks fetch, XHR, WebSocket, EventSource, sendBeacon and a few other interfaces, but not every way out. Staying honest means naming the others too, and saying what the code here does with them.

Possible with no CSP

  • Navigation, window.open and clicked links are not covered by the CSP, not even with data in the address. Chrome no longer knows navigate-to. The code here calls window.open nowhere and itself only navigates to its own pages, for example on Enter in the search.
  • WebRTC is not governed by any CSP directive: RTCPeerConnection could send UDP packets despite connect-src 'none'. It does not appear in the code here. The same goes for rel=preconnect, which opens a connection without the CSP reporting anything: no page contains one.
  • Everything outside the tab: extensions with access to all websites, malware and screen recordings on your device. The hosting provider sees your IP address with every request and keeps server logs.
  • And me. I upload both code and rule myself. Whoever uploads can change both. Your check applies to the version you have in front of you right now. That is why checking for yourself is better than any promise from me.

Would work, but is open here

  • Image requests to bnkz.de itself are allowed by img-src 'self', and an image address can carry text. A rule without 'self' would block that. Here the path is deliberately left open. Which address the code calls on this path is shown in section 08 (step 1).
  • Web workers do not inherit the <meta> CSP, there only a header on the worker file applies. As far as I know, the hosting provider does not deliver this header for JS files (section 04). In the workers, then, no CSP applies at all. That nothing sends there is due to the code, not to a rule. The two workers here: pdf.js gets the PDF as bytes, never as an address, and in the regex worker (regex-worker.js including the files it loads) there is no network code.
  • frame-ancestors only works as a header. If it does not arrive (section 04), a foreign page can embed this one.

By the way, the self-test above is the only place in the own code that calls WebSocket, sendBeacon and EventSource at all, and besides the speed test the only one with fetch and XMLHttpRequest: the self-test to show that they do not get through here, the speed test because it cannot measure anything without a counterpart. It talks only to bnkz.de and sends only random data.

08Verify

Check it yourself, entirely without me

Network Console NameType tech/pages.cssui.js p.php PageCSSJSImage your-file.pdf never
fig.08 The Network tab, simplified
  1. F12, “Network” tabThen use a tool. Besides the page itself with its styles and scripts, you see only an anonymous page counter without a cookie (p.php), details on the privacy page. Your file is not there, the download has a blob: address. Blocked attempts may also show up there, marked as blocked.
  2. ConsoleShows the rule and the block, without this page having a say.
  3. Page source, Ctrl+ULine 6 is the CSP. It is in the same place on every page, before every CSS file and every script.
  4. curl -IShows the headers that the server really sends. If the CSP is missing there, the one from the <meta> applies.
  5. Read the codeThe own code is not minified and has no source maps. Only the libraries pdf-lib and pdf.js are minified. The search index is a generated line of data.

Console (F12)

document.querySelector('meta[http-equiv="Content-Security-Policy"]').content
// in Chrome: TypeError: Failed to fetch, plus the message about connect-src
fetch('/')

Terminal

$ curl -sI https://bnkz.de/sicher/technik/
$ curl -s https://bnkz.de/assets/js/tools/sicher-technik.js

Enough tech?
The short version for everyone is on /en/safe/.

Related: What is not here, and why (German) · Is it safe?