Sicherheitsrunde: Befehlsinjektion, Pool-Pfad, Namensprüfung, Bild-Beacon
Vier Befunde aus einer Prüfung entlang der Angriffsflächen (MCP-Tools, Command-Grenze, Credentials, Schreibpfade und Abhängigkeiten): - todo.rs: Der SessionStart-Hook interpolierte den Projektpfad unquotiert in eine Shell-Kommandozeile, die Claude Code bei jedem Sessionstart ausfuehrt. Ein Ordner `repo$(...)` -- etwa aus einem geklonten Fremd-Repo -- ergab dauerhafte Codeausfuehrung; ein Leerzeichen zerbrach den Hook still. Der Pfad wird jetzt gequotet, inklusive Apostroph. - project_pool_dir: Der Pool-Name kommt aus der ai-control.json im Projektordner, also aus einer versionierten Datei. Ungeprueft bestimmte er mit ../ ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR, dessen settings.json wiederum apiKeyHelper traegt -- ein Kommando, das beim Sessionstart laeuft. Jetzt durch check_name abgesichert. - check_name: Steuerzeichen, Backslash und fuehrender Punkt werden abgelehnt. Ein Newline im Projektnamen hing bisher eigene Schluessel an die .desktop- Datei; Exec= braucht keinen Schraegstrich, den der alte Check verbot. - markdown.ts: Bilder duerfen nur noch lokale Ziele laden. Ein auswaertiges <img> laedt ohne Zutun und meldet damit IP, Zeitpunkt und im Pfad kodierte Daten -- der Panelinhalt stammt aus einer LLM-Session, ist also injizierbar. Schema-relative URLs (//host/x) waren durch ein zu laxes Muster erfasst. Links bleiben auswaerts erlaubt, jetzt mit rel="noopener noreferrer". escapeHtml escaped zusaetzlich das Apostroph. Dazu cargo audit nachgeholt: plist 1.9.0 -> 1.10.0 hebt quick-xml auf 0.41.0 und schliesst RUSTSEC-2026-0194/0195 fuer den Laufzeitpfad. Die zweite Instanz von quick-xml 0.39.4 bleibt ueber wayland-scanner im Lockfile -- ein proc-macro, das nur zur Compile-Zeit laeuft und ausgelieferte Protokoll-XMLs parst, nicht im Binary landet. 77 Rust-Tests, 39 Frontend-Tests.
This commit is contained in:
+37
-3
@@ -39,10 +39,44 @@ describe("renderMarkdown", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("blockt data:-Bilder, lässt normale Ziele durch", () => {
|
||||
it("blockt data:-Bilder", () => {
|
||||
expect(renderMarkdown(">)")).not.toContain("<img");
|
||||
expect(renderMarkdown("")).toContain("<img");
|
||||
expect(renderMarkdown("[a](https://example.org)")).toContain('href="https://example.org"');
|
||||
});
|
||||
|
||||
/// Ein <img> lädt ohne Zutun. Auswärtige Bildquellen sind darum ein
|
||||
/// Zero-Click-Beacon: IP, Zeitpunkt und im Pfad kodierte Daten gehen raus,
|
||||
/// sobald jemand ein vergiftetes Archiv-Dokument nur ansieht.
|
||||
it("lädt keine auswärtigen Bilder, auch nicht schema-relativ", () => {
|
||||
for (const src of [
|
||||
"https://evil.example/t.png",
|
||||
"http://evil.example/t.png",
|
||||
"//evil.example/t.png",
|
||||
"HTTPS://evil.example/t.png",
|
||||
]) {
|
||||
const el = document.createElement("div");
|
||||
el.innerHTML = renderMarkdown(``);
|
||||
expect(el.querySelector("img"), `${src} durfte kein img erzeugen`).toBeNull();
|
||||
expect(el.textContent).toContain("alt");
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt lokale Bilder durch", () => {
|
||||
expect(renderMarkdown("")).toContain("<img");
|
||||
expect(renderMarkdown("")).toContain("<img");
|
||||
});
|
||||
|
||||
/// Links dürfen auswärts zeigen — sie brauchen einen Klick.
|
||||
it("erlaubt auswärtige Links, aber ohne Referrer und Opener", () => {
|
||||
const el = document.createElement("div");
|
||||
el.innerHTML = renderMarkdown("[a](https://example.org)");
|
||||
const a = el.querySelector("a")!;
|
||||
expect(a.getAttribute("href")).toBe("https://example.org");
|
||||
expect(a.getAttribute("rel")).toContain("noopener");
|
||||
expect(a.getAttribute("rel")).toContain("noreferrer");
|
||||
});
|
||||
|
||||
it("escapt auch das Apostroph", () => {
|
||||
expect(renderMarkdown("Text mit ' Apostroph")).toContain("'");
|
||||
});
|
||||
|
||||
it("escapt Anführungszeichen im title, damit das Attribut nicht aufbricht", () => {
|
||||
|
||||
+31
-11
@@ -13,25 +13,41 @@
|
||||
|
||||
import { marked, type Tokens } from "marked";
|
||||
|
||||
/// Schemata, die im Panel etwas anzuzeigen haben. Alles andere — allen voran
|
||||
/// `javascript:`, aber auch `data:` (SVG mit Skript) und `file:` — fliegt raus.
|
||||
const OK_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/)/i;
|
||||
/// Ziele ohne Schema: Anker, absolute und relative Pfade innerhalb der App.
|
||||
/// Beide Schrägstrich-Formen müssen den doppelten ausschließen — `//host/x` ist
|
||||
/// eine schema-relative URL und damit auswärtig. Darum `\/(?!\/)` für den
|
||||
/// absoluten Pfad und `\.{1,2}\/` für den relativen: Ein `{0,2}` würde den
|
||||
/// nackten `/` mitmatchen und die erste Regel wirkungslos machen.
|
||||
const LOCAL = /^(#|\/(?!\/)|\.{1,2}\/)/;
|
||||
|
||||
/// Links dürfen auswärts zeigen: Sie brauchen einen Klick, und ein Archiv-
|
||||
/// Dokument ohne funktionierende Quellenangaben wäre nutzlos.
|
||||
const OK_LINK = new RegExp(`^(https?:|mailto:)|${LOCAL.source}`, "i");
|
||||
|
||||
/// Bilder dagegen nur lokal. Ein `<img>` lädt beim bloßen Anzeigen, ohne jedes
|
||||
/// Zutun: Ein vergiftetes Archiv-Dokument — der Panel-Inhalt kommt aus einer
|
||||
/// LLM-Session und ist damit prompt-injizierbar — meldet über die Bild-URL
|
||||
/// still IP, Zeitpunkt und im Pfad kodierte Daten nach außen.
|
||||
const OK_IMAGE = LOCAL;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
// Auch das Apostroph: Sonst hängt die Sicherheit daran, dass jedes Attribut
|
||||
// hier doppelt gequotet bleibt — eine Konvention, keine Garantie.
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/// Leerer Link statt gefährlichem Ziel — der Text bleibt sichtbar, der Klick
|
||||
/// tut nichts.
|
||||
function safeHref(href: string): string {
|
||||
/// Leeres Ziel statt gefährlichem — der Text bleibt sichtbar, der Klick tut
|
||||
/// nichts.
|
||||
function safeHref(href: string, erlaubt: RegExp): string {
|
||||
const h = href.trim();
|
||||
// Steuerzeichen entfernen: `java\nscript:` ist sonst ein Umgehungsweg.
|
||||
const clean = h.replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
return OK_SCHEME.test(clean) ? clean : "";
|
||||
return erlaubt.test(clean) ? clean : "";
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
@@ -42,13 +58,17 @@ renderer.html = ({ raw }: Tokens.HTML | Tokens.Tag) => escapeHtml(raw);
|
||||
|
||||
renderer.link = function ({ href, title, tokens }: Tokens.Link) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const safe = safeHref(href);
|
||||
const safe = safeHref(href, OK_LINK);
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return safe ? `<a href="${escapeHtml(safe)}"${t}>${text}</a>` : `<a${t}>${text}</a>`;
|
||||
// `rel` auch bei fehlendem target: Der Webview soll dem Ziel keinen Bezug auf
|
||||
// das öffnende Fenster und keinen Referrer mitgeben.
|
||||
const rel = ` rel="noopener noreferrer"`;
|
||||
return safe ? `<a href="${escapeHtml(safe)}"${t}${rel}>${text}</a>` : `<a${t}>${text}</a>`;
|
||||
};
|
||||
|
||||
renderer.image = ({ href, title, text }: Tokens.Image) => {
|
||||
const safe = safeHref(href);
|
||||
const safe = safeHref(href, OK_IMAGE);
|
||||
// Auswärtiges Bild: nur der Alt-Text, keine Anfrage nach draußen.
|
||||
if (!safe) return escapeHtml(text);
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return `<img src="${escapeHtml(safe)}" alt="${escapeHtml(text)}"${t}>`;
|
||||
|
||||
Reference in New Issue
Block a user