Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/sentry/integrations/msteams/card_builder/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ class ColumnBlock(_ColumnBlockNotRequired):
width: ColumnWidth | str


class CodeBlock(TypedDict):
type: Literal["CodeBlock"]
codeSnippet: str


class ColumnSetBlock(TypedDict):
type: Literal["ColumnSet"]
columns: list[ColumnBlock]
Expand Down Expand Up @@ -147,7 +152,13 @@ class InputChoiceSetBlock(_InputChoiceSetBlockNotRequired):

ItemBlock: TypeAlias = str | TextBlock | ImageBlock
Block: TypeAlias = (
ActionSet | TextBlock | ImageBlock | ColumnSetBlock | ContainerBlock | InputChoiceSetBlock
ActionSet
| TextBlock
| ImageBlock
| ColumnSetBlock
| ContainerBlock
| InputChoiceSetBlock
| CodeBlock
)


Expand All @@ -173,6 +184,13 @@ def create_text_block(text: str | None, **kwargs: Unpack[_TextBlockNotRequired])
}


def create_code_block(text: str) -> CodeBlock:
return {
"type": "CodeBlock",
"codeSnippet": escape_markdown_special_chars(text),
}


def create_logo_block(**kwargs: Unpack[_ImageBlockNotRequired]) -> ImageBlock:
# Default size if no size is given
if not kwargs.get("height"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ def get(self, request: Request) -> Response:
def serialize_rendered_example(rendered_template: NotificationRenderedTemplate) -> dict[str, Any]:
response: dict[str, Any] = {
"subject": rendered_template.subject,
"body": rendered_template.body,
"body": [
{
"type": block.type,
"blocks": [
{"type": text_block.type, "text": text_block.text}
for text_block in block.blocks
],
}
for block in rendered_template.body
],
"actions": [
{"label": action.label, "link": action.link} for action in rendered_template.actions
],
Expand Down
49 changes: 48 additions & 1 deletion src/sentry/notifications/platform/discord/provider.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from sentry.notifications.platform.provider import NotificationProvider, NotificationProviderError
Expand All @@ -8,6 +10,10 @@
PreparedIntegrationNotificationTarget,
)
from sentry.notifications.platform.types import (
NotificationBodyFormattingBlock,
NotificationBodyFormattingBlockType,
NotificationBodyTextBlock,
NotificationBodyTextBlockType,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
Expand All @@ -18,6 +24,9 @@

if TYPE_CHECKING:
from sentry.integrations.discord.message_builder.base.base import DiscordMessage
from sentry.integrations.discord.message_builder.base.embed.field import (
DiscordMessageEmbedField,
)

# TODO(ecosystem): Proper typing - https://discord.com/developers/docs/resources/message#create-message
type DiscordRenderable = DiscordMessage
Expand Down Expand Up @@ -51,10 +60,12 @@ def render[DataT: NotificationData](
components: list[DiscordMessageComponent] = []
embeds = []

body_blocks = cls.render_body_blocks(rendered_template.body)

embeds.append(
DiscordMessageEmbed(
title=rendered_template.subject,
description=rendered_template.body,
fields=body_blocks,
image=(
DiscordMessageEmbedImage(url=rendered_template.chart.url)
if rendered_template.chart
Expand Down Expand Up @@ -82,6 +93,42 @@ def render[DataT: NotificationData](

return builder.build()

@classmethod
def render_body_blocks(
cls, body: list[NotificationBodyFormattingBlock]
) -> list[DiscordMessageEmbedField]:
from sentry.integrations.discord.message_builder.base.embed.field import (
DiscordMessageEmbedField,
)

fields = []
for block in body:
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
fields.append(
DiscordMessageEmbedField(
name=block.type.value, value=cls.render_text_blocks(block.blocks)
)
)
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
fields.append(
DiscordMessageEmbedField(
name=block.type.value, value=f"```{cls.render_text_blocks(block.blocks)}```"
)
)
return fields

@classmethod
def render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
texts = []
for block in blocks:
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
texts.append(block.text)
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
texts.append(f"**{block.text}**")
elif block.type == NotificationBodyTextBlockType.CODE:
texts.append(f"`{block.text}`")
return " ".join(texts)


@provider_registry.register(NotificationProviderKey.DISCORD)
class DiscordNotificationProvider(NotificationProvider[DiscordRenderable]):
Expand Down
69 changes: 66 additions & 3 deletions src/sentry/notifications/platform/email/provider.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
from django.core.mail import EmailMultiAlternatives
from django.core.mail.message import make_msgid
from django.utils.html import escape
from django.utils.safestring import mark_safe

from sentry import options
from sentry.notifications.platform.provider import NotificationProvider
from sentry.notifications.platform.registry import provider_registry
from sentry.notifications.platform.renderer import NotificationRenderer
from sentry.notifications.platform.target import GenericNotificationTarget
from sentry.notifications.platform.types import (
NotificationBodyFormattingBlock,
NotificationBodyFormattingBlockType,
NotificationBodyTextBlock,
NotificationBodyTextBlockType,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
Expand All @@ -32,24 +38,29 @@ class EmailRenderer(NotificationRenderer[EmailRenderable]):
def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
) -> EmailRenderable:
html_body_blocks = cls.render_body_blocks_to_html_string(rendered_template.body)
txt_body_blocks = cls.render_body_blocks_to_txt_string(rendered_template.body)

email_context = {
"subject": rendered_template.subject,
"body": rendered_template.body,
"actions": [(action.label, action.link) for action in rendered_template.actions],
"chart_url": rendered_template.chart.url if rendered_template.chart else None,
"chart_alt_text": rendered_template.chart.alt_text if rendered_template.chart else None,
"footer": rendered_template.footer,
}

html_email_context = {**email_context, "body": html_body_blocks}
txt_email_context = {**email_context, "body": txt_body_blocks}

html_body = inline_css(
render_to_string(
template=(rendered_template.email_html_path or DEFAULT_EMAIL_HTML_PATH),
context=email_context,
context=html_email_context,
)
)
txt_body = render_to_string(
template=(rendered_template.email_text_path or DEFAULT_EMAIL_TEXT_PATH),
context=email_context,
context=txt_email_context,
)
# Required by RFC 2822 (https://www.rfc-editor.org/rfc/rfc2822.html)
headers = {"Message-Id": make_msgid(domain=get_from_email_domain())}
Expand All @@ -68,6 +79,58 @@ def render[DataT: NotificationData](
email.attach_alternative(html_body, "text/html")
return email

@classmethod
def render_body_blocks_to_html_string(cls, body: list[NotificationBodyFormattingBlock]) -> str:
body_blocks = []
for block in body:
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
safe_content = cls.render_text_blocks_to_html_string(block.blocks)
body_blocks.append(f"<p>{safe_content}</p>")
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
safe_content = cls.render_text_blocks_to_html_string(block.blocks)
body_blocks.append(f"<pre><code>{safe_content}</code></pre>")

return mark_safe("".join(body_blocks))

@classmethod
def render_text_blocks_to_html_string(cls, blocks: list[NotificationBodyTextBlock]) -> str:
texts: list[str] = []
for block in blocks:
# Escape user content to prevent XSS
escaped_text = escape(block.text)

if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
texts.append(escaped_text)
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
# HTML tags are safe, content is escaped
texts.append(f"<strong>{escaped_text}</strong>")
elif block.type == NotificationBodyTextBlockType.CODE:
texts.append(f"<code>{escaped_text}</code>")

return " ".join(texts)

@classmethod
def render_body_blocks_to_txt_string(cls, blocks: list[NotificationBodyFormattingBlock]) -> str:
body_blocks = []
for block in blocks:
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
body_blocks.append(f"\n{cls.render_text_blocks_to_txt_string(block.blocks)}")
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
body_blocks.append(f"\n```{cls.render_text_blocks_to_txt_string(block.blocks)}```")
return mark_safe(" ".join(body_blocks))

@classmethod
def render_text_blocks_to_txt_string(cls, blocks: list[NotificationBodyTextBlock]) -> str:
texts = []
for block in blocks:
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
texts.append(block.text)
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
texts.append(f"**{block.text}**")
elif block.type == NotificationBodyTextBlockType.CODE:
texts.append(f"`{block.text}`")
return mark_safe(" ".join(texts))


@provider_registry.register(NotificationProviderKey.EMAIL)
class EmailNotificationProvider(NotificationProvider[EmailRenderable]):
Expand Down
42 changes: 36 additions & 6 deletions src/sentry/notifications/platform/msteams/provider.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from sentry.notifications.platform.provider import NotificationProvider, NotificationProviderError
Expand All @@ -8,6 +10,10 @@
PreparedIntegrationNotificationTarget,
)
from sentry.notifications.platform.types import (
NotificationBodyFormattingBlock,
NotificationBodyFormattingBlockType,
NotificationBodyTextBlock,
NotificationBodyTextBlockType,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
Expand All @@ -17,7 +23,7 @@
from sentry.organizations.services.organization.model import RpcOrganizationSummary

if TYPE_CHECKING:
from sentry.integrations.msteams.card_builder.block import AdaptiveCard
from sentry.integrations.msteams.card_builder.block import AdaptiveCard, Block

type MSTeamsRenderable = AdaptiveCard

Expand All @@ -35,8 +41,6 @@ def render[DataT: NotificationData](
Action,
ActionSet,
ActionType,
AdaptiveCard,
Block,
ImageBlock,
OpenUrlAction,
TextSize,
Expand All @@ -47,9 +51,8 @@ def render[DataT: NotificationData](
title_text = create_text_block(
text=rendered_template.subject, size=TextSize.LARGE, weight=TextWeight.BOLDER
)
body_text = create_text_block(text=rendered_template.body)

body_blocks: list[Block] = [title_text, body_text]
body_text = cls.render_body_blocks(rendered_template.body)
body_blocks: list[Block] = [title_text, *body_text]

if len(rendered_template.actions) > 0:
actions: list[Action] = []
Expand Down Expand Up @@ -81,6 +84,33 @@ def render[DataT: NotificationData](
}
return card

@classmethod
def render_body_blocks(cls, body: list[NotificationBodyFormattingBlock]) -> list[Block]:
from sentry.integrations.msteams.card_builder.block import (
create_code_block,
create_text_block,
)

body_blocks: list[Block] = []
for block in body:
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
body_blocks.append(create_text_block(text=cls.render_text_blocks(block.blocks)))
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
body_blocks.append(create_code_block(text=cls.render_text_blocks(block.blocks)))
return body_blocks

@classmethod
def render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
texts = []
for block in blocks:
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
texts.append(block.text)
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
texts.append(f"**{block.text}**")
elif block.type == NotificationBodyTextBlockType.CODE:
texts.append(f"`{block.text}`")
return " ".join(texts)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Markdown Formatting Vulnerable to User Input

The render_text_blocks method wraps user text with markdown formatting (** for bold, ` for code) but doesn't escape markdown special characters in the user text first. When the concatenated string is passed to create_text_block or create_code_block, escape_markdown_special_chars only escapes &, <, >, _ but not * or `. If user-supplied text like error_message contains these characters, it will break markdown rendering or cause unintended formatting injection.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've decided this a fine sacrifice, since the alternative is a lot of selective formatting. If it's a big problem I'll come back and do a patch



@provider_registry.register(NotificationProviderKey.MSTEAMS)
class MSTeamsNotificationProvider(NotificationProvider[MSTeamsRenderable]):
Expand Down
32 changes: 30 additions & 2 deletions src/sentry/notifications/platform/slack/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
PreparedIntegrationNotificationTarget,
)
from sentry.notifications.platform.types import (
NotificationBodyFormattingBlock,
NotificationBodyFormattingBlockType,
NotificationBodyTextBlock,
NotificationBodyTextBlockType,
NotificationData,
NotificationProviderKey,
NotificationRenderedTemplate,
Expand All @@ -42,9 +46,9 @@ def render[DataT: NotificationData](
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
) -> SlackRenderable:
subject = HeaderBlock(text=PlainTextObject(text=rendered_template.subject))
body = SectionBlock(text=MarkdownTextObject(text=rendered_template.body))
body_blocks: list[Block] = cls._render_body(rendered_template.body)

blocks = [subject, body]
blocks = [subject, *body_blocks]

if len(rendered_template.actions) > 0:
actions_block = ActionsBlock(elements=[])
Expand All @@ -63,6 +67,30 @@ def render[DataT: NotificationData](

return SlackRenderable(blocks=blocks, text=rendered_template.subject)

@classmethod
def _render_body(cls, body: list[NotificationBodyFormattingBlock]) -> list[Block]:
blocks: list[Block] = []
for block in body:
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
text = cls._render_text_blocks(block.blocks)
blocks.append(SectionBlock(text=MarkdownTextObject(text=text)))
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
text = cls._render_text_blocks(block.blocks)
blocks.append(SectionBlock(text=MarkdownTextObject(text=f"```{text}```")))
return blocks

@classmethod
def _render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
texts = []
for block in blocks:
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
texts.append(block.text)
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
texts.append(f"*{block.text}*")
elif block.type == NotificationBodyTextBlockType.CODE:
texts.append(f"`{block.text}`")
return " ".join(texts)


@provider_registry.register(NotificationProviderKey.SLACK)
class SlackNotificationProvider(NotificationProvider[SlackRenderable]):
Expand Down
Loading
Loading