Sicherheits-Härtung (v1.1.1) und aktuelle README
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
bb7ee831a8
commit
a04aaae859
@@ -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
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+13
-2
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user