Files
Hellion-NewTab/docs/superpowers/specs/2026-04-16-hardening-v2.0.1-design.md
T
JonKazama-Hellion 63825cd393 docs(spec): Hardening v2.0.1 Design-Dokument erstellen
Audit-basierte Spec für Security, Stability, i18n und Code-Quality Fixes.
Foundation-First-Strategie: Event-System → Widget-Migration → Security → i18n → Quality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:25:03 +02:00

12 KiB

Hellion NewTab v2.0.1 — Hardening Release Design

Datum: 2026-04-16 Autor: Florian Wathling / Claude Code Status: Approved Scope: Security, Stability, i18n, Code Quality Strategie: Foundation First (Event-System zuerst, dann darauf aufbauen)


Kontext

Umfassender Audit von v2.0.0 hat Findings in vier Kategorien ergeben:

  • 3 Sicherheitslücken (HOCH)
  • 2 Stabilitätsprobleme (Race Conditions)
  • 8 fehlende i18n-Attribute
  • 3 Code-Qualität-Items

Dieses Design beschreibt alle Fixes als zusammenhängendes Hardening-Release.


Sektion 1: Widget Event-System

Problem

Calculator (calculator.js:692-728), Timer (timer.js:723-758) und ImageRef (image-ref.js:463-498) überschreiben WidgetManager.close, .minimize und .openWidget durch Monkey-Patching in ihrer init(). Das erzeugt eine 3-stufige Closure-Kette pro Methode. Funktional korrekt, aber fragil und schwer debugbar.

Lösung

WidgetManager bekommt ein internes Event-System basierend auf EventTarget.

Neue API in widgets.js:

_emitter: new EventTarget(),

on(event, handler) {
  this._emitter.addEventListener(event, handler);
},

off(event, handler) {
  this._emitter.removeEventListener(event, handler);
},

Events:

Event Feuert nach Detail
widget:close entry.el.remove() + _widgets.delete(id) { id }
widget:minimize State-Änderung + Animation + Save { id }
widget:open State-Änderung + Display-Reset + Save { id }

Migration der Widget-Module:

Das gesamte Monkey-Patching wird ersetzt durch WidgetManager.on() Aufrufe:

// Beispiel: Calculator.init()
WidgetManager.on('widget:close', (e) => {
  if (e.detail.id === self.WIDGET_ID) self.onClose();
});
WidgetManager.on('widget:minimize', (e) => {
  if (e.detail.id === self.WIDGET_ID) {
    self._isOpen = false;
    self.save();
  }
});
WidgetManager.on('widget:open', (e) => {
  if (e.detail.id === self.WIDGET_ID) {
    self._isOpen = true;
    const body = WidgetManager.getBody(self.WIDGET_ID);
    if (body && body.children.length === 0) self.renderBody(body);
    self.save();
  }
});

ImageRef folgt dem gleichen Pattern, prüft aber per self._images.some(img => img.id === id) statt gegen eine feste WIDGET_ID.

Load-Order: Kein Problem. widgets.js wird vor allen Widget-Modulen geladen. Die Module rufen WidgetManager.on() in ihrer init() auf, die erst in app.js aufgerufen wird.

Betroffene Dateien

  • src/js/widgets.js — Event-System hinzufügen, Events in close/minimize/openWidget dispatchen
  • src/js/calculator.js — Monkey-Patching (Z. 692-728) durch Event-Listener ersetzen
  • src/js/timer.js — Monkey-Patching (Z. 723-758) durch Event-Listener ersetzen
  • src/js/image-ref.js — Monkey-Patching (Z. 463-498) durch Event-Listener ersetzen

Sektion 2: Minimize-Animation mit transitionend

Problem

WidgetManager.minimize() (widgets.js:154-163) setzt display: none nach 250ms setTimeout. Wenn openWidget() in diesen 250ms aufgerufen wird, überschreibt der Timeout das display: flex wieder (Race Condition).

Lösung

setTimeout wird durch transitionend Event ersetzt. Eine _minimizing Flag verhindert die Race Condition.

async minimize(id) {
  const entry = this._widgets.get(id);
  if (!entry) return;
  entry.state.open = false;
  entry._minimizing = true;
  entry.el.classList.add('widget-minimized');

  entry.el.addEventListener('transitionend', function onEnd(e) {
    if (e.target !== entry.el) return;
    entry.el.removeEventListener('transitionend', onEnd);
    if (entry._minimizing) {
      entry.el.style.display = 'none';
    }
    entry._minimizing = false;
  }, { once: false });

  this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
  await this.save();
},

async openWidget(id) {
  const entry = this._widgets.get(id);
  if (!entry) return;
  entry._minimizing = false;  // Race Condition verhindert
  entry.state.open = true;
  entry.el.style.display = 'flex';
  requestAnimationFrame(() => {
    entry.el.classList.remove('widget-minimized');
  });
  this.bringToFront(id);
  this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
  await this.save();
},

Warum _minimizing Flag: Robuster als clearTimeout, weil sie unabhängig von der CSS-Transition-Duration funktioniert.

Fallback: Falls transitionend nicht feuert (kein Transition definiert), bleibt das Widget sichtbar mit der Klasse. Akzeptabel, da alle Widgets in main.css eine Transition haben.

Betroffene Dateien

  • src/js/widgets.jsminimize() und openWidget() umschreiben

Sektion 3: Security Fixes

3a: URL-Injection in backgroundImage

Datei: src/js/settings.js:93 Problem: settings.bgUrl wird unvalidiert in CSS-Template-Literal eingefügt.

Fix: Protokoll-Whitelist. Nur blob: und data:image/ erlauben (die einzigen Protokolle die der Upload erzeugt).

function isValidBgUrl(url) {
  return typeof url === 'string' &&
    (url.startsWith('blob:') || url.startsWith('data:image/'));
}

Validierung an zwei Stellen: applySettings() und beim Speichern nach Upload.

3b: URL-Validierung beim JSON-Import

Datei: src/js/data.js:45-49 Problem: Importierte Bookmark-URLs werden nicht auf Protokoll geprüft. javascript: oder data: URLs kommen durch.

Fix: Protokoll-Whitelist für importierte URLs.

function isSafeUrl(url) {
  try {
    const u = new URL(url);
    return ['http:', 'https:', 'ftp:'].includes(u.protocol);
  } catch {
    return false;
  }
}

Integration in die Bookmark-Filter-Logik: if (!bm || typeof bm.title !== 'string' || !isSafeUrl(bm.url)) return false;

Ungültige Bookmarks werden still übersprungen.

3c: Objekt-Mutation im Import

Datei: src/js/data.js:43-48 Problem: b.id = b.id || uid() mutiert das geparste JSON-Objekt direkt. Keine Längenvalidierung.

Fix: Immutable Mapping mit expliziter Feldauswahl und String-Längen-Limits.

.map(bm => ({
  id: bm.id || uid(),
  title: String(bm.title).slice(0, 200),
  url: bm.url,
  desc: String(bm.desc || '').slice(0, 500)
}));

Analog für Boards:

.map(b => ({
  id: b.id || uid(),
  title: String(b.title).slice(0, 100),
  blurred: !!b.blurred,
  bookmarks: /* bereits sanitized, siehe oben */
}));

Notes-Felder beim Import werden ebenfalls sanitized:

.filter(n => n && n.id && n.template)
.map(n => ({
  id: n.id,
  template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
  title: String(n.title || '').slice(0, 200),
  content: String(n.content || '').slice(0, 5000),
  checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));

Betroffene Dateien

  • src/js/settings.jsisValidBgUrl() + Validierung in applySettings()
  • src/js/data.jsisSafeUrl() + immutable Mapping + Längen-Limits

Sektion 4: Lokale Favicons

Problem

getFaviconUrl() (state.js:36-43) ruft Google Favicons API auf. Brave Shields blockiert das. Jeder Bookmark erzeugt einen fehlgeschlagenen Netzwerk-Request. Zusätzlich leakt jeder Hostname an Google.

Lösung

Kein externer Request mehr. getFaviconUrl() wird entfernt. Bookmarks zeigen ein farbiges Buchstaben-Icon (erster Buchstabe des Titels).

state.js: getFaviconUrl() löschen.

boards.js: Statt <img> + Error-Fallback nur noch ein <div>:

const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
favicon.textContent = bm.title.charAt(0).toUpperCase();
// Deterministische Farbe pro Buchstabe
const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;

Inline-Style für backgroundColor ist hier gerechtfertigt, weil der Wert dynamisch pro Bookmark berechnet wird. Restliche Styles (Größe, Border-Radius, Schrift) kommen aus CSS.

main.css: .bm-favicon und .bm-favicon-fallback ersetzen durch .bm-favicon-local.

Was entfällt

  • getFaviconUrl() in state.js
  • <img class="bm-favicon"> Erzeugung in boards.js
  • Error-Listener für Favicon-Loads
  • .bm-favicon und .bm-favicon-fallback CSS-Regeln
  • Der einzige externe Netzwerk-Request der Extension

Betroffene Dateien

  • src/js/state.jsgetFaviconUrl() entfernen
  • src/js/boards.js — Favicon-Rendering umbauen
  • src/css/main.css — CSS-Klassen tauschen

Sektion 5: i18n-Lücken schließen

5a: Toolbar-Buttons — fehlende data-i18n-title

Fünf Header-Buttons (newtab.html:26-42) haben hardcodierte deutsche title-Attribute.

Button Key DE EN
#btnImport header.import_title Bookmarks importieren (HTML) Import bookmarks (HTML)
#btnAddBoard header.board_title Neues Board hinzufügen Add new board
#btnNote header.note_title Schnellnotiz Quick note
#btnTheme header.theme_title Darstellung & Theme Appearance & Theme
#btnSettings header.settings_title Einstellungen Settings

Fix: data-i18n-title Attribute hinzufügen. applyLanguage() erkennt diese automatisch.

5b: Button-Texte ohne i18n

Drei Settings-Buttons haben hardcodierte Texte.

Button Key DE EN
#btnRestartOnboarding settings.onboarding_btn Start Start
#btnResetAll settings.reset_btn Reset Reset
#btnBgFile settings.bg_upload_btn Upload Upload

Aktuell in beiden Sprachen identisch, aber data-i18n wird für Konsistenz und zukünftige Erweiterbarkeit gesetzt.

Betroffene Dateien

  • newtab.html — 5x data-i18n-title, 3x data-i18n hinzufügen
  • src/js/i18n.js — 8 neue Keys in STRINGS.de und STRINGS.en

Sektion 6: Code-Qualität

6a: Notes-Mutation beim Import

Datei: src/js/data.js:~79 Problem: Notes._notes = merged setzt das interne Array direkt, umgeht Notes.save().

Fix: Nach dem Speichern in widgetStates wird Notes.init() aufgerufen statt das interne Array direkt zu manipulieren.

existingWidgets.notes = merged;
await Store.set('widgetStates', existingWidgets);
await Notes.init();  // Neu aus Storage laden + UI rendern

6b: backdrop-filter Fallback

Datei: src/css/main.css Problem: 24 Stellen mit backdrop-filter. Brave Shields kann das blockieren.

Fix: Zentraler @supports not Block mit solidem Hintergrund-Fallback:

@supports not (backdrop-filter: blur(1px)) {
  .board,
  .widget,
  .settings-panel,
  .dialog-box,
  .theme-modal {
    background-color: var(--bg-solid-fallback);
  }
}

Jedes Theme bekommt --bg-solid-fallback als deckende Variante der Glassmorphism-Farbe.

6c: Clock Interval Cleanup

Datei: src/js/app.js:135 Problem: setInterval(tick, 1000) ID wird nicht gespeichert.

Fix: Interval-ID in Variable speichern. Niedrigste Priorität, da der Interval mit dem Tab stirbt.

let _clockInterval = null;
_clockInterval = setInterval(tick, 1000);

Betroffene Dateien

  • src/js/data.js — Notes-Import über Notes.init() statt direkter Mutation
  • src/css/main.css@supports not Block + --bg-solid-fallback pro Theme
  • src/js/app.js — Interval-ID speichern

Implementierungsreihenfolge (Foundation First)

  1. Event-System in widgets.js bauen
  2. Widget-Module auf Events migrieren (calculator.js, timer.js, image-ref.js)
  3. Minimize mit transitionend in widgets.js
  4. Security Fixes in settings.js und data.js
  5. Lokale Favicons in state.js, boards.js, main.css
  6. i18n-Lücken in newtab.html und i18n.js
  7. Code-Qualität in data.js, main.css, app.js
  8. Version Bump auf 2.0.1 in allen drei Manifests + CHANGELOG

Betroffene Dateien (Gesamt)

Datei Sektionen
src/js/widgets.js 1, 2
src/js/calculator.js 1
src/js/timer.js 1
src/js/image-ref.js 1
src/js/settings.js 3a
src/js/data.js 3b, 3c, 6a
src/js/state.js 4
src/js/boards.js 4
src/js/i18n.js 5
src/js/app.js 6c
src/css/main.css 4, 6b
newtab.html 5
manifest.json 8
manifest.firefox.json 8
manifest.opera.json 8
CHANGELOG.md 8