Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions client/app/pages/alert/AlertEdit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export default class AlertEdit extends React.Component {
setSubject={subject => onNotificationTemplateChange({ custom_subject: subject })}
body={options.custom_body}
setBody={body => onNotificationTemplateChange({ custom_body: body })}
allowHtml={options.allow_html}
setAllowHtml={allowHtml => onNotificationTemplateChange({ allow_html: allowHtml })}
/>
</HorizontalFormItem>
</>
Expand Down
2 changes: 2 additions & 0 deletions client/app/pages/alert/AlertNew.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export default class AlertNew extends React.Component {
setSubject={subject => onNotificationTemplateChange({ custom_subject: subject })}
body={options.custom_body}
setBody={body => onNotificationTemplateChange({ custom_body: body })}
allowHtml={options.allow_html}
setAllowHtml={allowHtml => onNotificationTemplateChange({ allow_html: allowHtml })}
/>
</HorizontalFormItem>
</>
Expand Down
29 changes: 28 additions & 1 deletion client/app/pages/alert/components/NotificationTemplate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { head, isEmpty, isNull, isUndefined } from "lodash";
import Mustache from "mustache";

import HelpTrigger from "@/components/HelpTrigger";
import Tooltip from "@/components/Tooltip";
import { Alert as AlertType, Query as QueryType } from "@/components/proptypes";

import Input from "antd/lib/input";
import Select from "antd/lib/select";
import Modal from "antd/lib/modal";
import Switch from "antd/lib/switch";
import Checkbox from "antd/lib/checkbox";

import "./NotificationTemplate.less";

Expand All @@ -30,7 +32,18 @@ function normalizeCustomTemplateData(alert, query, columnNames, resultValues) {
};
}

function NotificationTemplate({ alert, query, columnNames, resultValues, subject, setSubject, body, setBody }) {
function NotificationTemplate({
alert,
query,
columnNames,
resultValues,
subject,
setSubject,
body,
setBody,
allowHtml,
setAllowHtml,
}) {
const hasContent = !!(subject || body);
const [enabled, setEnabled] = useState(hasContent ? 1 : 0);
const [showPreview, setShowPreview] = useState(false);
Expand All @@ -49,6 +62,7 @@ function NotificationTemplate({ alert, query, columnNames, resultValues, subject
onOk: () => {
setSubject(null);
setBody(null);
setAllowHtml(false);
setEnabled(value);
setShowPreview(false);
},
Expand Down Expand Up @@ -100,6 +114,16 @@ function NotificationTemplate({ alert, query, columnNames, resultValues, subject
<i className="fa fa-question-circle" aria-hidden="true" /> Formatting guide{" "}
<span className="sr-only">(help)</span>
</HelpTrigger>
<Checkbox
className="alert-template-allow-html"
checked={allowHtml}
onChange={e => setAllowHtml(e.target.checked)}
data-test="AlertTemplateAllowHTML">
Allow HTML content
<Tooltip title="Renders query values without escaping, so destination formatting comes through instead of appearing as literal text. Only enable for queries you trust.">
<i className="fa fa-question-circle" aria-hidden="true" />
</Tooltip>
</Checkbox>
</div>
)}
</div>
Expand All @@ -115,11 +139,14 @@ NotificationTemplate.propTypes = {
setSubject: PropTypes.func.isRequired,
body: PropTypes.string,
setBody: PropTypes.func.isRequired,
allowHtml: PropTypes.bool,
setAllowHtml: PropTypes.func.isRequired,
};

NotificationTemplate.defaultProps = {
subject: "",
body: "",
allowHtml: false,
};

export default NotificationTemplate;
11 changes: 11 additions & 0 deletions client/app/pages/alert/components/NotificationTemplate.less
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,15 @@
.alert-template-preview {
margin: 0 0 0 5px !important;
}

.alert-template-allow-html {
display: block;
margin-top: 8px;

.fa-question-circle {
margin-left: 5px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
}
}
9 changes: 5 additions & 4 deletions redash/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,7 @@ def evaluate(self):
def subscribers(self):
return User.query.join(AlertSubscription).filter(AlertSubscription.alert == self)

def render_template(self, template):
def render_template(self, template, escape=True):
if template is None:
return ""

Expand Down Expand Up @@ -1081,17 +1081,18 @@ def render_template(self, template):
"QUERY_RESULT_COLS": data["columns"],
"QUERY_RESULT_TABLE": result_table,
}
return mustache_render_escape(template, context)
render = mustache_render_escape if escape else mustache_render

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When allow_html is enabled, render_template switches the entire template from the escaping renderer to raw Mustache for all variables—including QUERY_RESULT_VALUE, QUERY_RESULT_ROWS, and other query-derived context—without visible sanitization or destination-specific controls. Since query results may contain attacker-controlled data, an alert owner opting in to HTML for Slack mrkdwn could inadvertently inject executable markup into email or web UI notifications that other subscribers receive. Consider scoping the raw rendering to intended variables or adding downstream sanitization so the opt-in does not become a cross-channel XSS vector.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At redash/models/__init__.py, line 1084:

<comment>When `allow_html` is enabled, `render_template` switches the entire template from the escaping renderer to raw Mustache for all variables—including `QUERY_RESULT_VALUE`, `QUERY_RESULT_ROWS`, and other query-derived context—without visible sanitization or destination-specific controls. Since query results may contain attacker-controlled data, an alert owner opting in to HTML for Slack `mrkdwn` could inadvertently inject executable markup into email or web UI notifications that other subscribers receive. Consider scoping the raw rendering to intended variables or adding downstream sanitization so the opt-in does not become a cross-channel XSS vector.</comment>

<file context>
@@ -1081,17 +1081,18 @@ def render_template(self, template):
             "QUERY_RESULT_TABLE": result_table,
         }
-        return mustache_render_escape(template, context)
+        render = mustache_render_escape if escape else mustache_render
+        return render(template, context)
 
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is why the option is opt-in and has a warning tooltip.

return render(template, context)

@property
def custom_body(self):
template = self.options.get("custom_body", self.options.get("template"))
return self.render_template(template)
return self.render_template(template, escape=self.options.get("allow_html") is not True)

@property
def custom_subject(self):
template = self.options.get("custom_subject")
return self.render_template(template)
return self.render_template(template, escape=self.options.get("allow_html") is not True)

@property
def groups(self):
Expand Down
29 changes: 29 additions & 0 deletions tests/models/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,32 @@ def test_render_custom_alert_template_query_table(self):
"""
result = alert.render_template(textwrap.dedent(custom_alert))
self.assertMultiLineEqual(result, textwrap.dedent(expected))

def test_custom_body_escapes_html_by_default(self):
alert = self.create_alert(get_results("<b>bold</b>"))
alert.options["custom_body"] = "Value: {{QUERY_RESULT_VALUE}}"
self.assertEqual(alert.custom_body, "Value: &lt;b&gt;bold&lt;/b&gt;")

def test_custom_body_renders_html_when_allow_html_enabled(self):
alert = self.create_alert(get_results("<b>bold</b>"))
alert.options["custom_body"] = "Value: {{QUERY_RESULT_VALUE}}"
alert.options["allow_html"] = True
self.assertEqual(alert.custom_body, "Value: <b>bold</b>")

def test_custom_subject_respects_allow_html(self):
alert = self.create_alert(get_results("<b>bold</b>"))
alert.options["custom_subject"] = "{{QUERY_RESULT_VALUE}}"
self.assertEqual(alert.custom_subject, "&lt;b&gt;bold&lt;/b&gt;")
alert.options["allow_html"] = True
self.assertEqual(alert.custom_subject, "<b>bold</b>")

def test_allow_html_does_not_affect_default_render_template(self):
alert = self.create_alert(get_results("<b>bold</b>"))
alert.options["allow_html"] = True
self.assertEqual(alert.render_template("{{QUERY_RESULT_VALUE}}"), "&lt;b&gt;bold&lt;/b&gt;")

def test_allow_html_requires_boolean_true(self):
alert = self.create_alert(get_results("<b>bold</b>"))
alert.options["custom_body"] = "Value: {{QUERY_RESULT_VALUE}}"
alert.options["allow_html"] = "false"
self.assertEqual(alert.custom_body, "Value: &lt;b&gt;bold&lt;/b&gt;")