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
+41 -2
View File
@@ -18,10 +18,49 @@ pub(crate) mod watcher;
#[cfg(test)]
pub(crate) mod testutil;
/// Namensprüfung für Projekte und Pool-Anzeigenamen (keine Pfad-Bestandteile).
/// Namensprüfung für Projekte und Pool-Anzeigenamen.
///
/// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien)
/// und landet zugleich in Dateien mit zeilenbasiertem Format — vor allem in der
/// `.desktop`-Datei unter Linux. Darum reicht das Verbot von `/` und `..`
/// nicht: Ein Zeilenumbruch im Namen hängt dort eigene Schlüssel an, und
/// `Exec=` braucht keinen Schrägstrich. Projektnamen stammen aus
/// `dir.file_name()`, bei einem geklonten Fremd-Repo also von außen.
pub(crate) fn check_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
let ungueltig = name.trim().is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains("..")
|| name.starts_with('.')
|| name.chars().any(|c| c.is_control());
if ungueltig {
return Err(format!("ungültiger Name: {name}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::check_name;
#[test]
fn namen_pruefung() {
for ok in ["projekt", "mein-projekt", "Projekt 2", "äöü"] {
assert!(check_name(ok).is_ok(), "{ok} sollte gültig sein");
}
for bad in [
"",
" ",
"a/b",
"a\\b",
"..",
"../x",
".versteckt",
"boo\nExec=bash -c pwn", // .desktop-Injektion
"a\tb",
"a\u{7f}b",
] {
assert!(check_name(bad).is_err(), "{bad:?} sollte abgelehnt werden");
}
}
}
+27 -1
View File
@@ -156,9 +156,20 @@ pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
///
/// Der Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon tut:
/// Die Quelle ist die `ai-control.json` **im Projektordner**, also eine
/// versionierte Datei, die mit einem geklonten Repo hereinkommt. Ungeprüft
/// bestimmte sie mit `../…` ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR —
/// und dessen `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim
/// Sessionstart ausgeführt wird.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
let paths = Paths::real();
Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
let Some(pool) = read_project_config_in(&paths, project)?.pool else {
return Ok(None);
};
check_name(&pool).map_err(|_| format!("ungültiger Pool in {PROJECT_FILE}: {pool}"))?;
Ok(Some(paths.pool_dir(&pool)))
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
@@ -534,6 +545,21 @@ mod tests {
assert_eq!(v["pool"], serde_json::Value::String(pool));
}
/// Ein Pool-Eintrag aus einer mitgeklonten `ai-control.json` darf kein
/// beliebiges Verzeichnis zum CLAUDE_CONFIG_DIR machen.
#[test]
fn pool_aus_projektdatei_mit_traversal_wird_abgelehnt() {
let p = tmp_paths();
create_project(&p, "fremd").unwrap();
let cfg_path = project_config_path(&p, "fremd").unwrap();
fs::write(&cfg_path, r#"{"pool":"../../../../tmp/x"}"#).unwrap();
let cfg = read_project_config_in(&p, "fremd").unwrap();
assert_eq!(cfg.pool.as_deref(), Some("../../../../tmp/x"));
// Gelesen wird der Wert noch, aber er darf keinen Pfad bilden.
assert!(check_name(cfg.pool.as_deref().unwrap()).is_err());
}
/// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur
/// theme/icon/title — unbekannte Keys müssen trotzdem stehen bleiben.
#[test]
+29 -1
View File
@@ -11,13 +11,26 @@ use crate::domain::registry::project_dir;
pub(crate) const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
/// Der Hook landet in der `settings.json` des Projekts und wird von Claude Code
/// bei jedem Sessionstart über die Shell ausgeführt. Der Pfad muss darum
/// gequotet werden: Er stammt aus dem Ordnernamen, den der Nutzer im Dialog
/// wählt — bei einem geklonten Fremd-Repo also von außen. Unquotiert genügte
/// ein Ordner `repo$(…)` für dauerhafte Codeausführung, und schon ein
/// Leerzeichen im Pfad hätte den Hook still zerbrochen.
fn todo_hook_command(dir: &std::path::Path) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
dir.join(TODO_FILE).display()
shell_quote(&dir.join(TODO_FILE).to_string_lossy())
)
}
/// Ein Argument für `sh -c` in einfache Anführungszeichen setzen. Innerhalb
/// davon ist jedes Zeichen literal; einzig das Apostroph selbst muss die
/// Quotierung verlassen und wieder betreten (`'\''`).
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn hook_is_todo(group: &serde_json::Value) -> bool {
group["hooks"]
.as_array()
@@ -98,6 +111,21 @@ mod tests {
use crate::domain::project::{create_project_full_in, TerminalConfig};
use crate::domain::testutil::{create_project, tmp_paths};
/// Der Hook-Befehl geht durch die Shell; ein Pfad mit Metazeichen darf dort
/// nichts ausführen. Ordnernamen sind bei geklonten Repos Fremdeingabe.
#[test]
fn hook_befehl_quotet_den_pfad() {
let cmd = todo_hook_command(std::path::Path::new("/tmp/repo$(touch /tmp/pwned)"));
assert!(cmd.ends_with("'/tmp/repo$(touch /tmp/pwned)/OFFENE-PUNKTE.md'"), "{cmd}");
// Ein Apostroph im Pfad darf die Quotierung nicht aufbrechen.
let cmd = todo_hook_command(std::path::Path::new("/tmp/o'brien"));
assert!(cmd.ends_with(r"'/tmp/o'\''brien/OFFENE-PUNKTE.md'"), "{cmd}");
// Nach dem Zerlegen an den Quotes bleibt kein unquotierter Bereich übrig,
// in dem eine Shell noch etwas zu interpretieren hätte.
assert!(!cmd.contains("$("));
}
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();