Sanitizery to biblioteki odpowiedzialne za ochronę naszych aplikacji przed atakami Cross Site Scripting (XSS). Stosuje się je w sytuacji, gdy potrzebujemy wyrenderować kod HTML, który przechowujemy w zwykłym stringu.
Sanitizery otrzymują ciąg kodu HTML jako dane wejściowe i parsują go, pozbywając się niebezpiecznych wpisów, które pozwoliłyby atakującemu na wstrzyknięcie niebezpiecznego kodu JavaScript lub CSS. W teorii brzmi to skutecznie, ale parsowanie kodu HTML jest bardzo trudnym zagadnieniem. Dlaczego?
W teorii kod HTML jest prosty: istnieją zagnieżdżone znaczniki, a każdy z nich ma inne atrybuty. Wszystko co musimy zrobić to napisać wyrażenie regularne, które dzieli kod po znakach „<” i „>” i sprawdza wszystkie możliwe miejsca, gdzie można wstrzyknąć niebezpieczny kod. Jednak jest tu kilka luk:
- Kod nie musi być poprawny, może brakować np. znaczników zamykających lub atrybutów, mogą być dodane zbędne znaki itp:
1234<div><b onmouseover=alert(‘hello!’)>click me!</div><IMG """><SCRIPT>alert("XSS")</SCRIPT>"\><<SCRIPT>alert("XSS");//\<</SCRIPT><IMG SRC="('XSS')" - Część kodu może być napisana w nietypowej notacji UTF-8:
12<IMG SRC=jX41vascript:alert(‘hello!’)><IMG SRC=00001060000097000011800000970000115000009900001140000105000011200001160000058000009700001080000101000011400001160000040000003900000880000083000008300000390000041> - Kod JavaScript może nie być prosty do oznaczenia jako niebezpieczny bez głębszej analizy:
1234<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))><IMG SRC="jav ascript:alert('XSS');"><IMG SRC="javascript:alert('XSS');"> - Niektóre atrybuty mogą być nieznane dla programistów, którzy implementują sanitizer:
1<IMG LOWSRC="javascript:alert('XSS')"> - Albo kod może być po prostu zaskakujący:
1234<svg/onload=alert('XSS')><LINK REL="stylesheet" HREF="javascript:alert('XSS');"><STYLE>@im\port'\ja\vasc\ript:alert("XSS")';</STYLE><STYLE>li {list-style-image: url("javascript:alert('XSS')");}</STYLE><UL><LI>XSS</br>
Po więcej ciekawych przykładów zapraszam tutaj: https://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html.
Jak sobie z tym poradzić?
Jak widać, napisanie sanitizera to bardzo trudne zadanie. Szczególnie, że przeglądarki są ciągle rozwijane, dodawane są do nich nowe funkcjonalności, a wraz z nimi kolejne podatności. Nikt nie jest w stanie zagwarantować, że wynik działania sanitizera to w 100% bezpieczny kod.
Poza tym, jak każde oprogramowanie, sanitazery mogą zawierać błędy, a do tak kluczowego zadania, jakim jest ochrona przed wstrzyknięciem dowolnego kodu do naszej aplikacji, potrzebujemy rozwiązania gwarantującego pełne bezpieczeństwo.
Jak więc możemy sobie z tym poradzić? Co możemy zrobić, aby nasz kod gwarantował 100% bezpieczeństwa? Nie możemy korzystać z rozwiązań, które nie gwarantują 100% bezpieczeństwa 🙂 Potrzebne jest inne podejście.
Ponieważ problem parsowania jest bardzo trudny, porzućmy go całkowicie. Zamiast renderować HTML z łańcucha, renderujmy go ze struktury, którą możemy bezpiecznie przekonwertować na elementy drzewa DOM. Zbudujmy zagnieżdżoną strukturę reprezentującą HTML, który chcemy renderować.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
type Text = { type: 'text'; text: string; }; type Link = { type: 'link'; href: string; children: Content[]; }; type Paragraph = { type: 'paragraph'; children: Content[]; }; type List = { type: 'list'; children: Content[]; }; type ListItem = { type: 'list-item'; children: Content[]; }; type Content = Paragraph | Link | Text | List | ListItem; contentItems: Content[] = [ { type: 'paragraph', children: [ { type: 'text', text: 'This is a text in a paragraph with some JavaScript code: <script>alert("hello!")</script>.', }, ], }, { type: 'list', children: [ { type: 'list-item', children: [ { type: 'text', text: 'The first element', }, ], }, { type: 'list-item', children: [ { type: 'text', text: 'The second element with a', }, { type: 'link', href: 'https://google.com', children: [ { type: 'text', text: 'link', }, ], }, ], }, ], }, ]; |
A następnie użyjmy ng-template do rekurencyjnego renderowania elementów:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
<ng-template #textTemplate let-text="text"> {{ text }} </ng-template> <ng-template #childrenTemplate let-children="children"> <ng-container *ngFor="let child of children" [ngTemplateOutlet]="contentTemplate" [ngTemplateOutletContext]="{ content: child }" > </ng-container> </ng-template> <ng-template #paragraphTemplate let-children="children"> <p> <ng-container [ngTemplateOutlet]="childrenTemplate" [ngTemplateOutletContext]="{ children }" > </ng-container> </p> </ng-template> <ng-template #listTemplate let-children="children"> <ul> <ng-container [ngTemplateOutlet]="childrenTemplate" [ngTemplateOutletContext]="{ children }" > </ng-container> </ul> </ng-template> <ng-template #listItemTemplate let-children="children"> <li> <ng-container [ngTemplateOutlet]="childrenTemplate" [ngTemplateOutletContext]="{ children }" > </ng-container> </li> </ng-template> <ng-template #linkTemplate let-href="href" let-children="children"> <a [href]="href" _target="blank"> <ng-container [ngTemplateOutlet]="childrenTemplate" [ngTemplateOutletContext]="{ children }" > </ng-container> </a> </ng-template> <ng-template #contentTemplate let-content="content"> <ng-container [ngSwitch]="content.type"> <ng-container *ngSwitchCase="'paragraph'" [ngTemplateOutlet]="paragraphTemplate" [ngTemplateOutletContext]="{ children: content.children }" ></ng-container> <ng-container *ngSwitchCase="'list'" [ngTemplateOutlet]="listTemplate" [ngTemplateOutletContext]="{ children: content.children }" ></ng-container> <ng-container *ngSwitchCase="'list-item'" [ngTemplateOutlet]="listItemTemplate" [ngTemplateOutletContext]="{ children: content.children }" ></ng-container> <ng-container *ngSwitchCase="'link'" [ngTemplateOutlet]="linkTemplate" [ngTemplateOutletContext]="{ href: content.href, children: content.children }" ></ng-container> <ng-container *ngSwitchCase="'text'" [ngTemplateOutlet]="textTemplate" [ngTemplateOutletContext]="{ text: content.text }" ></ng-container> </ng-container> </ng-template> <ng-container *ngFor="let content of contentItems" [ngTemplateOutlet]="contentTemplate" [ngTemplateOutletContext]="{ content }" > </ng-container> |
Pełny przykład: https://stackblitz.com/edit/angular-ivy-iquuzz?file=src/app/app.component.ts.
Real life
Powodem, dla którego chcemy używać sanityzatorów, jest to, że pozwalamy naszym użytkownikom używać HTML. Aby skorzystać z opisanego powyżej rozwiązania, musimy przekonwertować kod HTML, który stworzył użytkownik, na naszą strukturę. Jest to bardzo łatwe do wykonania:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@Component({ selector: 'my-app', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], }) export class AppComponent implements AfterViewInit { private converter = new Converter([ new TextNodeConverter(), new ParagraphNodeConverter(), new LinkNodeConverter(), new ListNodeConverter(), new ListItemNodeConverter(), ]); @ViewChild('container') container: ElementRef<HTMLElement>; ngAfterViewInit(): void { console.log( this.converter.convertNodes(this.container.nativeElement.childNodes) ); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class Converter { private nodeConvertersMap = new Map<string, NodeConverterInterface>(); constructor(nodeConverters: NodeConverterInterface[] = []) { nodeConverters.forEach((nodeConverter) => { this.registerNodeConverter(nodeConverter); }); } registerNodeConverter(nodeConverter: NodeConverterInterface): void { this.nodeConvertersMap.set(nodeConverter.nodeName, nodeConverter); } convertNodes(nodes: NodeListOf<ChildNode>): Content[] { return Array.from(nodes) .map((node) => this.convertNode(node)) .filter((contentObj) => contentObj !== null); } private convertNode(node: Node): Content | null { if (this.nodeConvertersMap.has(node.nodeName)) { return this.nodeConvertersMap .get(node.nodeName) .convert(node, this.convertNodes.bind(this)); } return null; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
interface NodeConverterInterface { readonly nodeName: string; convert( node: Node, convertChildren: (nodes: NodeListOf<ChildNode>) => Content[] ): Content; } class TextNodeConverter implements NodeConverterInterface { readonly nodeName = '#text'; convert( node: Node, convertChildren: (nodes: NodeListOf<ChildNode>) => Content[] ): Text { return { type: 'text', text: node.textContent, }; } } class LinkNodeConverter implements NodeConverterInterface { readonly nodeName = 'A'; convert( node: Node, convertChildren: (nodes: NodeListOf<ChildNode>) => Content[] ): Link { const hrefAttribute = (node as HTMLAnchorElement).attributes.getNamedItem( 'href' ).textContent; const acceptedProtocols = ['https://', 'http://']; return { type: 'link', href: acceptedProtocols.some( (acceptedProtocol) => hrefAttribute.indexOf(acceptedProtocol) === 0 ) ? hrefAttribute : '', children: convertChildren(node.childNodes), }; } } class ParagraphNodeConverter implements NodeConverterInterface { readonly nodeName = 'P'; convert( node: Node, convertChildren: (nodes: NodeListOf<ChildNode>) => Content[] ): Paragraph { return { type: 'paragraph', children: convertChildren(node.childNodes), }; } } class ListNodeConverter implements NodeConverterInterface { readonly nodeName = 'UL'; convert( node: Node, convertChildren: (nodes: NodeListOf<ChildNode>) => Content[] ): List { return { type: 'list', children: convertChildren(node.childNodes), }; } } class ListItemNodeConverter implements NodeConverterInterface { readonly nodeName = 'LI'; convert( node: Node, convertChildren: (nodes: NodeListOf<ChildNode>) => Content[] ): ListItem { return { type: 'list-item', children: convertChildren(node.childNodes), }; } } |
Kod tworzy poprawną, bezpieczną strukturę i sprawdza, czy atrybut „href” jest poprawny. Jest otwarty na obsługę nowych znaczników.
Pełny przykład: https://stackblitz.com/edit/angular-ivy-qtcyzo?file=src/app/app.component.ts.
Popularne edytory tekstu zwracają podobne struktury:
- https://editorjs.io/ – Przykład można znaleźć od razu na stronie głównej:
- https://quilljs.com zwraca struktury o nazwie Blots (https://github.com/quilljs/parchment#blots):
12345678910111213141516171819202122<!-- Include stylesheet --><linkhref="https://cdn.quilljs.com/1.3.6/quill.snow.css"rel="stylesheet"/><!-- Create the editor container --><div id="editor"><p>Hello World!</p><p>The second line with a <a href="https://google.com">link</a> to Google</p></div><!-- Include the Quill library --><script src="https://cdn.quilljs.com/1.3.6/quill.js"></script><!-- Initialize Quill editor --><script>var quill = new Quill('#editor');console.log(quill.getLines());</script>
- W https://draftjs.org (edytor tekstu stworzony przez Facebooka) programiści mają dostęp do obiektu ContenetState (https://draftjs.org/docs/api-reference-content-state). Zawiera on strukturę całego dokumentu.
Oto przykład konwersji Blots’ów z quill.js na zwykły obiekt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
type Text = { type: 'text'; text: string; }; type Link = { type: 'link'; href: string; children: Content[]; }; type Paragraph = { type: 'paragraph'; children: Content[]; }; type Content = Paragraph | Link | Text; interface BlotConverterInterface { readonly nodeName: string; convert( blot: Quill.Blot, convertChildren: (blocks: Quill.LinkedList<Quill.Blot>) => Content[] ): Content; } class TextBlotConverter implements BlotConverterInterface { readonly nodeName = '#text'; convert( blot: Quill.Blot, convertChildren: (blocks: Quill.LinkedList<Quill.Blot>) => Content[] ): Text { return { type: 'text', text: blot.domNode.textContent, }; } } class LinkBlotConverter implements BlotConverterInterface { readonly nodeName = 'A'; convert( blot: Quill.Blot, convertChildren: (blocks: Quill.LinkedList<Quill.Blot>) => Content[] ): Link { const hrefAttribute = ( blot.domNode as HTMLAnchorElement ).attributes.getNamedItem('href').textContent; const acceptedProtocols = ['https://', 'http://']; return { type: 'link', href: acceptedProtocols.some( (acceptedProtocol) => hrefAttribute.indexOf(acceptedProtocol) === 0 ) ? hrefAttribute : '', children: convertChildren(blot.children), }; } } class ParagraphBlotConverter implements BlotConverterInterface { readonly nodeName = 'P'; convert( blot: Quill.Blot, convertChildren: (blocks: Quill.LinkedList<Quill.Blot>) => Content[] ): Paragraph { return { type: 'paragraph', children: convertChildren(blot.children), }; } } class Converter { private blotConvertersMap = new Map<string, BlotConverterInterface>(); constructor(blotConvertersMap: BlotConverterInterface[] = []) { blotConvertersMap.forEach((blotConverter) => { this.registerBlotConverter(blotConverter); }); } registerBlotConverter(nodeConverter: BlotConverterInterface): void { this.blotConvertersMap.set(nodeConverter.nodeName, nodeConverter); } convertBlots(linkedList: Quill.LinkedList<Quill.Blot>): Content[] { let element = linkedList.head; const converted = []; while (element !== null) { converted.push(this.convertBlot(element)); element = element.next; } return converted; } private convertBlot(blot: Quill.Blot): Content | null { const nodeName = blot.domNode.nodeName; if (this.blotConvertersMap.has(nodeName)) { return this.blotConvertersMap .get(nodeName) .convert(blot, this.convertBlots.bind(this)); } return null; } } @Component({ selector: 'my-app', standalone: true, imports: [CommonModule], template: ` <div #editor> <p>Hello World!</p> <p> The second line with a <a href="https://google.com">link</a> to Google </p> </div> `, }) export class App implements AfterViewInit { private converter = new Converter([ new TextBlotConverter(), new ParagraphBlotConverter(), new LinkBlotConverter(), ]); @ViewChild('editor') editor: ElementRef<HTMLDivElement>; ngAfterViewInit(): void { const editor = new Quill(this.editor.nativeElement); console.log(this.converter.convertBlots(editor.scroll.children)); } } |
Pełny przykład: https://stackblitz.com/edit/angular-bywfc1?file=src/main.ts.
Korzyści
Praca na takiej strukturze daje nam trzy ogromne korzyści.
Po pierwsze, mamy pełną kontrolę nad tym, jak będą renderowane elementy. Możemy łatwo zamienić warstwę widoku na inną. Zamiast używać standardowych elementów (np. linków), możemy użyć własnych komponentów, które dodają nową funkcjonalność (np. wyświetlają link z odpowiednią ikoną).
Po drugie, treści w takiej strukturze mogą być łatwo ponownie wykorzystane w innych aplikacjach, także tych, które nie wykorzystują HTML do renderowania treści, np. w aplikacjach mobilnych możemy wykorzystać natywne komponenty mobilne.
Po trzecie, możliwości ataku XSS na ten kod są znacznie bardziej ograniczone.
Jak widać stosując to rozwiązanie otwieramy się na zasadę open-close SOLID’u: nasz kod będzie otwarty na rozszerzenia i zamknięty na modyfikacje.
Na przykład nie mielibyśmy problemu z dodaniem komponentu, który nie istnieje natywnie w HTML lub zmianą wyświetlania wcześniej przechowywanej zawartości. W przypadku przechowywania czystego kodu HTML musielibyśmy dokonać kilku “tricky” modyfikacji, aby zapewnić, że stworzony przez użytkownika kod HTML będzie zawsze przetwarzany poprawnie. Przy zastosowaniu struktury opisanej w tym artykule jest to bardzo łatwe do osiągnięcia.
Podsumowanie
Używanie sanitizerów jest bardzo prostym i dość powszechnym rozwiązaniem chroniącym przed atakami XSS. W tym artykule przedstawiłem zagrożenia z tym związane. Być może warto zainwestować więcej czasu na początku projektu w obsługę struktury innej niż HTML, aby w późniejszym etapie czerpać opisane powyżej korzyści.
Dodaj komentarz