diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index e7cc4499b006..9cda17e24503 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -17,6 +17,10 @@ DATA_MEDIA_SOURCE_PLATFORMS: HassKey[LazyIntegrationPlatforms[MediaSource]] = Ha "media_source_platforms" ) MEDIA_MIME_TYPES = ("audio", "video", "image") +# Media types that pass the check above but are documents a browser executes. +# Serving these inline would run them on the Home Assistant origin, where the +# frontend keeps its tokens, so the browser is told to download them instead. +DOWNLOAD_ONLY_MIME_TYPES = {"image/svg+xml"} MEDIA_CLASS_MAP = { "audio": MediaClass.MUSIC, "video": MediaClass.VIDEO, diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 9f60ea95808b..104ee1eb59ac 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -7,7 +7,7 @@ from pathlib import Path import shutil from typing import Any, Protocol, cast, override -from aiohttp import web +from aiohttp import hdrs, web from aiohttp.web_request import FileField import probatio @@ -24,7 +24,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path -from .const import DATA_LOCAL_SOURCE, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES +from .const import ( + DATA_LOCAL_SOURCE, + DOMAIN, + DOWNLOAD_ONLY_MIME_TYPES, + MEDIA_CLASS_MAP, + MEDIA_MIME_TYPES, +) from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia @@ -349,6 +355,15 @@ class LocalSource(MediaSource): return media +@callback +def _async_media_headers(mime_type: str) -> dict[str, str]: + """Return the headers a media file of this type is served with.""" + if mime_type in DOWNLOAD_ONLY_MIME_TYPES: + return {hdrs.CONTENT_DISPOSITION: "attachment"} + + return {} + + class LocalMediaView(http.HomeAssistantView): """Local Media Finder View. @@ -364,8 +379,10 @@ class LocalMediaView(http.HomeAssistantView): self.name = source.url_prefix.strip("/").replace("/", ":") self.url = f"{source.url_prefix}/{{source_dir_id}}/{{location:.*}}" - async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: - """Validate media path and return it if valid.""" + async def _validate_media_path( + self, source_dir_id: str, location: str + ) -> tuple[Path, str]: + """Validate media path and return it with its media type if valid.""" try: raise_if_invalid_path(location) except ValueError as err: @@ -385,7 +402,7 @@ class LocalMediaView(http.HomeAssistantView): if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: raise web.HTTPNotFound - return media_path + return media_path, mime_type async def head( self, request: web.Request, source_dir_id: str, location: str @@ -397,16 +414,17 @@ class LocalMediaView(http.HomeAssistantView): Check whether the location exists or not. """ - media_path = await self._validate_media_path(source_dir_id, location) - mime_type, _ = mimetypes.guess_type(str(media_path)) - return web.Response(content_type=mime_type) + _, mime_type = await self._validate_media_path(source_dir_id, location) + return web.Response( + content_type=mime_type, headers=_async_media_headers(mime_type) + ) async def get( self, request: web.Request, source_dir_id: str, location: str ) -> web.FileResponse: """Handle a GET request.""" - media_path = await self._validate_media_path(source_dir_id, location) - return web.FileResponse(media_path) + media_path, mime_type = await self._validate_media_path(source_dir_id, location) + return web.FileResponse(media_path, headers=_async_media_headers(mime_type)) class UploadMediaView(http.HomeAssistantView): diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 47a7b030de79..bd228c1e5903 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -505,3 +505,73 @@ async def test_remove_file( assert not msg["success"] assert to_delete_3.is_file() + + +@pytest.mark.parametrize( + ("filename", "content_type"), + [ + ("photo.jpg", "image/jpeg"), + ("song.mp3", "audio/mpeg"), + ("clip.mp4", "video/mp4"), + ], +) +async def test_media_view_serves_media_inline( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + tmp_path: Path, + filename: str, + content_type: str, +) -> None: + """Test ordinary media is served for the browser to render.""" + (tmp_path / filename).touch() + + await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get(f"/media/local/{filename}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == content_type + assert "Content-Disposition" not in resp.headers + + resp = await client.head(f"/media/local/{filename}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == content_type + assert "Content-Disposition" not in resp.headers + + +async def test_media_view_serves_svg_as_attachment( + hass: HomeAssistant, hass_client: ClientSessionGenerator, tmp_path: Path +) -> None: + """Test an SVG is downloaded rather than rendered. + + An SVG is a document a browser executes, and these are served from the + Home Assistant origin. + """ + (tmp_path / "drawing.svg").write_text( + '' + ) + + await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/media/local/drawing.svg") + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Disposition"] == "attachment" + # Applied to every response by the HTTP integration, asserted here because + # it is what keeps the type from being sniffed into something executable + assert resp.headers["X-Content-Type-Options"] == "nosniff" + + resp = await client.head("/media/local/drawing.svg") + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Disposition"] == "attachment" + assert resp.headers["X-Content-Type-Options"] == "nosniff"