From a04aaae85999273982278b55b806f4ef4ba2eac5 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 11 Oct 2026 18:22:26 +0000 Subject: [PATCH] =?UTF-8?q?Sicherheits-H=C3=A4rtung=20(v1.1.1)=20und=20akt?= =?UTF-8?q?uelle=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nur http(s)-Links, Validierung/Limits für Remote-, Storage- und Importdaten - HTTP-Sync nur im lokalen Netz, keine Zugangsdaten in der URL - Berechtigung bei Serverwechsel entziehen, explizite CSP, UUID-IDs Co-Authored-By: Claude Sonnet 5.5 --- README.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++--- manifest.json | 3 +- options.js | 15 ++++++-- popup.js | 17 ++++++--- sync.js | 75 +++++++++++++++++++++++++++++++++++----- 5 files changed, 185 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e32d377..689e973 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,94 @@ # LabLinks -Retro-Launcher (Chrome, Manifest V3) für deine HomeLab-WebUIs. +Schlanke Chrome-Erweiterung (Manifest V3) im Retro-Terminal-Look, um die WebUIs deines HomeLabs +(Proxmox, Nextcloud, Router, NAS …) per Popup zu öffnen – optional per WebDAV/Nextcloud über mehrere Geräte synchronisiert. -- Installation: `chrome://extensions` → Entwicklermodus → „Entpackte Erweiterung laden“ → Ordner mit `manifest.json`. -- Shortcut: frei wählbar unter `chrome://extensions/shortcuts` (Vorschlag: Alt+Shift+L). -- Sync: `[⚙]` im Popup → WebDAV-/Nextcloud-Ordner, Benutzer und App-Passwort eintragen. - Die Links liegen dann als `lablinks.json` im Ordner; Änderungen werden pro Eintrag nach Zeitstempel zusammengeführt. +## Funktionen + +- **Popup-Launcher** mit Suche (Enter öffnet den ersten Treffer) +- **Links verwalten**: Name, URL und Emoji-Icon anlegen, bearbeiten, löschen, umsortieren +- **Export / Import** als JSON +- **WebDAV-/Nextcloud-Sync** (siehe unten) +- **Tastenkürzel** zum Öffnen des Popups +- Keine Tracker, keine externen Schriften oder Skripte, keine Abhängigkeiten + +## Installation + +1. Repository klonen oder als ZIP laden und entpacken. + Achtung: Beim ZIP-Download steckt der Inhalt oft in einem verschachtelten Ordner. Wähle den Ordner, in dem direkt `manifest.json` liegt. +2. In Chrome `chrome://extensions` öffnen und den **Entwicklermodus** aktivieren. +3. **Entpackte Erweiterung laden** → Ordner auswählen. +4. Icon in der Toolbar anpinnen. + +Nach einem Update (`git pull`) die Erweiterung in `chrome://extensions` neu laden. + +## Tastenkürzel + +Vorschlag: **Alt+Shift+L**. Chrome übernimmt Vorschläge nur bei der Erstinstallation und nur, wenn sie frei sind. +Eigene Kombination setzen unter `chrome://extensions/shortcuts` → LabLinks → „LabLinks öffnen“. + +## Sync mit Nextcloud / WebDAV + +1. In Nextcloud ein **App-Passwort** erstellen (Einstellungen → Sicherheit). Nicht das normale Passwort verwenden. +2. Im Popup **[⚙]** klicken und eintragen: + - **WebDAV-Ordner**, z. B. `https://cloud.example.com/remote.php/dav/files/NUTZER/LabLinks` + (wird bei Bedarf angelegt; die Daten liegen darin als `lablinks.json`) + - **Benutzer** und **App-Passwort** +3. **[SPEICHERN & TESTEN]** – Chrome fragt einmal nach Zugriff auf genau diesen Server. + +Funktionsweise: + +- Sync beim Öffnen des Popups und nach jeder Änderung; **[⟳]** im Popup startet ihn manuell + (grün = ok, rot = Fehler, Tooltip zeigt die Meldung, blinkend = läuft). +- Zusammenführung pro Eintrag: der neuere Zeitstempel gewinnt. Löschungen werden als Tombstones (60 Tage) übertragen, + damit gelöschte Links auf anderen Geräten nicht wieder auftauchen. Die Reihenfolge übernimmt das zuletzt geänderte Gerät. +- Schreibkonflikte werden per ETag erkannt, danach wird erneut abgeglichen. +- **[TRENNEN]** entfernt Zugangsdaten und Server-Berechtigung; lokale Links bleiben. +- Funktioniert mit jedem WebDAV-Server, nicht nur mit Nextcloud. + +Dateiformat (`lablinks.json`): + +```json +{ + "version": 1, + "links": [{ "id": "…", "name": "Proxmox", "url": "https://pve:8006/", "icon": "🖥", "updated": 1760000000000 }], + "deleted": [{ "id": "…", "updated": 1760000000000 }], + "modified": 1760000000000 +} +``` + +## Sicherheit + +- **Nur http(s)-Links**: `javascript:`, `data:`, `file:`, `chrome:` u. Ä. werden beim Anlegen, Import und Sync verworfen. +- **Remote-Daten gelten als nicht vertrauenswürdig**: Typen, Längen (Name 80, URL 2048, Icon 2 Zeichen), Anzahl (500 Links), + Dateigröße (1 MB) und Zeitstempel (max. 1 Tag in der Zukunft) werden geprüft; Anzeige nur per `textContent`. +- **Minimale Rechte**: Nur `storage`. Der Serverzugriff wird als *optionale* Berechtigung nur für den eingetragenen Host angefragt + und beim Trennen oder Serverwechsel wieder entzogen. +- **HTTP nur im LAN**: Unverschlüsseltes `http://` (Basic-Auth!) ist nur für lokale Adressen erlaubt + (`localhost`, private IP-Bereiche, `*.local`, `*.lan`, Hostnamen ohne Punkt) und zeigt eine Warnung. Zugangsdaten in der URL werden abgelehnt. +- **Strikte CSP**: `script-src 'self'; object-src 'none'; base-uri 'none'`, keine Inline-Skripte, keine externen Ressourcen. +- **Zugangsdaten** liegen in `chrome.storage.local` (nicht im Google-Sync), aber **unverschlüsselt** auf der Festplatte des Browser-Profils. + Wer Zugriff auf dein Profil hat, kann sie auslesen. Nutze deshalb immer ein widerrufbares **App-Passwort** mit dem Nutzer, der nur diesen Ordner braucht. +- Selbstsignierte Zertifikate lehnt Chrome ab; nutze ein vertrauenswürdiges Zertifikat (z. B. Let's Encrypt) oder importiere deine CA ins System. + +## Bekannte Grenzen + +- Links liegen in `chrome.storage.sync` (Chrome-Limit ≈ 8 KB pro Eintrag, grob 50 Links). Bei mehr Links wäre ein Wechsel auf `storage.local` nötig. +- Kein Online-Status pro Link, keine Gruppen. +- Sync läuft nur, wenn das Popup geöffnet wird (kein Hintergrundprozess). + +## Projektstruktur + +| Datei | Zweck | +| --- | --- | +| `manifest.json` | Manifest V3, Rechte, Shortcut, CSP | +| `popup.html/.css/.js` | Popup: Liste, Suche, Bearbeiten | +| `options.html/.css/.js` | Sync-Einstellungen | +| `sync.js` | WebDAV-Client, Merge, Validierung (von Popup und Einstellungen genutzt) | +| `icons/` | Toolbar-Icons | + +## Versionen + +- **1.1.1** – Sicherheits-Härtung (URL-Schema, Validierung, HTTP-Regel, CSP, UUID-IDs) +- **1.1.0** – WebDAV-/Nextcloud-Sync +- **1.0.0** – Erste Version diff --git a/manifest.json b/manifest.json index 829d252..d160b03 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,11 @@ { "manifest_version": 3, "name": "LabLinks", - "version": "1.1.0", + "version": "1.1.1", "description": "Retro-Launcher für deine HomeLab WebUIs.", "permissions": ["storage"], "optional_host_permissions": ["https://*/*", "http://*/*"], + "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'none'; base-uri 'none'" }, "options_ui": { "page": "options.html", "open_in_tab": true }, "action": { "default_popup": "popup.html", diff --git a/options.js b/options.js index 103cd30..b162902 100644 --- a/options.js +++ b/options.js @@ -34,16 +34,27 @@ $("cfg").addEventListener("submit", async (e) => { e.preventDefault(); const cfg = { url: $("url").value.trim(), user: $("user").value.trim(), pass: $("pass").value }; - let origin; - try { origin = new URL(cfg.url).origin + "/*"; } catch { return status("> FEHLER: Ungültige URL.", "err"); } + let check; + try { check = LabSync.checkServerUrl(cfg.url); } catch (err) { return status("> FEHLER: " + err.message + ".", "err"); } + const origin = check.origin + "/*"; // Muss direkt aus dem Klick heraus aufgerufen werden (Nutzergeste). const granted = await chrome.permissions.request({ origins: [origin] }); if (!granted) return status("> FEHLER: Zugriff auf den Server wurde nicht erlaubt.", "err"); + // Berechtigung für einen früher genutzten, anderen Server wieder entziehen + const old = await LabSync.getConfig(); + if (old?.url) { + try { + const oldOrigin = new URL(old.url).origin + "/*"; + if (oldOrigin !== origin) await chrome.permissions.remove({ origins: [oldOrigin] }); + } catch {} + } + await LabSync.setConfig(cfg); showConnected(true); await runSync(); + if (check.insecure) status($("status").textContent + " (Achtung: HTTP ohne Verschlüsselung – Passwort geht im Klartext durchs Netz.)", $("status").className); }); $("now").addEventListener("click", runSync); diff --git a/popup.js b/popup.js index dc25fe3..9f6d305 100644 --- a/popup.js +++ b/popup.js @@ -7,12 +7,12 @@ let editId = null; let syncOn = false; let syncState = ""; // "", "busy", "ok", "err" -const uid = () => Math.random().toString(36).slice(2, 10); +const uid = () => crypto.randomUUID(); function normalizeUrl(raw) { raw = raw.trim(); if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = "http://" + raw; - try { return new URL(raw).href; } catch { return null; } + return LabSync.safeUrl(raw); } async function load() { @@ -135,7 +135,11 @@ function closeForm() { $("form").addEventListener("submit", async (e) => { e.preventDefault(); const url = normalizeUrl($("fUrl").value); - if (!url) { $("fUrl").focus(); return; } + if (!url) { + $("fUrl").setCustomValidity("Nur gültige http(s)-URLs"); + $("fUrl").reportValidity(); + return; + } const entry = { name: $("fName").value.trim(), url, icon: $("fIcon").value.trim(), updated: Date.now() }; if (editId === "new") links.push({ id: uid(), ...entry }); else Object.assign(links.find((l) => l.id === editId), entry); @@ -143,6 +147,7 @@ $("form").addEventListener("submit", async (e) => { closeForm(); }); +$("fUrl").addEventListener("input", () => $("fUrl").setCustomValidity("")); $("cancel").addEventListener("click", closeForm); $("add").addEventListener("click", () => openForm()); $("search").addEventListener("input", render); @@ -172,9 +177,11 @@ $("file").addEventListener("change", async (e) => { try { const arr = JSON.parse(await file.text()); const now = Date.now(); + if (!Array.isArray(arr)) throw new Error("kein Array"); const imported = arr - .filter((x) => x && x.name && x.url) - .map((x) => ({ id: uid(), name: String(x.name), url: normalizeUrl(String(x.url)) || String(x.url), icon: x.icon || "", updated: now })); + .slice(0, 500) + .map((x) => LabSync.cleanLink({ ...x, id: uid(), url: x && normalizeUrl(String(x.url ?? "")), updated: now })) + .filter(Boolean); deleted = [...deleted, ...links.map((l) => ({ id: l.id, updated: now }))]; links = imported; await save(); diff --git a/sync.js b/sync.js index a82a8fa..355d401 100644 --- a/sync.js +++ b/sync.js @@ -5,13 +5,71 @@ const LabSync = (() => { const FILE = "lablinks.json"; const TOMB_TTL = 60 * 24 * 3600 * 1000; + const MAX_LINKS = 500; + const MAX_TOMBS = 1000; + const MAX_BODY = 1_000_000; + const DAY = 24 * 3600 * 1000; + + // Nur http(s) – verhindert javascript:, data:, file:, chrome: usw. + function safeUrl(raw) { + try { + const u = new URL(String(raw)); + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + return u.href.length <= 2048 ? u.href : null; + } catch { return null; } + } + + // Daten aus Storage, Remote-Datei oder Import sind nicht vertrauenswürdig. + function cleanLink(x) { + if (!x || typeof x !== "object") return null; + const id = String(x.id ?? ""); + const url = safeUrl(x.url); + const name = String(x.name ?? "").trim().slice(0, 80); + if (!/^[\w-]{1,64}$/.test(id) || !url || !name) return null; + const icon = typeof x.icon === "string" ? [...x.icon].slice(0, 2).join("") : ""; + const updated = Math.min(Math.max(Number(x.updated) || 0, 0), Date.now() + DAY); + return { id, name, url, icon, updated }; + } + function cleanTomb(x) { + if (!x || typeof x !== "object" || !/^[\w-]{1,64}$/.test(String(x.id ?? ""))) return null; + return { id: String(x.id), updated: Math.min(Math.max(Number(x.updated) || 0, 0), Date.now() + DAY) }; + } + function cleanState(d) { + const arr = (v) => (Array.isArray(v) ? v : []); + return { + links: arr(d.links).slice(0, MAX_LINKS).map(cleanLink).filter(Boolean), + deleted: arr(d.deleted).slice(0, MAX_TOMBS).map(cleanTomb).filter(Boolean), + modified: Math.min(Math.max(Number(d.modified) || 0, 0), Date.now() + DAY), + }; + } + + // Prüft die Server-URL. Basic-Auth über http:// nur im lokalen Netz erlauben. + function isPrivateHost(h) { + if (h === "localhost" || h === "[::1]" || !h.includes(".")) return true; + if (/\.(local|lan|home\.arpa|internal|localdomain)$/.test(h)) return true; + const m = h.match(/^(\d+)\.(\d+)\.\d+\.\d+$/); + if (!m) return false; + const [a, b] = [+m[1], +m[2]]; + return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254); + } + function checkServerUrl(raw) { + let u; + try { u = new URL(String(raw).trim()); } catch { throw new Error("Ungültige URL"); } + if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("Nur http(s)-URLs erlaubt"); + if (u.username || u.password) throw new Error("Zugangsdaten bitte in die Felder, nicht in die URL"); + const insecure = u.protocol === "http:"; + if (insecure && !isPrivateHost(u.hostname)) + throw new Error("Unverschlüsseltes HTTP ist nur im lokalen Netz erlaubt – bitte HTTPS verwenden"); + return { origin: u.origin, insecure }; + } + const getConfig = async () => (await chrome.storage.local.get("webdav")).webdav || null; const setConfig = (cfg) => cfg ? chrome.storage.local.set({ webdav: cfg }) : chrome.storage.local.remove("webdav"); async function readLocal() { const d = await chrome.storage.sync.get(["links", "deleted", "modified"]); - return { links: d.links || [], deleted: d.deleted || [], modified: d.modified || 0 }; + return cleanState(d); } const writeLocal = (s) => chrome.storage.sync.set({ links: s.links, deleted: s.deleted, modified: s.modified }); @@ -46,13 +104,13 @@ const LabSync = (() => { const res = await dav(cfg, "GET", fileUrl(cfg)); if (res.status === 404) return null; if (!res.ok) throw new Error(`Server antwortet mit HTTP ${res.status}`); + const text = await res.text(); + if (text.length > MAX_BODY) throw new Error("Remote-Datei ist unerwartet groß"); let data; - try { data = await res.json(); } catch { throw new Error("Remote-Datei ist kein gültiges JSON"); } - if (!data || !Array.isArray(data.links)) throw new Error("Remote-Datei hat ein unbekanntes Format"); - return { - data: { links: data.links, deleted: data.deleted || [], modified: data.modified || 0 }, - etag: res.headers.get("ETag"), - }; + try { data = JSON.parse(text); } catch { throw new Error("Remote-Datei ist kein gültiges JSON"); } + if (!data || typeof data !== "object" || !Array.isArray(data.links)) + throw new Error("Remote-Datei hat ein unbekanntes Format"); + return { data: cleanState(data), etag: res.headers.get("ETag") }; } // -> "ok" | "conflict" @@ -108,6 +166,7 @@ const LabSync = (() => { async function doSync() { const cfg = await getConfig(); if (!cfg || !cfg.url) return { skipped: true }; + checkServerUrl(cfg.url); for (let attempt = 0; attempt < 4; attempt++) { const local = await readLocal(); @@ -128,5 +187,5 @@ const LabSync = (() => { throw new Error("Sync-Konflikt, bitte erneut versuchen"); } - return { getConfig, setConfig, readLocal, writeLocal, sync }; + return { getConfig, setConfig, readLocal, writeLocal, sync, safeUrl, cleanLink, checkServerUrl }; })();