Всем, кому приходилось вставлять HTML содержимое в DOM в Angular, доводилось видеть это сообщение. Конечно, все мы получаем проверенное содержимое с нашего же сервера и просто хотим застилизовать сообщение об ошибке. Или вставляем HTML из наших же констант, инлайним наши SVG иконки, ведь нам всего лишь нужно покрасить их в цвет текста. Ведь ничего плохого не случится, если мы просто скажем Angular`у — не дрейфь, там всё чисто.
Чаще всего это может быть и так, но в крупных проектах с массой разработчиков, которые пишут независимые компоненты, никогда не знаешь наверняка, где может оказаться твой код. А если вы, как я, разрабатываете библиотеку переиспользуемых компонентов, то подобную ситуацию нужно решить на корню.
Sanitize и DomSanitizer
В Angular есть абстрактные классы, реализации которых призваны очищать содержимое от вредоносного барахла:
abstract class Sanitizer {
abstract sanitize(context: SecurityContext, value: string | {}): string | null
}
abstract class DomSanitizer implements Sanitizer {
abstract sanitize(context: SecurityContext, value: string | SafeValue): string | null
abstract bypassSecurityTrustHtml(value: string): SafeHtml
abstract bypassSecurityTrustStyle(value: string): SafeStyle
abstract bypassSecurityTrustScript(value: string): SafeScript
abstract bypassSecurityTrustUrl(value: string): SafeUrl
abstract bypassSecurityTrustResourceUrl(value: string): SafeResourceUrl
}
Конкретный внутренний класс DomSanitizerImpl
Angular использует в построении DOM. Именно его люди добавляют в директивы, компоненты и пайпы, чтобы сказать фреймворку, что этому содержимому можно доверять.
Исходный код этого класса довольно прост:
https://github.com/angular/angular/blob/8.1.0/packages/platform-browser/src/security/dom_sanitization_service.ts#L148
Этот сервис вводит понятие SafeValue
. По сути, это всего навсего класс-обёртка для строки. Когда Renderer
вставляет значение через байндинг, будь то innerHTML
, @HostBinding
, style
или src
— значение прогоняется через метод sanitize
. Если туда попал уже SafeValue
, то он просто возвращает обёрнутую строку, иначе чистит содержимое своими силами.
Angular не является специализированной библиотекой очистки вредоносного кода. Поэтому фреймворк вполне справедливо идёт по пути наименьшего риска и режет всё, что вызывает опасения. SVG код превращается в пустые строки, инлайн стили удаляются и т.д. Однако, есть библиотеки, задуманные как раз с целью обезопасить DOM, одна из которых — DOMPurify:
https://github.com/cure53/DOMPurify
Правильный SafeHtml пайп
После подключения DOMPurify, мы можем сделать пайп, который не только помечает содержимое, как безопасное, но и очищает его. Для этого нужно прогнать входное значение через метод DOMPurify.sanitize
, а затем пометить его, как безопасное с соответствующим контекстом:
@Pipe({name: 'dompurify'})
export class NgDompurifyPipe implements PipeTransform {
constructor(private readonly domSanitizer: DomSanitizer) {}
transform(
value: {} | string | null,
context: SecurityContext = SecurityContext.HTML,
): SafeValue | null {
return this.bypassSecurityTrust(context, DOMPurify.sanitize(value));
}
private bypassSecurityTrust(
context: SecurityContext,
purifiedValue: string,
): SafeValue | null {
switch (context) {
case SecurityContext.HTML:
return this.domSanitizer.bypassSecurityTrustHtml(purifiedValue);
case SecurityContext.STYLE:
return this.domSanitizer.bypassSecurityTrustStyle(purifiedValue);
case SecurityContext.SCRIPT:
return this.domSanitizer.bypassSecurityTrustScript(purifiedValue);
case SecurityContext.URL:
return this.domSanitizer.bypassSecurityTrustUrl(purifiedValue);
case SecurityContext.RESOURCE_URL:
return this.domSanitizer.bypassSecurityTrustResourceUrl(purifiedValue);
default:
return null;
}
}
}
Вот и всё необходимое для того, чтобы обезопасить приложение при вставке HTML в страницу. +7КБ gzip от DOMPurify и микропайп. Однако, раз уж мы забрались сюда — попробуем пойти дальше. Абстрактность классов означает, что Angular предполагает возможность создания собственных имплементаций. Зачем создавать пайп и помечать контент, как безопасный если можно использовать DOMPurify сразу как DomSanitizer
?
DomPurifyDomSanitizer
Создадим класс, который наследуется от DomSanitizer
и делегирует очистку значений в DOMPurify. На будущее, сразу реализуем сервис Sanitizer
и будем использовать его и в пайпе, и в DomSanitizer
. Это поможет нам в дальнейшем, так как появится единая точка входа в DOMPurify. Реализация SafeValue
и всё, что относится к этой концепции является приватным кодом Angular, поэтому нам придётся написать его самим. Однако, как мы видели в исходном коде, это не составляет никакого труда.
Тут стоит заметить, что помимо HTML есть и другие SecurityContext
и, в частности, для очистки стилей DOMPurify как таковой не подходит. Однако, его функции расширяются с помощью хуков, об этом позже. Кроме того, в DOMPurify можно передать объект-конфигурацию, и для этого отлично подойдёт dependency injection. Добавим к нашему коду токен, который позволит предоставлять параметры для DOMPurify:
@NgModule({
// ...
providers: [
{
provide: DOMPURIFY_CONFIG,
useValue: {FORBID_ATTR: ['id']},
},
],
// ...
})
export class AppModule {}
@Injectable({
providedIn: 'root',
})
export class NgDompurifySanitizer extends Sanitizer {
constructor(
@Inject(DOMPURIFY_CONFIG)
private readonly config: NgDompurifyConfig,
) {
super();
}
sanitize(
_context: SecurityContext,
value: {} | string | null,
config: NgDompurifyConfig = this.config,
): string {
return sanitize(String(value || ''), config);
}
}
Работа DomSanitizer
со стилями реализована таким образом, что на вход он получает только значение CSS правила, но не название самого стиля. Таким образом, чтобы позволить нашему санитайзеру очищать стили, нам нужно предоставить ему метод, который будет получать на вход строку-значение и возвращать очищенную строку. Для этого добавим ещё один токен и определим его по умолчанию функцией, которая очищает строку в ничто, так как мы не будем брать на себя ответственность за потенциально вредоносные стили.
export const SANITIZE_STYLE = new InjectionToken<SanitizeStyle>(
'A function that sanitizes value for a CSS rule',
{
factory: () => () => '',
providedIn: 'root',
},
);
sanitize(
context: SecurityContext,
value: {} | string | null,
config: NgDompurifyConfig = this.config,
): string {
return context === SecurityContext.STYLE
? this.sanitizeStyle(String(value))
: sanitize(String(value || ''), config);
}
Таким образом мы очистим стили, которые непосредственно участвуют в байндинге через [style.*]
или @HostBinding('style.*')
. Как мы помним, поведение встроенного санитайзера Angular — убирать все инлайн стили. Честно говоря, не знаю, почему они решили так поступить, раз уж написали методы для удаления подозрительных CSS правил, но если вам нужно реализовать, скажем, WYSIWYG редактор, то без инлайн стилей не обойтись. К счастью, DOMPurify позволяет добавлять хуки для расширения возможностей библиотеки. В разделе примеров даже есть код, который очищает стили как на DOM элементах, так и тэги HTMLStyleElement
целиком.
Хуки
С помощью DOMPurify.addHook()
можно регистрировать методы, которые получают на вход текущий элемент на разном этапе выполнения очистки. Для их подключения мы добавим третий и последний токен. У нас уже есть метод для очистки стилей, нужно просто вызывать его для всех инлайн правил. Для этого зарегистрируем обязательный хук в конструкторе нашего сервиса. Там же пробежимся по всем хукам, которые попали к нам с помощью DI:
constructor(
@Inject(DOMPURIFY_CONFIG)
private readonly config: NgDompurifyConfig,
@Inject(SANITIZE_STYLE)
private readonly sanitizeStyle: SanitizeStyle,
@Inject(DOMPURIFY_HOOKS)
hooks: ReadonlyArray<NgDompurifyHook>,
) {
super();
addHook('afterSanitizeAttributes', createAfterSanitizeAttributes(this.sanitizeStyle));
hooks.forEach(({name, hook}) => {
addHook(name, hook);
});
}
Наш сервис готов и осталось только написать свой DomSanitizer
который будет только передавать в него содержимое для очистки. Ещё нужно реализовать bypassSecurityTrust***
методы, но их мы уже подсмотрели в исходном коде Angular. Теперь DOMPurify доступен как точечно, с помощью пайпа или сервиса, так и автоматически по всему приложению.
Вывод
Мы разобрались с работой DomSanitizer
в Angular и больше не маскируем проблему вставки произвольного HTML. Теперь можно спокойно инлайнить SVG и HTML сообщения с сервера. Для полноценной работы санитайзера всё же нужно предоставить ему метод для очистки стилей. Вредоносный CSS встречается гораздо реже других видов атак, времена CSS expressions уже давно прошли и что делать с CSS решать вам. Можно написать обработчик самому, можно плюнуть и пропускать всё, у чего нет скобок, чтоб наверняка. А можно немного схитрить и присмотреться к исходникам Angular. Метод _sanitizeStyle
находится в пакете @angular/core
, в то время, как DomSanitizerImpl
лежит в @angular/platform-browser
и, хоть этот метод и приватный в своём пакете — команда Angular не стесняется обращаться к нему по его приватному имени ?_sanitizeStyle
. Мы можем сделать тоже самое и передать этот метод в наш санитайзер. Таким образом, мы получим тот же уровень очистки стилей, что и по умолчанию, но с возможностью использовать инлайн стили при вставке HTML кода. Имя этого приватного импорта не менялось с момента его появления в 6-ой версии но с такими вещами надо быть осторожным, ничего не мешает команде Angular переименовать, перенести или удалить его в любой момент.
Описанный в статье код находится на Github
А так же доступен в виде npm пакета tinkoff/ng-dompurify:
https://www.npmjs.com/package/@tinkoff/ng-dompurify
На stackblitz можно немного поиграться с демо
kemsky
Мне кажется со своей реализацией SafeValue могут быть проблемы, эти интерфейсы пустые, а это означает, что они в любой момент могут все сломать.
Waterplea Автор
Если ты именно про собственную реализацию, то оно ничего не сломает у нас, так как это замкнутая система. Она не используется нигде извне, только внутри реализации `DomSanitizer`, так же, как и у самого Angular. Если они там у себя потом что-нибудь добавят, то это отразится только на их собственной имплементации `DomSanitizerImpl`. Мы просто подсмотрели механизм, по которому они различают обработанное и необработанное значение.
А если ты про сам `SafeValue` интерфейс, то он публичный и если команда Angular в него потом что-нибудь добавит, то это будет breaking change и работать с ним нужно как с любым другим ломающим изменением публичного API фреймворка — перерабатывать код и выпускать новую версию своей библиотеки.