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:
marcusH
2026-07-19 23:47:29 +02:00
parent e245600235
commit 6d44f77671
6 changed files with 178 additions and 22 deletions
+37 -3
View File
@@ -39,10 +39,44 @@ describe("renderMarkdown", () => {
}
});
it("blockt data:-Bilder, lässt normale Ziele durch", () => {
it("blockt data:-Bilder", () => {
expect(renderMarkdown("![a](data:image/svg+xml,<svg onload=alert(1)>)")).not.toContain("<img");
expect(renderMarkdown("![a](https://example.org/b.png)")).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(`![alt](${src})`);
expect(el.querySelector("img"), `${src} durfte kein img erzeugen`).toBeNull();
expect(el.textContent).toContain("alt");
}
});
it("lässt lokale Bilder durch", () => {
expect(renderMarkdown("![a](./bild.png)")).toContain("<img");
expect(renderMarkdown("![a](/bild.png)")).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("&#39;");
});
it("escapt Anführungszeichen im title, damit das Attribut nicht aufbricht", () => {
+31 -11
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/"/g, "&quot;")
// Auch das Apostroph: Sonst hängt die Sicherheit daran, dass jedes Attribut
// hier doppelt gequotet bleibt — eine Konvention, keine Garantie.
.replace(/'/g, "&#39;");
}
/// 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}>`;