Serve SVG media as attachments (#182095)

This commit is contained in:
Franck Nijhof
2026-09-18 17:41:03 +02:00
committed by GitHub
parent c9664a9ea8
commit 6f9563fadd
3 changed files with 102 additions and 10 deletions
@@ -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,
@@ -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):
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
)
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"