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) 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): def _put(user, path, json_data):
api = _api(user) api = _api(user)
if not api: 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.""" """Read a note's full content (Markdown). Resource links appear as :/resourceId — use list_note_resources + read_resource for attachments."""
user = get_current_user() user = get_current_user()
if not user: return "Error: not authenticated" 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"}) r, err = _get(user, f"/notes/{note_id}", {"fields": "id,title,body"})
if err: return f"Fehler: {err}" if err: return f"Fehler: {err}"
d = r.json() 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.""" """List attachments (images, PDFs, files) embedded in a note. Use read_resource with the resource ID to fetch one."""
user = get_current_user() user = get_current_user()
if not user: return "Error: not authenticated" 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"}) items, err = _all_items(user, f"/notes/{note_id}/resources", {"fields": "id,title,mime,size"})
if err: return f"Fehler: {err}" if err: return f"Fehler: {err}"
if not items: 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.""" """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() user = get_current_user()
if not user: return [TextContent(type="text", text="Error: not authenticated")] 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 # Get metadata
rmeta, err = _get(user, f"/resources/{resource_id}", {"fields": "id,title,mime,size"}) rmeta, err = _get(user, f"/resources/{resource_id}", {"fields": "id,title,mime,size"})
if err: return [TextContent(type="text", text=f"Fehler: {err}")] 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.""" """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() user = get_current_user()
if not user: return "Error: not authenticated" if not user: return "Error: not authenticated"
if not _valid_id(note_id): return "Fehler: ungueltige note_id (erwartet 32 Hex-Zeichen)"
payload = {} payload = {}
if title: payload["title"] = title if title: payload["title"] = title
if body: payload["body"] = body 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.""" """Delete a note. By default it goes to Joplin's trash; permanent=True removes it for good."""
user = get_current_user() user = get_current_user()
if not user: return "Error: not authenticated" 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"}) r, err = _get(user, f"/notes/{note_id}", {"fields": "id,title"})
if err: return f"Fehler: {err}" if err: return f"Fehler: {err}"
note_title = r.json().get("title", "?") note_title = r.json().get("title", "?")