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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhHNJKk3RsHgUsJ27WQSom
This commit is contained in:
Stefan Lohmaier
2026-07-22 12:31:02 +02:00
co-authored by Claude Opus 4.8
parent 4d198017df
commit 7c01e7be53
+13
View File
@@ -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", "?")