Skip to content

Commit 6f03010

Browse files
fix: Security Patches (#6743)
1 parent 69397c9 commit 6f03010

File tree

9 files changed

+73
-14
lines changed

9 files changed

+73
-14
lines changed

docs/docs/overrides/api.html

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

frontend/components/Domain/Group/GroupExportData.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<BaseButton
1515
download
1616
size="small"
17-
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
17+
:download-url="`/api/recipes/bulk-actions/export/${item.id}/download`"
1818
/>
1919
</template>
2020
</v-data-table>

frontend/components/global/SafeMarkdown.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default defineNuxtComponent({
2929
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
3030
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
3131
],
32-
ADD_ATTR: [
32+
ALLOWED_ATTR: [
3333
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
3434
"scrolling", "cite", "datetime", "name", "abbr", "target", "border",
3535
],

mealie/routes/recipe/bulk_actions.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pathlib import Path
33

44
from fastapi import APIRouter, HTTPException
5+
from pydantic import UUID4
56

67
from mealie.core.dependencies.dependencies import get_temporary_zip_path
78
from mealie.core.security import create_file_token
@@ -48,14 +49,15 @@ def bulk_export_recipes(self, export_recipes: ExportRecipes):
4849
with get_temporary_zip_path() as temp_path:
4950
self.service.export_recipes(temp_path, export_recipes.recipes)
5051

51-
@router.get("/export/download")
52-
def get_exported_data_token(self, path: Path):
52+
@router.get("/export/{export_id}/download")
53+
def get_exported_data_token(self, export_id: UUID4):
5354
"""Returns a token to download a file"""
54-
path = Path(path).resolve()
5555

56-
if not path.is_relative_to(self.folders.DATA_DIR):
57-
raise HTTPException(400, "path must be relative to data directory")
56+
export = self.service.get_export(export_id)
57+
if not export:
58+
raise HTTPException(404, "export not found")
5859

60+
path = Path(export.path).resolve()
5961
return {"fileToken": create_file_token(path)}
6062

6163
@router.get("/export", response_model=list[GroupDataExport])

mealie/routes/utility_routes.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ async def download_file(file_path: Path = Depends(validate_file_token)):
1717
file_path = Path(file_path).resolve()
1818

1919
dirs = get_app_dirs()
20+
allowed_dirs = [
21+
dirs.BACKUP_DIR, # admin backups
22+
dirs.GROUPS_DIR, # group exports
23+
]
2024

21-
if not file_path.is_relative_to(dirs.DATA_DIR):
25+
if not any(file_path.is_relative_to(allowed_dir) for allowed_dir in allowed_dirs):
2226
raise HTTPException(status.HTTP_400_BAD_REQUEST)
2327

2428
if not file_path.is_file():

mealie/services/recipe/recipe_bulk_service.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from pathlib import Path
22

3+
from pydantic import UUID4
4+
35
from mealie.core.exceptions import UnexpectedNone
46
from mealie.repos.repository_factory import AllRepositories
57
from mealie.schema.group.group_exports import GroupDataExport
68
from mealie.schema.recipe import CategoryBase
79
from mealie.schema.recipe.recipe_category import TagBase
810
from mealie.schema.recipe.recipe_settings import RecipeSettings
11+
from mealie.schema.response.pagination import PaginationQuery
912
from mealie.schema.user.user import GroupInDB, PrivateUser
1013
from mealie.services._base_service import BaseService
1114
from mealie.services.exporter import Exporter, RecipeExporter
@@ -25,7 +28,11 @@ def export_recipes(self, temp_path: Path, slugs: list[str]) -> None:
2528
exporter.run(self.repos)
2629

2730
def get_exports(self) -> list[GroupDataExport]:
28-
return self.repos.group_exports.multi_query({"group_id": self.group.id})
31+
exports_page = self.repos.group_exports.page_all(PaginationQuery(per_page=-1))
32+
return exports_page.items
33+
34+
def get_export(self, id: UUID4) -> GroupDataExport | None:
35+
return self.repos.group_exports.get_one(id)
2936

3037
def purge_exports(self) -> int:
3138
all_exports = self.get_exports()

tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,12 @@ def test_bulk_export_recipes(api_client: TestClient, unique_user: TestUser, ten_
123123
response_data = response.json()
124124
assert len(response_data) == 1
125125

126+
export_id = response_data[0]["id"]
126127
export_path = response_data[0]["path"]
127128

128129
# Get Export Token
129130
response = api_client.get(
130-
f"{api_routes.recipes_bulk_actions_export_download}?path={export_path}", headers=unique_user.token
131+
f"{api_routes.recipes_bulk_actions_export_export_id_download(export_id)}", headers=unique_user.token
131132
)
132133
assert response.status_code == 200
133134

tests/unit_tests/test_security.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import ldap
44
import pytest
5+
from fastapi import HTTPException
56
from pytest import MonkeyPatch
67

78
from mealie.core import security
8-
from mealie.core.config import get_app_settings
9+
from mealie.core.config import get_app_dirs, get_app_settings
910
from mealie.core.dependencies import validate_file_token
1011
from mealie.core.security.providers.credentials_provider import (
1112
CredentialsProvider,
@@ -15,6 +16,7 @@
1516
from mealie.db.db_setup import session_context
1617
from mealie.db.models.users.users import AuthMethod
1718
from mealie.repos.repository_factory import AllRepositories
19+
from mealie.routes.utility_routes import download_file
1820
from mealie.schema.user.auth import CredentialsRequestForm
1921
from mealie.schema.user.user import PrivateUser
2022
from tests.utils import random_string
@@ -122,6 +124,46 @@ def test_create_file_token():
122124
assert file_path == validate_file_token(file_token)
123125

124126

127+
@pytest.mark.asyncio
128+
async def test_download_file_security_restrictions():
129+
dirs = get_app_dirs()
130+
131+
# Test 1: File in DATA_DIR but outside allowed dirs should be blocked
132+
secret_file = dirs.DATA_DIR / ".secret"
133+
134+
with pytest.raises(HTTPException) as exc_info:
135+
await download_file(secret_file)
136+
assert exc_info.value.status_code == 400
137+
138+
# Test 2: File in BACKUP_DIR should be allowed (but only if it exists)
139+
backup_file = dirs.BACKUP_DIR / "test.zip"
140+
dirs.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
141+
backup_file.write_text("test backup content")
142+
143+
try:
144+
response = await download_file(backup_file)
145+
assert response.media_type == "application/octet-stream"
146+
assert response.path == backup_file
147+
finally:
148+
backup_file.unlink(missing_ok=True)
149+
150+
# Test 3: File in GROUPS_DIR should be allowed (but only if it exists)
151+
export_dir = dirs.GROUPS_DIR / "some-group-id" / "export"
152+
export_dir.mkdir(parents=True, exist_ok=True)
153+
export_file = export_dir / "test.zip"
154+
export_file.write_text("test export content")
155+
156+
try:
157+
response = await download_file(export_file)
158+
assert response.media_type == "application/octet-stream"
159+
assert response.path == export_file
160+
finally:
161+
export_file.unlink(missing_ok=True)
162+
# Clean up the directory structure
163+
export_dir.rmdir()
164+
(dirs.GROUPS_DIR / "some-group-id").rmdir()
165+
166+
125167
def get_provider(session, username: str, password: str):
126168
request_data = CredentialsRequest(username=username, password=password)
127169
return LDAPProvider(session, request_data)

tests/utils/api_routes/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,6 @@
141141
"""`/api/recipes/bulk-actions/delete`"""
142142
recipes_bulk_actions_export = "/api/recipes/bulk-actions/export"
143143
"""`/api/recipes/bulk-actions/export`"""
144-
recipes_bulk_actions_export_download = "/api/recipes/bulk-actions/export/download"
145-
"""`/api/recipes/bulk-actions/export/download`"""
146144
recipes_bulk_actions_export_purge = "/api/recipes/bulk-actions/export/purge"
147145
"""`/api/recipes/bulk-actions/export/purge`"""
148146
recipes_bulk_actions_settings = "/api/recipes/bulk-actions/settings"
@@ -463,6 +461,11 @@ def organizers_tools_slug_tool_slug(tool_slug):
463461
return f"{prefix}/organizers/tools/slug/{tool_slug}"
464462

465463

464+
def recipes_bulk_actions_export_export_id_download(export_id):
465+
"""`/api/recipes/bulk-actions/export/{export_id}/download`"""
466+
return f"{prefix}/recipes/bulk-actions/export/{export_id}/download"
467+
468+
466469
def recipes_shared_token_id(token_id):
467470
"""`/api/recipes/shared/{token_id}`"""
468471
return f"{prefix}/recipes/shared/{token_id}"

0 commit comments

Comments
 (0)