From 7c01e7be5364793f8ad31cd9142840db99e5642e Mon Sep 17 00:00:00 2001 From: Stefan Lohmaier Date: Wed, 22 Jul 2026 12:31:02 +0200 Subject: [PATCH] harden(notes): note_id/resource_id auf 32-Hex validieren (Security-Audit) Audit-Fund: note_id/resource_id gingen ungeprueft in die Joplin-API-URL -> moegliche Query-/Path-Injection (auf das eigene Konto beschraenkt, aber unschoen). Fix: _valid_id() (^[0-9a-f]{32}$) als Guard in read_note, list_note_resources, read_resource, update_note, delete_note. create_note/list/search brauchen keine ID. Notes-Tests 11/11 gruen; ungueltige ID wird mit klarer Meldung abgelehnt. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XhHNJKk3RsHgUsJ27WQSom --- notes/server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/notes/server.py b/notes/server.py index 55c0c2b..2a0d89d 100644 --- a/notes/server.py +++ b/notes/server.py @@ -56,6 +56,14 @@ def _post(user, path, json_data): return None, str(e) +import re +_JOPLIN_ID = re.compile(r"^[0-9a-f]{32}$") + +def _valid_id(x): + """Joplin item-IDs sind 32 Hex-Zeichen. Verhindert URL/Query-Injection ueber note_id.""" + return isinstance(x, str) and bool(_JOPLIN_ID.match(x)) + + def _put(user, path, json_data): api = _api(user) if not api: @@ -159,6 +167,7 @@ def read_note( """Read a note's full content (Markdown). Resource links appear as :/resourceId — use list_note_resources + read_resource for attachments.""" user = get_current_user() if not user: return "Error: not authenticated" + if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)" r, err = _get(user, f"/notes/{note_id}", {"fields": "id,title,body"}) if err: return f"Fehler: {err}" d = r.json() @@ -172,6 +181,7 @@ def list_note_resources( """List attachments (images, PDFs, files) embedded in a note. Use read_resource with the resource ID to fetch one.""" user = get_current_user() if not user: return "Error: not authenticated" + if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)" items, err = _all_items(user, f"/notes/{note_id}/resources", {"fields": "id,title,mime,size"}) if err: return f"Fehler: {err}" if not items: @@ -190,6 +200,7 @@ def read_resource( """Read an attachment. Images shown inline, PDFs and Office documents (docx, xlsx, pptx) as extracted text, text directly, other formats as binary.""" user = get_current_user() if not user: return [TextContent(type="text", text="Error: not authenticated")] + if not _valid_id(resource_id): return [TextContent(type="text", text="Fehler: ungueltige resource_id (erwartet 32 Hex-Zeichen)")] # Get metadata rmeta, err = _get(user, f"/resources/{resource_id}", {"fields": "id,title,mime,size"}) if err: return [TextContent(type="text", text=f"Fehler: {err}")] @@ -245,6 +256,7 @@ def update_note( """Update an existing note: change title and/or body, or move it to another notebook. Only the given fields are changed.""" user = get_current_user() if not user: return "Error: not authenticated" + if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)" payload = {} if title: payload["title"] = title if body: payload["body"] = body @@ -274,6 +286,7 @@ def delete_note( """Delete a note. By default it goes to Joplin's trash; permanent=True removes it for good.""" user = get_current_user() if not user: return "Error: not authenticated" + if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)" r, err = _get(user, f"/notes/{note_id}", {"fields": "id,title"}) if err: return f"Fehler: {err}" note_title = r.json().get("title", "?")