diff --git a/custom_components/bahmcloud_store/views.py b/custom_components/bahmcloud_store/views.py index 2afb852..979f670 100644 --- a/custom_components/bahmcloud_store/views.py +++ b/custom_components/bahmcloud_store/views.py @@ -14,13 +14,118 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +def _render_markdown_server_side(md: str) -> str | None: + """ + Render Markdown -> sanitized HTML. + We do NOT rely on Home Assistant internal markdown utilities (they may change), + and we do NOT rely on frontend JS libs (marked/DOMPurify may be unavailable). + """ + text = (md or "").strip() + if not text: + return None + + html: str | None = None + + # 1) Try python-markdown (commonly available in HA) + try: + import markdown as mdlib # type: ignore + + html = mdlib.markdown( + text, + extensions=[ + "fenced_code", + "tables", + "sane_lists", + "toc", + ], + output_format="html5", + ) + except Exception as e: + _LOGGER.debug("python-markdown render failed: %s", e) + html = None + + if not html: + return None + + # 2) Sanitize via bleach if available (preferred) + try: + import bleach # type: ignore + + allowed_tags = [ + # structure + "p", + "br", + "hr", + "div", + "span", + "blockquote", + "pre", + "code", + # headings + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + # lists + "ul", + "ol", + "li", + # emphasis + "strong", + "em", + "b", + "i", + "u", + "s", + # links/images + "a", + "img", + # tables + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ] + + allowed_attrs = { + "a": ["href", "title", "target", "rel"], + "img": ["src", "alt", "title"], + "th": ["align"], + "td": ["align"], + "*": ["class"], + } + + sanitized = bleach.clean( + html, + tags=allowed_tags, + attributes=allowed_attrs, + protocols=["http", "https", "mailto"], + strip=True, + ) + + # Make links safe by default (no opener) + sanitized = sanitized.replace(' web.Response: @@ -33,7 +138,6 @@ class StaticAssetsView(HomeAssistantView): target = (base / req_path).resolve() - # Prevent path traversal if not str(target).startswith(str(base_resolved)): return web.Response(status=404) @@ -44,7 +148,6 @@ class StaticAssetsView(HomeAssistantView): _LOGGER.error("BCS static asset not found: %s", target) return web.Response(status=404) - # aiohttp: do NOT include charset in content_type string content_type = "text/plain" charset = None @@ -63,8 +166,6 @@ class StaticAssetsView(HomeAssistantView): content_type = "image/png" resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset) - - # Development friendly (prevents stale cached panel.js) resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Pragma"] = "no-cache" return resp @@ -80,7 +181,11 @@ class BCSApiView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: return web.json_response( - {"ok": True, "version": self.core.version, "repos": self.core.list_repos_public()} + { + "ok": True, + "version": self.core.version, + "repos": self.core.list_repos_public(), + } ) async def post(self, request: web.Request) -> web.Response: @@ -132,19 +237,6 @@ class BCSReadmeView(HomeAssistantView): if not md: return web.json_response({"ok": False, "message": "README not found."}, status=404) - # For now keep html best-effort (we will perfect it after the store is stable again) - html = None - try: - from homeassistant.util.markdown import async_render_markdown # type: ignore - html = await async_render_markdown(self.core.hass, md) - except Exception as e: - _LOGGER.debug("Markdown render failed: %s", e) - - if html: - try: - from homeassistant.util.sanitize_html import async_sanitize_html # type: ignore - html = await async_sanitize_html(self.core.hass, html) - except Exception as e: - _LOGGER.debug("HTML sanitize not available/failed: %s", e) + html = _render_markdown_server_side(md) return web.json_response({"ok": True, "readme": md, "html": html})