Skip to content

Commit bd296c3

Browse files
authored
fix: path traversal vulnerabilities in migration image imports and media routes (#7474)
1 parent 8aa016e commit bd296c3

16 files changed

Lines changed: 113 additions & 43 deletions

mealie/routes/admin/admin_backups.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@
1919

2020
@controller(router)
2121
class AdminBackupController(BaseAdminController):
22-
def _backup_path(self, name) -> Path:
23-
return get_app_dirs().BACKUP_DIR / name
22+
def _backup_path(self, name: str) -> Path:
23+
backup_dir = get_app_dirs().BACKUP_DIR
24+
candidate = (backup_dir / name).resolve()
25+
if not candidate.is_relative_to(backup_dir.resolve()):
26+
raise HTTPException(status.HTTP_400_BAD_REQUEST)
27+
return candidate
2428

2529
@router.get("", response_model=AllBackups)
2630
def get_all(self):
@@ -86,7 +90,7 @@ def upload_one(self, archive: UploadFile = File(...)):
8690
app_dirs = get_app_dirs()
8791
dest = app_dirs.BACKUP_DIR.joinpath(f"{name}.zip")
8892

89-
if dest.absolute().parent != app_dirs.BACKUP_DIR:
93+
if dest.resolve().parent != app_dirs.BACKUP_DIR.resolve():
9094
raise HTTPException(status.HTTP_400_BAD_REQUEST)
9195

9296
with dest.open("wb") as buffer:

mealie/routes/admin/admin_debug.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import shutil
3+
from pathlib import Path
34

45
from fastapi import APIRouter, File, UploadFile
56

@@ -25,9 +26,12 @@ async def debug_openai(self, image: UploadFile | None = File(None)):
2526

2627
with get_temporary_path() as temp_path:
2728
if image:
28-
with temp_path.joinpath(image.filename).open("wb") as buffer:
29+
if not image.filename:
30+
return DebugResponse(success=False, response="Invalid image filename")
31+
safe_filename = Path(image.filename).name
32+
local_image_path = temp_path.joinpath(safe_filename)
33+
with local_image_path.open("wb") as buffer:
2934
shutil.copyfileobj(image.file, buffer)
30-
local_image_path = temp_path.joinpath(image.filename)
3135
local_images = [OpenAILocalImage(filename=os.path.basename(local_image_path), path=local_image_path)]
3236
else:
3337
local_images = None

mealie/routes/media/media_recipe.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class ImageType(StrEnum):
1717

1818

1919
@router.get("/{recipe_id}/images/{file_name}")
20-
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original):
20+
async def get_recipe_img(recipe_id: UUID4, file_name: ImageType = ImageType.original):
2121
"""
2222
Takes in a recipe id, returns the static image. This route is proxied in the docker image
2323
and should not hit the API in production
@@ -32,7 +32,7 @@ async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.origin
3232

3333
@router.get("/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}")
3434
async def get_recipe_timeline_event_img(
35-
recipe_id: str, timeline_event_id: str, file_name: ImageType = ImageType.original
35+
recipe_id: UUID4, timeline_event_id: UUID4, file_name: ImageType = ImageType.original
3636
):
3737
"""
3838
Takes in a recipe id and event timeline id, returns the static image. This route is proxied in the docker image
@@ -51,7 +51,11 @@ async def get_recipe_timeline_event_img(
5151
@router.get("/{recipe_id}/assets/{file_name}")
5252
async def get_recipe_asset(recipe_id: UUID4, file_name: str):
5353
"""Returns a recipe asset"""
54-
file = Recipe.directory_from_id(recipe_id).joinpath("assets", file_name)
54+
asset_dir = Recipe.directory_from_id(recipe_id).joinpath("assets")
55+
file = asset_dir.joinpath(file_name).resolve()
56+
57+
if not file.is_relative_to(asset_dir.resolve()):
58+
raise HTTPException(status.HTTP_400_BAD_REQUEST)
5559

5660
if file.exists():
5761
return FileResponse(file)

mealie/routes/media/media_user.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
async def get_user_image(user_id: UUID4, file_name: str):
1212
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
1313
and should not hit the API in production"""
14-
recipe_image = PrivateUser.get_directory(user_id) / file_name
14+
user_dir = PrivateUser.get_directory(user_id)
15+
recipe_image = (user_dir / file_name).resolve()
16+
17+
if not recipe_image.is_relative_to(user_dir.resolve()):
18+
raise HTTPException(status.HTTP_400_BAD_REQUEST)
1519

1620
if recipe_image.exists():
1721
return FileResponse(recipe_image, media_type="image/webp")

mealie/services/migrations/_migration_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,8 @@ def clean_recipe_dictionary(self, recipe_dict: dict) -> Recipe:
272272
recipe = cleaner.clean(recipe_dict, self.translator, url=recipe_dict.get("org_url", None))
273273
return recipe
274274

275-
def import_image(self, slug: str, src: str | Path, recipe_id: UUID4):
275+
def import_image(self, slug: str, src: str | Path, recipe_id: UUID4, extraction_root: Path | None = None):
276276
try:
277-
import_image(src, recipe_id)
277+
import_image(src, recipe_id, extraction_root=extraction_root)
278278
except UnidentifiedImageError as e:
279279
self.logger.error(f"Failed to import image for {slug}: {e}")

mealie/services/migrations/chowdown.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from ._migration_base import BaseMigrator
66
from .utils.migration_alias import MigrationAlias
7-
from .utils.migration_helpers import MigrationReaders, split_by_comma
7+
from .utils.migration_helpers import MigrationReaders, safe_local_path, split_by_comma
88

99

1010
class ChowdownMigrator(BaseMigrator):
@@ -60,8 +60,10 @@ def _migrate(self) -> None:
6060
continue
6161

6262
if r.image:
63-
cd_image = image_dir.joinpath(r.image)
63+
cd_image = safe_local_path(image_dir.joinpath(r.image), image_dir)
64+
else:
65+
cd_image = None
6466
except StopIteration:
6567
continue
6668
if cd_image:
67-
self.import_image(slug, cd_image, recipe_id)
69+
self.import_image(slug, cd_image, recipe_id, extraction_root=image_dir)

mealie/services/migrations/cookn.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mealie.services.parser_services.parser_utils.string_utils import extract_quantity_from_string
1212

1313
from ._migration_base import BaseMigrator
14-
from .utils.migration_helpers import format_time
14+
from .utils.migration_helpers import format_time, safe_local_path
1515

1616

1717
class DSVParser:
@@ -157,15 +157,21 @@ def _parse_media(self, _cookbook_id: str, _chapter_id: str, _recipe_id: str, db:
157157
if _media_type != "":
158158
# Determine file extension based on media type
159159
_extension = _media_type.split("/")[-1]
160-
_old_image_path = os.path.join(db.directory, str(_media_id))
161-
new_image_path = f"{_old_image_path}.{_extension}"
160+
_old_image_path = Path(db.directory) / str(_media_id)
161+
new_image_path = _old_image_path.with_suffix(f".{_extension}")
162+
if safe_local_path(_old_image_path, db.directory) is None:
163+
return None
164+
if safe_local_path(new_image_path, db.directory) is None:
165+
return None
162166
# Rename the file if it exists and has no extension
163-
if os.path.exists(_old_image_path) and not os.path.exists(new_image_path):
167+
if _old_image_path.exists() and not new_image_path.exists():
164168
os.rename(_old_image_path, new_image_path)
165-
if Path(new_image_path).exists():
166-
return new_image_path
169+
if new_image_path.exists():
170+
return str(new_image_path)
167171
else:
168-
return os.path.join(db.directory, str(_media_id))
172+
candidate = Path(db.directory) / str(_media_id)
173+
if safe_local_path(candidate, db.directory) is not None:
174+
return str(candidate)
169175
return None
170176

171177
def _parse_ingredients(self, _recipe_id: str, db: DSVParser) -> list[RecipeIngredient]:
@@ -388,14 +394,14 @@ def _process_cookbook(self, path: Path) -> None:
388394
recipe = recipe_lookup.get(slug)
389395
if recipe:
390396
if recipe.image:
391-
self.import_image(slug, recipe.image, recipe_id)
397+
self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
392398
else:
393399
index_len = len(slug.split("-")[-1])
394400
recipe = recipe_lookup.get(slug[: -(index_len + 1)])
395401
if recipe:
396402
self.logger.warning("Duplicate recipe (%s) found! Saved as copy...", recipe.name)
397403
if recipe.image:
398-
self.import_image(slug, recipe.image, recipe_id)
404+
self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
399405
else:
400406
self.logger.warning("Failed to lookup recipe! (%s)", slug)
401407

mealie/services/migrations/copymethat.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ._migration_base import BaseMigrator
1111
from .utils.migration_alias import MigrationAlias
12-
from .utils.migration_helpers import import_image
12+
from .utils.migration_helpers import import_image, safe_local_path
1313

1414

1515
def parse_recipe_tags(tags: list) -> list[str]:
@@ -52,7 +52,9 @@ def _process_recipe_document(self, source_dir: Path, soup: BeautifulSoup) -> dic
5252
# the recipe image tag has no id, so we parse it directly
5353
if tag.name == "img" and "recipeImage" in tag.get("class", []):
5454
if image_path := tag.get("src"):
55-
recipe_dict["image"] = str(source_dir.joinpath(image_path))
55+
safe = safe_local_path(source_dir.joinpath(image_path), source_dir)
56+
if safe is not None:
57+
recipe_dict["image"] = str(safe)
5658

5759
continue
5860

@@ -120,4 +122,4 @@ def _migrate(self) -> None:
120122
except StopIteration:
121123
continue
122124

123-
import_image(r.image, recipe_id)
125+
import_image(r.image, recipe_id, extraction_root=source_dir)

mealie/services/migrations/nextcloud.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,4 @@ def _migrate(self) -> None:
9797
if status:
9898
nc_dir = nextcloud_dirs[slug]
9999
if nc_dir.image:
100-
self.import_image(slug, nc_dir.image, recipe_id)
100+
self.import_image(slug, nc_dir.image, recipe_id, extraction_root=base_dir)

mealie/services/migrations/paprika.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,6 @@ def _migrate(self) -> None:
8484
temp_file.write(image.read())
8585
temp_file.flush()
8686
path = Path(temp_file.name)
87-
self.import_image(slug, path, recipe_id)
87+
self.import_image(slug, path, recipe_id, extraction_root=path.parent)
8888
except Exception as e:
8989
self.logger.error(f"Failed to import image for {slug}: {e}")

0 commit comments

Comments
 (0)