fix: sanitize output for email (#8373)

# Which Problems Are Solved

ZITADEL uses HTML for emails and renders certain information such as
usernames dynamically. That information can be entered by users or
administrators. Due to a missing output sanitization, these emails could
include malicious code.
This may potentially lead to a threat where an attacker, without
privileges, could send out altered notifications that are part of the
registration processes. An attacker could create a malicious link, where
the injected code would be rendered as part of the email.

During investigation of this issue a related issue was found and
mitigated, where on the user's detail page the username was not
sanitized and would also render HTML, giving an attacker the same
vulnerability.

While it was possible to inject HTML including javascript, the execution
of such scripts would be prevented by most email clients and the Content
Security Policy in Console UI.

# How the Problems Are Solved

- All arguments used for email are sanitized (`html.EscapeString`)
- The email text no longer `html.UnescapeString` (HTML in custom text is
still possible)
- Console no longer uses `[innerHtml]` to render the username

# Additional Changes

None

# Additional Context

- raised via email

---------

Co-authored-by: peintnermax <max@caos.ch>

(cherry picked from commit 189505c80fa639108488f5979fe52967df9729fa)
This commit is contained in:
Livio Spring 2024-07-31 14:21:10 +02:00
parent c2093ce015
commit 38da602ee1
No known key found for this signature in database
GPG Key ID: 26BB1C2FA5952CF0
19 changed files with 38 additions and 22 deletions

View File

@ -16,7 +16,7 @@
></div> ></div>
</div> </div>
<div class="cnsl-doc-row"> <div class="cnsl-doc-row">
<span class="cnsl-type" [innerHtml]="sub"></span> <span class="cnsl-type">{{ sub }}</span>
<a *ngIf="docLink" mat-icon-button [href]="docLink" rel="noreferrer" target="_blank"> <a *ngIf="docLink" mat-icon-button [href]="docLink" rel="noreferrer" target="_blank">
<mat-icon class="icon">info_outline</mat-icon> <mat-icon class="icon">info_outline</mat-icon>
</a> </a>

View File

@ -82,7 +82,7 @@
.cnsl-type { .cnsl-type {
font-size: 14px; font-size: 14px;
margin-top: 2rem; margin-top: 1.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }

View File

@ -2,9 +2,7 @@
title="{{ project?.projectName }}" title="{{ project?.projectName }}"
[hasActions]="false" [hasActions]="false"
docLink="https://zitadel.com/docs/guides/manage/console/projects#what-is-a-granted-project" docLink="https://zitadel.com/docs/guides/manage/console/projects#what-is-a-granted-project"
sub="{{ 'PROJECT.PAGES.TYPE.GRANTED_SINGULAR' | translate }} {{ 'ACTIONS.OF' | translate }} <strong>{{ sub="{{ 'PROJECT.PAGES.TYPE.GRANTED_SINGULAR' | translate: { name: project?.projectOwnerName } }}"
project?.projectOwnerName
}}</strong>"
[isActive]="project?.state === ProjectGrantState.PROJECT_GRANT_STATE_ACTIVE" [isActive]="project?.state === ProjectGrantState.PROJECT_GRANT_STATE_ACTIVE"
[isInactive]="project?.state === ProjectGrantState.PROJECT_GRANT_STATE_INACTIVE" [isInactive]="project?.state === ProjectGrantState.PROJECT_GRANT_STATE_INACTIVE"
stateTooltip="{{ 'ORG.STATE.' + project?.state | translate }}" stateTooltip="{{ 'ORG.STATE.' + project?.state | translate }}"

View File

@ -1770,7 +1770,7 @@
"TYPE": { "TYPE": {
"OWNED": "Притежавани проекти", "OWNED": "Притежавани проекти",
"OWNED_SINGULAR": "Собствен проект", "OWNED_SINGULAR": "Собствен проект",
"GRANTED_SINGULAR": "Приет проект" "GRANTED_SINGULAR": "Отпуснат проект на {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"0": { "0": {

View File

@ -1778,7 +1778,7 @@
"TYPE": { "TYPE": {
"OWNED": "Vlastní projekty", "OWNED": "Vlastní projekty",
"OWNED_SINGULAR": "Vlastní projekt", "OWNED_SINGULAR": "Vlastní projekt",
"GRANTED_SINGULAR": "Přidělený projekt" "GRANTED_SINGULAR": "Projekt přidělený {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Nastavení brandingu", "TITLE": "Nastavení brandingu",

View File

@ -1776,7 +1776,7 @@
"TYPE": { "TYPE": {
"OWNED": "Eigene Projekte", "OWNED": "Eigene Projekte",
"OWNED_SINGULAR": "Eigenes Projekt", "OWNED_SINGULAR": "Eigenes Projekt",
"GRANTED_SINGULAR": "Berechtigtes Projekt" "GRANTED_SINGULAR": "Berechtigtes Projekt von {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Branding Verhalten", "TITLE": "Branding Verhalten",

View File

@ -1777,7 +1777,7 @@
"TYPE": { "TYPE": {
"OWNED": "Owned Projects", "OWNED": "Owned Projects",
"OWNED_SINGULAR": "Owned Project", "OWNED_SINGULAR": "Owned Project",
"GRANTED_SINGULAR": "Granted Project" "GRANTED_SINGULAR": "Granted Project of {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Branding Setting", "TITLE": "Branding Setting",

View File

@ -1778,7 +1778,7 @@
"TYPE": { "TYPE": {
"OWNED": "Proyectos propios", "OWNED": "Proyectos propios",
"OWNED_SINGULAR": "Proyecto propio", "OWNED_SINGULAR": "Proyecto propio",
"GRANTED_SINGULAR": "Proyecto concedido" "GRANTED_SINGULAR": "Proyecto asignado {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Ajustes de imagen de marca", "TITLE": "Ajustes de imagen de marca",

View File

@ -1776,7 +1776,7 @@
"TYPE": { "TYPE": {
"OWNED": "Projets possédés", "OWNED": "Projets possédés",
"OWNED_SINGULAR": "Projet possédé", "OWNED_SINGULAR": "Projet possédé",
"GRANTED_SINGULAR": "Projet octroyé" "GRANTED_SINGULAR": "Projet attribué à {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Image de marque", "TITLE": "Image de marque",

View File

@ -1776,7 +1776,7 @@
"TYPE": { "TYPE": {
"OWNED": "Progetti proprietari", "OWNED": "Progetti proprietari",
"OWNED_SINGULAR": "Progetto proprietario", "OWNED_SINGULAR": "Progetto proprietario",
"GRANTED_SINGULAR": "Progetto delegato" "GRANTED_SINGULAR": "Progetto concesso di {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Impostazione branding", "TITLE": "Impostazione branding",

View File

@ -1773,7 +1773,7 @@
"TYPE": { "TYPE": {
"OWNED": "所有プロジェクト", "OWNED": "所有プロジェクト",
"OWNED_SINGULAR": "所有プロジェクト", "OWNED_SINGULAR": "所有プロジェクト",
"GRANTED_SINGULAR": "グラントされたプロジェクト" "GRANTED_SINGULAR": "{{name}}に付与されたプロジェクト"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "ブランディング設定", "TITLE": "ブランディング設定",

View File

@ -1778,7 +1778,7 @@
"TYPE": { "TYPE": {
"OWNED": "Сопствени проекти", "OWNED": "Сопствени проекти",
"OWNED_SINGULAR": "Сопствен проект", "OWNED_SINGULAR": "Сопствен проект",
"GRANTED_SINGULAR": "Доделен проект" "GRANTED_SINGULAR": "Проект доделен на {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Подесувања за брендирање", "TITLE": "Подесувања за брендирање",

View File

@ -1777,7 +1777,7 @@
"TYPE": { "TYPE": {
"OWNED": "Eigen Projecten", "OWNED": "Eigen Projecten",
"OWNED_SINGULAR": "Eigen Project", "OWNED_SINGULAR": "Eigen Project",
"GRANTED_SINGULAR": "Verleend Project" "GRANTED_SINGULAR": "Project toegewezen {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Branding Instelling", "TITLE": "Branding Instelling",

View File

@ -1776,7 +1776,7 @@
"TYPE": { "TYPE": {
"OWNED": "Własne Projekty", "OWNED": "Własne Projekty",
"OWNED_SINGULAR": "Własny Projekt", "OWNED_SINGULAR": "Własny Projekt",
"GRANTED_SINGULAR": "Udzielony Projekt" "GRANTED_SINGULAR": "Projekt przydzielony {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Ustawienia marka", "TITLE": "Ustawienia marka",

View File

@ -1776,7 +1776,7 @@
"TYPE": { "TYPE": {
"OWNED": "Projetos Próprios", "OWNED": "Projetos Próprios",
"OWNED_SINGULAR": "Projeto Próprio", "OWNED_SINGULAR": "Projeto Próprio",
"GRANTED_SINGULAR": "Projeto Concedido" "GRANTED_SINGULAR": "Projeto atribuído a {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Configuração de Marca", "TITLE": "Configuração de Marca",

View File

@ -1858,9 +1858,8 @@
"ZITADELPROJECT": "Это принадлежит проекту ZITADEL. Осторожно: Если вы внесёте изменения, ZITADEL может вести себя не так, как предполагалось.", "ZITADELPROJECT": "Это принадлежит проекту ZITADEL. Осторожно: Если вы внесёте изменения, ZITADEL может вести себя не так, как предполагалось.",
"TYPE": { "TYPE": {
"OWNED": "Собственные проекты", "OWNED": "Собственные проекты",
"GRANTED": "Проекты доступа",
"OWNED_SINGULAR": "Собственный проект", "OWNED_SINGULAR": "Собственный проект",
"GRANTED_SINGULAR": "Допуск проекта" "GRANTED_SINGULAR": "Проект, предоставленный {{name}}"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "Настройка брендинга", "TITLE": "Настройка брендинга",

View File

@ -1775,7 +1775,7 @@
"TYPE": { "TYPE": {
"OWNED": "拥有的项目", "OWNED": "拥有的项目",
"OWNED_SINGULAR": "拥有项目", "OWNED_SINGULAR": "拥有项目",
"GRANTED_SINGULAR": "授予的项目" "GRANTED_SINGULAR": "授予{{name}}的项目"
}, },
"PRIVATELABEL": { "PRIVATELABEL": {
"TITLE": "品牌标识设置", "TITLE": "品牌标识设置",

View File

@ -2,7 +2,6 @@ package templates
import ( import (
"fmt" "fmt"
"html"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/i18n"
@ -40,7 +39,7 @@ func (data *TemplateData) Translate(translator *i18n.Translator, msgType string,
data.PreHeader = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessagePreHeader), args, langs...) data.PreHeader = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessagePreHeader), args, langs...)
data.Subject = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageSubject), args, langs...) data.Subject = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageSubject), args, langs...)
data.Greeting = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageGreeting), args, langs...) data.Greeting = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageGreeting), args, langs...)
data.Text = html.UnescapeString(translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageText), args, langs...)) data.Text = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageText), args, langs...)
data.ButtonText = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageButtonText), args, langs...) data.ButtonText = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageButtonText), args, langs...)
// Footer text is neither included in i18n files nor defaults.yaml // Footer text is neither included in i18n files nor defaults.yaml
footerText := fmt.Sprintf("%s.%s", msgType, domain.MessageFooterText) footerText := fmt.Sprintf("%s.%s", msgType, domain.MessageFooterText)

View File

@ -2,7 +2,9 @@ package types
import ( import (
"context" "context"
"html"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/notification/channels/smtp" "github.com/zitadel/zitadel/internal/notification/channels/smtp"
@ -42,6 +44,7 @@ func SendEmail(
allowUnverifiedNotificationChannel bool, allowUnverifiedNotificationChannel bool,
) error { ) error {
args = mapNotifyUserToArgs(user, args) args = mapNotifyUserToArgs(user, args)
sanitizeArgsForHTML(args)
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
template, err := templates.GetParsedTemplate(mailhtml, data) template, err := templates.GetParsedTemplate(mailhtml, data)
if err != nil { if err != nil {
@ -59,6 +62,23 @@ func SendEmail(
} }
} }
func sanitizeArgsForHTML(args map[string]any) {
for key, arg := range args {
switch a := arg.(type) {
case string:
args[key] = html.EscapeString(a)
case []string:
for i, s := range a {
a[i] = html.EscapeString(s)
}
case database.TextArray[string]:
for i, s := range a {
a[i] = html.EscapeString(s)
}
}
}
}
func SendSMSTwilio( func SendSMSTwilio(
ctx context.Context, ctx context.Context,
channels ChannelChains, channels ChannelChains,