Warum sicher? Die einfache Fassung

> sicher --verbose

Keine Cloud.
Alles im Tab.

Deine Daten gehen niemanden etwas an. Auch mich nicht.

Keine Cloud heißt hier: Deine Dateien und Eingaben verlassen dein Gerät nicht. Vom Server kommt nur die Seite selbst.

Server Seite + Code Datei kein Upload bnkz.de 255044 462D31 %PDF-1
fig.00 Der Code kommt zu dir, deine Datei bleibt da

$ sicher --tldr

  1. Deine Dateien werden nicht hochgeladen. Der Tab liest sie direkt von deinem Gerät.
  2. PDFs, Rechnungen, Briefe, Bilder und Codes entstehen im Tab und kommen als blob:-Download zu dir.
  3. fetch, XHR, WebSocket, EventSource und sendBeacon verbietet dein Browser dieser Seite per CSP. Unten löst du die Sperre selbst aus.

01Bedrohungsmodell

Wovor die Bauweise schützt und wovor nicht

Netz Hosterich Gerät Schadsoftware Browser Erweiterungen CSP Tab sendenFormulareSkripteBilder
fig.01 Wo die CSP greift und wo nicht

Die innerste Grenze zieht dein Browser. Die Content-Security-Policy (CSP) legt fest, was diese Seite darf, und der Browser setzt sie gegen jeden Code im Dokument durch, auch gegen meinen (Web Worker: Abschnitt 07). Was außerhalb des Tabs passiert, regelt sie nicht.

Hält die CSP ab

  • Senden per fetch, XHR, WebSocket, EventSource oder sendBeacon
  • Skripte, die eingeschleust sind oder von fremden Servern kommen, dazu eval
  • Formulare, die an eine fremde Adresse senden
  • Bilder und Dateien von fremden Servern, also auch Tracker

Liegt außerhalb

  • Erweiterungen mit Zugriff auf alle Websites
  • Schadsoftware oder Bildschirmaufnahme auf deinem Gerät
  • Der Hoster sieht deine IP-Adresse und führt Server-Protokolle
  • Ich und was ich morgen hochlade: Code und Regel kommen beide von mir

Wo die CSP selbst Lücken hat, steht in Abschnitt 07.

02Datenfluss

Vom Dateidialog bis zum Download

üblich

dein Gerät Datei hoch Ergebnis Server

hier

dein Gerät Werkzeug kein Upload bnkz.de
fig.02 Üblich gegen hier

Ein übliches Online-Werkzeug schickt deine Datei auf einen fremden Rechner, macht dort die Arbeit und schickt das Ergebnis zurück. Hier ist es anders gebaut: Der Code kommt zu deiner Datei, nicht die Datei zum Code. Jeder Schritt unten läuft in deinem Tab.

Dein Browser-TabKeiner dieser Schritte schickt deine Daten ins Netz

  1. <input type="file">Du wählst die Datei aus oder ziehst sie hinein. Die Seite bekommt ein File-Objekt mit Name und Größe, keinen Pfad auf deiner Platte.
  2. await file.arrayBuffer()Die Bytes landen im Arbeitsspeicher des Tabs. Ein paar Werkzeuge lesen stattdessen per FileReader.
  3. PDFDocument.load(bytes)Die eigentliche Arbeit: pdf-lib, Canvas, WebCrypto oder eigener Code. Es rechnet der Prozessor deines Geräts.
  4. new Blob([bytes], { type })Das Ergebnis ist wieder ein Stück Arbeitsspeicher, diesmal mit Dateityp.
  5. URL.createObjectURL(blob)Daraus wird eine Adresse wie blob:https://bnkz.de/4ef6…. Sie zeigt in den Speicher deines Browsers, nicht ins Netz.
  6. <a href="blob:…" download>Ein Klick, und dein Browser legt die Datei im Download-Ordner ab oder fragt dich, wohin.
  7. URL.revokeObjectURL(url)Die Adresse wird wieder freigegeben. Schließt du den Tab, ist im Browser nichts mehr davon übrig.

tools/pdf-merge-split.js · PDF zusammenfügen · gekürzt, sonst wörtlich

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() · gekürzt

const u = URL.createObjectURL(blob);
…
urls.forEach((u) => URL.revokeObjectURL(u));
Wann die blob:-Adresse verschwindetBeim festen Download-Knopf: beim nächsten Ergebnis, bei „Neu beginnen“ oder wenn du die Seite verlässt. Beim Sofort-Download nach 1 bis 10 Sekunden. Ob die Datei wirklich gespeichert wurde, weiß die Seite nicht; das entscheidet dein Browser.

03Dokumente im Tab

Wie Rechnung, Foto und QR-Code bei dir entstehen

Drei echte Beispiele aus dem Code. Kein Dienst im Hintergrund rendert hier etwas: Jede Datei schreibt dein Tab Byte für Byte selbst, mit Bibliotheken, die von bnkz.de kommen.

y x 0|0 595,28 × 841,89 pt ∑ in Cent QR: re + f
fig.03a A4 in PDF-Punkten, Ursprung unten links

> /beleg/rechnung/

Rechnung als PDF mit pdf-lib

  • Eine Seite ist doc.addPage([SEITE.b, SEITE.h]) mit 595,28 × 841,89 Punkten: A4, 1 Punkt = 1/72 Zoll.
  • Der Ursprung liegt unten links. Der Code plant von oben und rechnet jede Zeile mit Y = SEITE.h - oben um.
  • Summen und Steuer rechnet der Code in ganzen Cent: Eingaben werden als Ziffernfolge gelesen (BigInt). Erst für den Text im GiroCode wird daraus ein Euro-Betrag mit zwei Nachkommastellen.
  • Der GiroCode ist kein Bild, sondern ein Pfad aus Rechtecken (PDF-Befehl re), einmal gefüllt (f). In jeder Größe scharf.
  • Schrift: Helvetica, eine der Standardschriften von PDF. Keine Schriftdatei, nichts aus dem Netz.
Code ansehen: lib/beleg.js

lib/beleg.js · baueBelegPdf() · gekürzt, sonst wörtlich

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 });

Auf Wunsch schreibt dasselbe Werkzeug die E-Rechnung als XRechnung (CII-XML), ebenfalls im Tab.

÷2 ÷2 drawImage toBlob GPS · EXIF fällt weg
fig.03b In Halbschritten verkleinert, Metadaten weg

> /bilder/verkleinern/

Foto neu gerechnet auf dem Canvas

  • createImageBitmap(file, { imageOrientation: 'from-image' }) dekodiert das Foto und dreht es so, wie die Kamera es gespeichert hat. Klappt das nicht, springt ein <img> mit kurzlebiger blob:-Adresse ein.
  • Verkleinert wird in Halbschritten auf einem <canvas> mit imageSmoothingQuality = 'high'. Das gibt beim starken Verkleinern sichtbar schärfere Ergebnisse.
  • Für JPEG malt der Code zuerst eine weiße Fläche, weil JPEG keine Transparenz kennt.
  • canvas.toBlob(res, type, q) schreibt eine ganz neue Datei. Die Leinwand kennt nur Pixel: GPS- und Kameradaten des Originals fallen dabei weg.
Code ansehen: tools/bilder.js

tools/bilder.js · decode(), draw(), convert() · gekürzt, sonst wörtlich

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 Reihe: M20 20h9 v1h-9z Version 2, Stufe M Rand: 4
fig.03c Echter Code für https://bnkz.de

> /qr/code/

QR-Code als SVG, Reihe für Reihe

  • qrcode(0, 'M') aus qrcode-generator: Größe automatisch, Fehlerkorrektur Stufe M. Umlaute kommen über stringToBytesFuncs['UTF-8'] richtig an.
  • matrixToPath() fasst jede Folge dunkler Module einer Reihe zu einem Rechteck zusammen: M x y h Länge v1 h-Länge z. Ein einziger Pfad für den ganzen Code, dazu 4 Module Rand.
  • Die SVG-Datei ist Text, der im Tab als Blob vom Typ image/svg+xml entsteht. Das PNG daneben zeichnet dieselbe Matrix auf ein <canvas>, mit mindestens 1024 px Kantenlänge.
  • Die Grafik ist echt: der Code für https://bnkz.de, erzeugt mit derselben Bibliothek und demselben Pfad. Version 2 heißt 25 × 25 Module.
Code ansehen: tools/qr.js

tools/qr.js · render() · gekürzt, sonst wörtlich

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() · gekürzt, sonst wörtlich

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 Was sonst noch im Tab arbeitet

Die Bausteine im Tab

  • pdf-lib 1.17.1 (MIT) schreibt PDFs. An einer Stelle gepatcht, das steht in LICENSES.txt.
  • pdf.js 4.2.67 (Apache-2.0, von Mozilla) zeigt PDFs an. Jeder Aufruf setzt isEvalSupported: false und bekommt die Datei als Bytes, nie als Adresse.
  • qrcode-generator 1.4.4 (MIT) schreibt QR-Codes, jsQR 1.4.0 (Apache-2.0) liest sie.
  • Canvas 2D und createImageBitmap tragen die Bildwerkzeuge.
  • WebCrypto: Verschlüsseln mit AES-GCM 256, Schlüssel per PBKDF2-SHA-256 mit 600.000 Runden, Zufall aus crypto.getRandomValues.
  • Deflate für ZIP, XLSX und Office-Dateien kommt aus dem Browser (CompressionStream('deflate-raw')), das Gerüst drumherum schreibt eigener Code ohne Bibliothek. pdf-lib bringt für PDF-Streams sein eigenes Deflate mit.
  • Worker: /text/regex/ rechnet in einem Web Worker und beendet ihn, wenn ein Auftrag länger als 1,5 Sekunden rechnet. Ein Muster, das ewig probiert, hält dann nur den Worker fest, nicht die Seite.

Versionen und Lizenzen stehen in LICENSES.txt, alle Bibliotheken liegen unter /assets/vendor/ auf bnkz.de.

04Header im Wortlaut

Die Regeln, Zeile für Zeile

fetch() XHR WebSocket sendBeacon EventSource CSP Netz diese Seite connect-src 'none'
fig.04 connect-src 'none' gegen alle fünf

Die Regel steht doppelt da: als <meta> im Kopf jeder Seite und als HTTP-Header aus der .htaccess. Kommen beide an, setzt der Browser beide durch, es gilt jeweils die strengere.

Zeile 6 dieser Seite (Strg+U zeigt sie dir)

<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'Alles ohne eigene Regel, etwa Rahmen und Schriften, darf nur von bnkz.de kommen.
  • script-src 'self'Nur Skriptdateien von hier. Kein Inline-Skript, kein onclick-Attribut, kein eval, kein new Function, kein WebAssembly (dafür fehlt 'wasm-unsafe-eval'). Worker aus blob: fallen ebenfalls darunter.
  • connect-src 'none'Kein fetch, kein XMLHttpRequest, kein WebSocket, keine EventSource, kein sendBeacon. Auch nicht zu bnkz.de selbst.
  • img-src 'self' blob: data:Bilder von bnkz.de oder direkt aus dem Arbeitsspeicher: so erscheint die Vorschau deiner Datei, ohne dass sie irgendwohin geht. Bildaufrufe an bnkz.de selbst bleiben damit erlaubt (Abschnitt 07).
  • style-src 'self'Nur CSS-Dateien von hier. <style>-Blöcke und style-Attribute sperrt der Browser; element.style aus einem Skript nicht.
  • object-src 'none'Kein <object> und kein <embed>, nicht einmal mit einer eigenen Datei.
  • base-uri 'none'Kein <base>, das alle Verweise umbiegt. Fällt nicht auf default-src zurück und steht deshalb ausdrücklich da.
  • form-action 'none'Kein Formular darf absenden. Einzige Ausnahme ist /wunsch/ mit 'self': das Wunsch-Formular, das du selbst abschickst. Auch diese Direktive hat keinen Rückfall auf default-src.
  • frame-ancestors 'none'Nur im Header: Keine fremde Seite darf diese einbetten. Im <meta> werten Browser das nicht aus.
.htaccess: die Header im Wortlaut

site/.htaccess · Zeilen 24 bis 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: Der Browser nimmt den angegebenen Dateityp ernst und rät nicht, ob eine Textdatei vielleicht ein Skript ist.
  • Referrer-Policyno-referrer, dazu dasselbe als <meta> in jeder Seite: Wer von hier weggeht, nimmt keine Herkunftsadresse mit.
  • Permissions-PolicySoll Kamera, Mikrofon und Standort sperren. Das wirkt nur, wenn der Header ankommt (siehe Hinweis unten). Der Code fragt diese Rechte ohnehin nie an: getUserMedia und geolocation kommen nicht vor. /pdf/scannen/ öffnet die Kamera nur über <input capture>, das Foto kommt wie eine gewählte Datei an. interest-cohort betrifft das eingestellte FLoC von Google und steht nur noch der Vollständigkeit halber drin.
  • Strict-Transport-SecurityEin Jahr lang nur HTTPS. Ohne includeSubDomains, ohne preload und nur, wenn die Anfrage schon per HTTPS kam.
Bekannte Grenze beim HosterNach meinem Stand liefert der Hoster HTML, CSS, JS und Bilder direkt über einen nginx aus, an der .htaccess vorbei. Dann kommen bei genau diesen Dateien die Header nicht an. Die CSP wirkt im Dokument trotzdem, weil sie im <meta> steht, in Web Workern aber nicht (Abschnitt 07). frame-ancestors, HSTS, Permissions-Policy und nosniff lassen sich per <meta> aber nicht setzen. Was wirklich ankommt, zeigt dir curl -I (Abschnitt 08).

05Live-Selbsttest

Löse die Sperre selbst aus

Der Knopf versucht fünfmal, etwas zu senden: per fetch, XMLHttpRequest, WebSocket, sendBeacon und EventSource. Das Ziel ist ein Pfad auf dieser Adresse (/sicher/technik/probe/…), gesendet wird kein Inhalt. Selbst ohne CSP ginge also keine Datei und keine Eingabe von dir mit, nur eine leere Anfrage an den eigenen Server, wie beim Laden der Seite.

XHR fetch WebSocket sendBeacon EventSource bereit 5 Versuche
fig.05 Jeder Punkt ein Sendeversuch

Die Meldungen stammen aus dem Ereignis securitypolicyviolation, das dein Browser auslöst, wenn er einen Versuch verwirft. In Chrome gibt sendBeacon dabei trotzdem true zurück: Das heißt nur „angenommen“, nicht „gesendet“. Gegenprobe ohne diese Seite: In der Konsole (F12) schreibt dein Browser zu jedem Versuch selbst eine Zeile, in Chrome etwa „Connecting to … violates the following Content Security Policy directive: connect-src 'none'“.

06Speicher

Was diese Seite bei dir ablegt: kein Cookie, kein Web-Speicher

document.cookielocalStoragesessionStorageIndexedDBCache StorageService Worker 000000
fig.06 Sechs Fächer, alle leer
  • Cookies: Besucher bekommen keine. document.cookie ist leer.
  • localStorage und sessionStorage: Kein Skript schreibt hinein, und auf bnkz.de liest auch keins. Die einzige Codezeile, die localStorage überhaupt anspricht, ist ein Testschalter für die Entwicklung, der nur auf localhost gelesen wird.
  • IndexedDB, Cache Storage, Service Worker kommen im Code nicht vor. Nachgemessen: indexedDB.databases() und caches.keys() liefern [], serviceWorker.getRegistrations() keinen Eintrag.
  • Deshalb gibt es auch keinen Offline-Modus: ein Web-App-Manifest ja, einen Service Worker nein.
  • Was trotzdem bei dir liegt: der normale HTTP-Zwischenspeicher deines Browsers mit den Dateien der Seite (wie lange, bestimmen die Header des Servers) und natürlich deine Downloads.

Selbst nachsehen, in der Konsole (F12)

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

Eine frühere Fassung der Seite hat sich unter localStorage["bnkz-theme"] den hellen oder dunklen Modus gemerkt. Findest du diesen Eintrag noch, stammt er von damals; heute fasst ihn kein Code mehr an.

07Grenzen

Was diese CSP nicht verhindert

Tab CSP außen fetch & Co. SeitenwechselWebRTCWorker ohne HeaderBild an bnkz.de
fig.07 Die Lücken dieser Regel

connect-src sperrt fetch, XHR, WebSocket, EventSource, sendBeacon und ein paar weitere Schnittstellen, aber nicht jeden Weg nach draußen. Ehrlich bleiben heißt, auch die anderen zu nennen, und zu sagen, was der Code hier damit macht.

Geht mit keiner CSP

  • Seitenwechsel, window.open und angeklickte Verweise fallen nicht unter die CSP, auch nicht mit Daten in der Adresse; navigate-to kennt Chrome nicht mehr. Der Code hier ruft window.open nirgends auf und wechselt selbst nur zu eigenen Seiten, etwa bei Enter in der Suche.
  • WebRTC regelt keine CSP-Direktive: RTCPeerConnection könnte trotz connect-src 'none' UDP-Pakete schicken. Im Code hier kommt es nicht vor. Dasselbe gilt für rel=preconnect, das eine Verbindung aufbaut, ohne dass die CSP etwas meldet: keine Seite enthält eins.
  • Alles außerhalb des Tabs: Erweiterungen mit Zugriff auf alle Websites, Schadsoftware und Bildschirmaufnahmen auf deinem Gerät. Der Hoster sieht bei jedem Aufruf deine IP-Adresse und führt Server-Protokolle.
  • Und ich. Code und Regel lade ich beide selbst hoch. Wer hochlädt, kann beides ändern. Deine Prüfung gilt für den Stand, den du gerade vor dir hast. Deshalb ist Nachprüfen besser als jedes Versprechen von mir.

Ginge, ist hier aber offen

  • Bildaufrufe an bnkz.de selbst erlaubt img-src 'self', und eine Bildadresse kann Text tragen. Eine Regel ohne 'self' würde das sperren; hier bleibt der Weg bewusst offen. Welche Adresse der Code auf diesem Weg aufruft, zeigt Abschnitt 08 (Schritt 1).
  • Web Worker erben die <meta>-CSP nicht, dort gilt nur ein Header an der Worker-Datei. Diesen Header liefert der Hoster nach meinem Stand für JS-Dateien nicht aus (Abschnitt 04). In den Workern gilt dann gar keine CSP; dass dort nichts sendet, liegt am Code, nicht an einer Regel. Die beiden Worker hier: pdf.js bekommt die PDF als Bytes, nie als Adresse, und im Regex-Worker (regex-worker.js samt den Dateien, die er lädt) steht kein Netzwerk-Code.
  • frame-ancestors wirkt nur als Header. Kommt der nicht an (Abschnitt 04), kann eine fremde Seite diese hier einbetten.

Der Selbsttest oben ist übrigens die einzige Stelle im eigenen Code, die fetch, XMLHttpRequest, WebSocket, sendBeacon und EventSource überhaupt aufruft: um zu zeigen, dass sie hier nicht durchkommen.

08Nachprüfen

Prüf es selbst, ganz ohne mich

Netzwerk Konsole NameTyp technik/pages.cssui.js p.php SeiteCSSJSBild deine-datei.pdf nie
fig.08 Der Netzwerk-Tab, vereinfacht
  1. F12, Reiter „Netzwerk“Dann ein Werkzeug benutzen. Außer der Seite selbst mit ihren Stilangaben und Skripten siehst du nur einen anonymen Seitenzähler ohne Cookie (p.php), Einzelheiten im Datenschutz. Deine Datei ist nicht dabei, der Download hat eine blob:-Adresse. Blockierte Versuche stehen dort unter Umständen auch, als blockiert markiert.
  2. KonsoleZeigt die Regel und die Sperre, ohne dass diese Seite mitredet.
  3. Seitenquelltext, Strg+UZeile 6 ist die CSP. Sie steht in jeder Seite an derselben Stelle, vor jeder CSS-Datei und jedem Skript.
  4. curl -IZeigt die Header, die der Server wirklich schickt. Fehlt dort die CSP, gilt die aus dem <meta>.
  5. Code lesenDer eigene Code ist nicht minifiziert und hat keine Source-Maps. Minifiziert sind nur die Bibliotheken pdf-lib und pdf.js; der Suchindex ist eine erzeugte Datenzeile.

Konsole (F12)

document.querySelector('meta[http-equiv="Content-Security-Policy"]').content
// in Chrome: TypeError: Failed to fetch, dazu die Meldung zu connect-src
fetch('/')

Terminal

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

Genug Technik?
Die kurze Fassung für alle steht auf /sicher/.