Fixed command parsing to use raw_body and added default HTML escaping to outbound chat messages.
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 21s
CI / Tests (Python 3.13) (push) Successful in 20s
CI / Tests (Python 3.14) (push) Successful in 17s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 4s
Audit / Dependencies (push) Failing after 7s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 21s
CI / Tests (Python 3.13) (push) Successful in 20s
CI / Tests (Python 3.14) (push) Successful in 17s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 4s
Audit / Dependencies (push) Failing after 7s
I got bamboozled. The command system was originally built on the `body` field in Owncast's webhook payloads which contains rendered HTML rather than raw user input like I expected. Switching to `rawBody` makes more sense in my opinion, but doing so introduces a new risk: modules like custom commands can echo user input back to chat via placeholders like `$(1)` and, as it turns out, Owncast does not sanitize messages from integrations the way it does for regular users. This means unsanitized HTML could be injected into chat through the bot if the body was blindly swapped for the raw body. All OwncastClient send methods now HTML-escape outgoing message bodies by default using markupsafe, with an `unsanitized=True` keyword-only opt-out for modules that intentionally need to send raw HTML.
This commit is contained in:
+1
-1
Submodule docs updated: 458065420a...8f83416203
@@ -124,8 +124,14 @@ class ChatEvent:
|
|||||||
|
|
||||||
user: User
|
user: User
|
||||||
client_id: int
|
client_id: int
|
||||||
|
|
||||||
|
# Rendered HTML from Owncast (markdown converted, custom emotes as <img>
|
||||||
|
# tags, outer <p> wrapper stripped).
|
||||||
body: str
|
body: str
|
||||||
|
|
||||||
|
# Original user input before Owncast rendering.
|
||||||
raw_body: str
|
raw_body: str
|
||||||
|
|
||||||
message_id: str
|
message_id: str
|
||||||
is_visible: bool
|
is_visible: bool
|
||||||
timestamp: datetime | None
|
timestamp: datetime | None
|
||||||
@@ -137,8 +143,9 @@ class ChatEvent:
|
|||||||
:param data: The event data from the webhook payload.
|
:param data: The event data from the webhook payload.
|
||||||
:return: A populated ChatEvent instance.
|
:return: A populated ChatEvent instance.
|
||||||
"""
|
"""
|
||||||
# Owncast wraps the message body in paragraph tags for HTML rendering.
|
# Owncast wraps the rendered body in <p> tags. Strip the outer
|
||||||
# We strip these off so handlers get clean text without HTML cruft.
|
# wrapper so handlers don't have to deal with it, but the
|
||||||
|
# contents are still rendered HTML (bold, links, emote <img>s, etc.).
|
||||||
body = data.get("body", "").strip()
|
body = data.get("body", "").strip()
|
||||||
|
|
||||||
if body.startswith("<p>") and body.endswith("</p>"):
|
if body.startswith("<p>") and body.endswith("</p>"):
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import ContentTypeError
|
from aiohttp import ContentTypeError
|
||||||
|
from markupsafe import escape as _escape_html
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .http_client import HttpClient
|
from .http_client import HttpClient
|
||||||
@@ -102,49 +103,78 @@ class OwncastClient:
|
|||||||
self._logger.debug("Fetching server status.")
|
self._logger.debug("Fetching server status.")
|
||||||
return dict(await self._get("/api/status"))
|
return dict(await self._get("/api/status"))
|
||||||
|
|
||||||
async def send_message(self, body: str) -> str:
|
async def send_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||||
"""Send a chat message visible to all viewers.
|
"""Send a chat message visible to all viewers.
|
||||||
|
|
||||||
|
The message body is HTML-escaped by default to prevent injection
|
||||||
|
when echoing user-originated content. Markdown syntax is unaffected
|
||||||
|
by escaping and will be rendered normally by Owncast.
|
||||||
|
|
||||||
:param body: The message text (supports markdown).
|
:param body: The message text (supports markdown).
|
||||||
|
:param unsanitized: If ``True``, skip HTML escaping and send the body
|
||||||
|
as-is. Only use this when the content is fully controlled by the
|
||||||
|
module and intentionally contains HTML.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
|
if not unsanitized:
|
||||||
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending chat message: {body}")
|
self._logger.info(f"Sending chat message: {body}")
|
||||||
return await self._post("/api/integrations/chat/send", {"body": body})
|
return await self._post("/api/integrations/chat/send", {"body": body})
|
||||||
|
|
||||||
async def send_system_message(self, body: str) -> str:
|
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||||
"""Send a system message visible to all viewers.
|
"""Send a system message visible to all viewers.
|
||||||
|
|
||||||
System messages are styled differently from regular chat (typically
|
System messages are styled differently from regular chat (typically
|
||||||
italicized or dimmed) and are used for announcements or notifications.
|
italicized or dimmed) and are used for announcements or notifications.
|
||||||
|
|
||||||
|
The message body is HTML-escaped by default. Pass
|
||||||
|
``unsanitized=True`` to send raw HTML intentionally.
|
||||||
|
|
||||||
:param body: The message text.
|
:param body: The message text.
|
||||||
|
:param unsanitized: If ``True``, skip HTML escaping.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
|
if not unsanitized:
|
||||||
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending system message: {body}")
|
self._logger.info(f"Sending system message: {body}")
|
||||||
return await self._post("/api/integrations/chat/system", {"body": body})
|
return await self._post("/api/integrations/chat/system", {"body": body})
|
||||||
|
|
||||||
async def send_action(self, body: str) -> str:
|
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
|
||||||
"""Send an action message (like IRC /me).
|
"""Send an action message (like IRC /me).
|
||||||
|
|
||||||
Action messages display as "*BotName does something*" and are used
|
Action messages display as "*BotName does something*" and are used
|
||||||
for describing actions rather than speech.
|
for describing actions rather than speech.
|
||||||
|
|
||||||
|
The message body is HTML-escaped by default. Pass
|
||||||
|
``unsanitized=True`` to send raw HTML intentionally.
|
||||||
|
|
||||||
:param body: The action text (displayed after the bot name).
|
:param body: The action text (displayed after the bot name).
|
||||||
|
:param unsanitized: If ``True``, skip HTML escaping.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
|
if not unsanitized:
|
||||||
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending action: {body}")
|
self._logger.info(f"Sending action: {body}")
|
||||||
return await self._post("/api/integrations/chat/action", {"body": body})
|
return await self._post("/api/integrations/chat/action", {"body": body})
|
||||||
|
|
||||||
async def send_system_message_to_client(self, client_id: int, body: str) -> str:
|
async def send_system_message_to_client(
|
||||||
|
self, client_id: int, body: str, *, unsanitized: bool = False
|
||||||
|
) -> str:
|
||||||
"""Send a private system message to a specific viewer.
|
"""Send a private system message to a specific viewer.
|
||||||
|
|
||||||
The message is only visible to the targeted client, useful for
|
The message is only visible to the targeted client, useful for
|
||||||
welcome messages or private notifications.
|
welcome messages or private notifications.
|
||||||
|
|
||||||
|
The message body is HTML-escaped by default. Pass
|
||||||
|
``unsanitized=True`` to send raw HTML intentionally.
|
||||||
|
|
||||||
:param client_id: The numeric client ID (from event.client_id).
|
:param client_id: The numeric client ID (from event.client_id).
|
||||||
:param body: The message text.
|
:param body: The message text.
|
||||||
|
:param unsanitized: If ``True``, skip HTML escaping.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
|
if not unsanitized:
|
||||||
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending system message to client {client_id}: {body}")
|
self._logger.info(f"Sending system message to client {client_id}: {body}")
|
||||||
return await self._post(
|
return await self._post(
|
||||||
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
|
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ class CommandDispatcher:
|
|||||||
|
|
||||||
:param event: The chat event to check for commands.
|
:param event: The chat event to check for commands.
|
||||||
"""
|
"""
|
||||||
parsed = self._command_registry.parse(event.body)
|
parsed = self._command_registry.parse(event.raw_body)
|
||||||
|
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ dependencies = [
|
|||||||
"aiohttp>=3.13.3",
|
"aiohttp>=3.13.3",
|
||||||
"aiosqlite>=0.22.1",
|
"aiosqlite>=0.22.1",
|
||||||
"cronsim>=2.7",
|
"cronsim>=2.7",
|
||||||
|
"emoji>=2.15.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
|
"markupsafe>=3.0.3",
|
||||||
"m3u8>=6.0.0",
|
"m3u8>=6.0.0",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def _make_chat_event(
|
|||||||
user=user or _make_user(),
|
user=user or _make_user(),
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
body=body,
|
body=body,
|
||||||
raw_body=f"<p>{body}</p>",
|
raw_body=body,
|
||||||
message_id="msg-001",
|
message_id="msg-001",
|
||||||
is_visible=True,
|
is_visible=True,
|
||||||
timestamp=None,
|
timestamp=None,
|
||||||
|
|||||||
@@ -371,6 +371,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "emoji"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.25.2"
|
version = "3.25.2"
|
||||||
@@ -868,8 +877,10 @@ dependencies = [
|
|||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "cronsim" },
|
{ name = "cronsim" },
|
||||||
|
{ name = "emoji" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "m3u8" },
|
{ name = "m3u8" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -891,8 +902,10 @@ requires-dist = [
|
|||||||
{ name = "aiohttp", specifier = ">=3.13.3" },
|
{ name = "aiohttp", specifier = ">=3.13.3" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||||
{ name = "cronsim", specifier = ">=2.7" },
|
{ name = "cronsim", specifier = ">=2.7" },
|
||||||
|
{ name = "emoji", specifier = ">=2.15.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||||
|
{ name = "markupsafe", specifier = ">=3.0.3" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user