From 2f9bef1adbe67b99713fd47babc582ca875c11c8 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 18 Jun 2026 10:15:21 +0200 Subject: [PATCH 1/4] fix(tools): Validate more inputs Assisted-by: ClaudeCode:claude-4-8 Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/circles.py | 20 ++++++++++++++++++++ ex_app/lib/all_tools/cookbook.py | 4 +++- ex_app/lib/all_tools/files.py | 21 ++++++++++++++++++++- ex_app/lib/all_tools/here.py | 14 +++++++++----- ex_app/lib/all_tools/openstreetmap.py | 23 ++++++++++++++++++----- 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index 9705f5e..b32bf07 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -16,6 +16,18 @@ TYPE_APP = 10000 +def _validate_circle_id(circle_id: str) -> str: + if not isinstance(circle_id, str) or '/' in circle_id or '\\' in circle_id: + raise ValueError(f'Invalid circle id: {circle_id!r}') + return circle_id + + +def _validate_member_id(member_id: str) -> str: + if not isinstance(member_id, str) or '/' in member_id or '\\' in member_id: + raise ValueError(f'Invalid member id: {member_id!r}') + return member_id + + async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool @@ -34,6 +46,7 @@ async def get_circle_details(circle_id: str): :param circle_id: the id of the circle (obtainable via list_circles) :return: complete circle information including members """ + _validate_circle_id(circle_id) return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}')) @tool @@ -44,6 +57,7 @@ async def list_circle_members(circle_id: str): :param circle_id: the id of the circle (obtainable via list_circles) :return: list of members with their id, display name, type, and level """ + _validate_circle_id(circle_id) circle_members = await nc.ocs('GET', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members') return json.dumps(circle_members) @@ -76,6 +90,7 @@ async def add_member_to_circle(circle_id: str, member_id: str, member_type: int :param member_type: type of member as integer constant - 1=user, 2=group, 4=mail, 8=contact, 16=circle (default: 1 for user) :return: the added member information """ + _validate_circle_id(circle_id) payload = { "members": [{ 'id': member_id, @@ -93,6 +108,8 @@ async def remove_member_from_circle(circle_id: str, member_id: str): :param member_id: the id of the member to remove (obtainable via list_circle_members) :return: """ + _validate_circle_id(circle_id) + _validate_member_id(member_id) return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}/members/{member_id}')) @tool @@ -105,6 +122,7 @@ async def update_circle(circle_id: str, name: Optional[str] = None, description: :param description: new description :return: """ + _validate_circle_id(circle_id) if name is not None: await nc.ocs('PUT', f'/ocs/v2.php/apps/circles/circles/{circle_id}/name', json={'value': name}) if description is not None: @@ -119,6 +137,7 @@ async def delete_circle(circle_id: str): :param circle_id: the id of the circle to delete (obtainable via list_circles) :return: """ + _validate_circle_id(circle_id) await nc.ocs('DELETE', f'/ocs/v2.php/apps/circles/circles/{circle_id}') @tool @@ -131,6 +150,7 @@ async def share_with_circle(path: str, circle_id: str, permissions: int = 19): :param permissions: permissions bitmask - 1=read, 2=update, 4=create, 8=delete, 16=share. Default is 19 :return: the created share """ + _validate_circle_id(circle_id) return json.dumps(await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={ 'path': path, 'shareType': 7, # 7 = circle diff --git a/ex_app/lib/all_tools/cookbook.py b/ex_app/lib/all_tools/cookbook.py index b79b0ac..1c573ca 100644 --- a/ex_app/lib/all_tools/cookbook.py +++ b/ex_app/lib/all_tools/cookbook.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import urllib.parse from typing import Optional from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp @@ -35,7 +36,8 @@ async def search_recipes(search_term: str): :param search_term: text to search for in recipe names and descriptions :return: list of matching recipes """ - response = await nc._session._create_adapter().request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/cookbook/api/v1/search/{search_term}", headers={ + encoded_search_term = urllib.parse.quote(search_term, safe='') + response = await nc._session._create_adapter().request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/cookbook/api/v1/search/{encoded_search_term}", headers={ "Content-Type": "application/json", "OCS-APIREQUEST": "true", }) diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index e20b15b..f20431b 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -9,6 +9,16 @@ from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +def _validate_path(path: str) -> str: + """Reject path traversal attempts in a user-supplied file path.""" + if not isinstance(path, str): + raise ValueError(f'Invalid path: {path!r}') + for segment in path.split('/'): + if segment in ('.', '..'): + raise ValueError(f'Invalid path (traversal not allowed): {path!r}') + return path + + async def get_tools(nc: AsyncNextcloudApp): @tool @@ -20,6 +30,7 @@ async def get_file_content(file_path: str): :return: """ + _validate_path(file_path) user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{file_path}", headers={ @@ -70,6 +81,7 @@ async def create_public_sharing_link(path: str): :return: """ + _validate_path(path) response = await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={ 'path': path, 'shareType': 3, @@ -86,6 +98,7 @@ async def upload_file(path: str, content: str): :param content: the text content to write to the file :return: success confirmation """ + _validate_path(path) user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] response = await nc._session._create_adapter(True).request('PUT', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{path}", headers={ @@ -102,6 +115,7 @@ async def create_folder(path: str): :param path: the path of the folder to create (e.g., "/Documents/NewFolder") :return: success confirmation """ + _validate_path(path) user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] response = await nc._session._create_adapter(True).request('MKCOL', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{path}", headers={ @@ -119,6 +133,8 @@ async def move_file(source_path: str, destination_path: str): :param destination_path: the new path for the file/folder :return: success confirmation """ + _validate_path(source_path) + _validate_path(destination_path) user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] response = await nc._session._create_adapter(True).request('MOVE', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{source_path}", headers={ @@ -136,6 +152,8 @@ async def copy_file(source_path: str, destination_path: str): :param destination_path: the destination path :return: success confirmation """ + _validate_path(source_path) + _validate_path(destination_path) user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] response = await nc._session._create_adapter(True).request('COPY', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{source_path}", headers={ @@ -152,6 +170,7 @@ async def delete_file(path: str): :param path: the path of the file/folder to delete :return: success confirmation """ + _validate_path(path) user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] response = await nc._session._create_adapter(True).request('DELETE', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{path}", headers={ @@ -176,4 +195,4 @@ def get_category_name(): return "Files" async def is_available(nc: AsyncNextcloudApp): - return True \ No newline at end of file + return True diff --git a/ex_app/lib/all_tools/here.py b/ex_app/lib/all_tools/here.py index f1350f6..737452f 100644 --- a/ex_app/lib/all_tools/here.py +++ b/ex_app/lib/all_tools/here.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import typing import datetime -import urllib.parse import niquests from langchain_core.tools import tool @@ -28,11 +27,16 @@ async def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon """ if departure_time is None: - departure_time = urllib.parse.quote_plus(datetime.datetime.now(datetime.UTC).isoformat()) + departure_time = datetime.datetime.now(datetime.UTC).isoformat() api_key = await nc.appconfig_ex.get_value('here_api') - res = await niquests.async_api.get('https://transit.hereapi.com/v8/routes?transportMode=car&origin=' - + origin_lat + ',' + origin_lon + '&destination=' + destination_lat + ',' + destination_lon - + '&alternatives=' + str(routes-1) + '&departureTime=' + departure_time + '&apikey=' + api_key) + res = await niquests.async_api.get('https://transit.hereapi.com/v8/routes', params={ + 'transportMode': 'car', + 'origin': f'{origin_lat},{origin_lon}', + 'destination': f'{destination_lat},{destination_lon}', + 'alternatives': str(routes - 1), + 'departureTime': departure_time, + 'apikey': api_key, + }) json = res.json() return json diff --git a/ex_app/lib/all_tools/openstreetmap.py b/ex_app/lib/all_tools/openstreetmap.py index afafd15..76ee5cb 100644 --- a/ex_app/lib/all_tools/openstreetmap.py +++ b/ex_app/lib/all_tools/openstreetmap.py @@ -1,5 +1,7 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import urllib.parse + import niquests from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp @@ -45,12 +47,23 @@ async def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destinat profile_num = "1" case "routed-foot": profile_num = "2" - case _: + case _: profile = "routed-foot" - profile_num = "2" - url = f'https://routing.openstreetmap.de/{profile}/route/v1/driving/{origin_lon},{origin_lat};{destination_lon},{destination_lat}?overview=false&steps=true' - map_url = f' https://routing.openstreetmap.de/?loc={origin_lat}%2C{origin_lon}&loc={destination_lat}%2C{destination_lon}&srv={profile_num}' - res = await niquests.async_api.get(url) + profile_num = "2" + # URL-encode each coordinate so untrusted values can't break out of the + # path segment (commas/semicolons are intentionally kept as separators + # expected by the OSRM route endpoint). + o_lat = urllib.parse.quote(origin_lat, safe='') + o_lon = urllib.parse.quote(origin_lon, safe='') + d_lat = urllib.parse.quote(destination_lat, safe='') + d_lon = urllib.parse.quote(destination_lon, safe='') + url = f'https://routing.openstreetmap.de/{profile}/route/v1/driving/{o_lon},{o_lat};{d_lon},{d_lat}' + map_url = 'https://routing.openstreetmap.de/?' + urllib.parse.urlencode([ + ('loc', f'{origin_lat},{origin_lon}'), + ('loc', f'{destination_lat},{destination_lon}'), + ('srv', profile_num), + ]) + res = await niquests.async_api.get(url, params={'overview': 'false', 'steps': 'true'}) json = res.json() return {'route_json_description': json, 'map_url': map_url} From 78f7b9cebb64101086ecd0ba05c54e84b1680bb5 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 18 Jun 2026 10:16:20 +0200 Subject: [PATCH 2/4] fix(ci: Use correct APP_PERSISTENT_STORAGE path Signed-off-by: Marcel Klehr --- .github/workflows/integration_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 5b3d8e8..a436f18 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -169,7 +169,7 @@ jobs: APP_PORT: 9080 APP_VERSION: ${{ fromJson(steps.llm2_appinfo.outputs.result).version }} run: | - APP_PERSISTENT_STORAGE="$(pwd)/../../llm2-persistent-storage/" poetry run python3 main.py > ../logs 2>&1 & + APP_PERSISTENT_STORAGE="$(pwd)/../../llm2-persistent_storage/" poetry run python3 main.py > ../logs 2>&1 & - name: Register backend run: | From 7fdf0b9a8714c158a85f3c850b560f17da53ad9c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 18 Jun 2026 11:34:54 +0200 Subject: [PATCH 3/4] fix: Fix cookbook search tool Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/cookbook.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/cookbook.py b/ex_app/lib/all_tools/cookbook.py index 1c573ca..462ce32 100644 --- a/ex_app/lib/all_tools/cookbook.py +++ b/ex_app/lib/all_tools/cookbook.py @@ -36,8 +36,9 @@ async def search_recipes(search_term: str): :param search_term: text to search for in recipe names and descriptions :return: list of matching recipes """ - encoded_search_term = urllib.parse.quote(search_term, safe='') - response = await nc._session._create_adapter().request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/cookbook/api/v1/search/{encoded_search_term}", headers={ + if '/' in search_term or '\\' in search_term: + raise ValueError("Search term cannot contain slashes.") + response = await nc._session._create_adapter().request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/cookbook/api/v1/search/{search_term}", headers={ "Content-Type": "application/json", "OCS-APIREQUEST": "true", }) From 8192c7ad0a663817ac317716b976c4651ab7f409 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 18 Jun 2026 11:36:10 +0200 Subject: [PATCH 4/4] fix: Avoid unnecessary checks Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/circles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/circles.py b/ex_app/lib/all_tools/circles.py index b32bf07..abb38d1 100644 --- a/ex_app/lib/all_tools/circles.py +++ b/ex_app/lib/all_tools/circles.py @@ -17,13 +17,13 @@ def _validate_circle_id(circle_id: str) -> str: - if not isinstance(circle_id, str) or '/' in circle_id or '\\' in circle_id: + if '/' in circle_id or '\\' in circle_id: raise ValueError(f'Invalid circle id: {circle_id!r}') return circle_id def _validate_member_id(member_id: str) -> str: - if not isinstance(member_id, str) or '/' in member_id or '\\' in member_id: + if '/' in member_id or '\\' in member_id: raise ValueError(f'Invalid member id: {member_id!r}') return member_id