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:
root
2026-10-11 18:22:26 +00:00
co-authored by Claude Sonnet 5.5
parent bb7ee831a8
commit a04aaae859
5 changed files with 185 additions and 21 deletions
+67 -8
View File
@@ -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 };
})();