Skip to content

Commit 41fa1c3

Browse files
committed
feat: implement html-sanitize pipe and update docs
1 parent 8f66f54 commit 41fa1c3

File tree

2 files changed

+414
-0
lines changed

2 files changed

+414
-0
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
computed,
5+
inject,
6+
signal,
7+
} from '@angular/core';
8+
import { DomSanitizer } from '@angular/platform-browser';
9+
import { HlmInputImports } from '@spartan-ng/helm/input';
10+
import { HlmLabelImports } from '@spartan-ng/helm/label';
11+
import { HlmButtonImports } from '@spartan-ng/helm/button';
12+
import { HtmlSanitizePipe } from '@ngx-transforms';
13+
import { NgIcon, provideIcons } from '@ng-icons/core';
14+
import {
15+
lucideShield,
16+
lucideAlertTriangle,
17+
lucideEye,
18+
lucideCode,
19+
} from '@ng-icons/lucide';
20+
21+
interface SanitizeExample {
22+
label: string;
23+
html: string;
24+
danger: 'safe' | 'warning' | 'danger';
25+
}
26+
27+
@Component({
28+
selector: 'app-rich-text-previewer',
29+
standalone: true,
30+
imports: [
31+
HlmInputImports,
32+
HlmLabelImports,
33+
HlmButtonImports,
34+
NgIcon,
35+
HtmlSanitizePipe,
36+
],
37+
providers: [
38+
HtmlSanitizePipe,
39+
provideIcons({ lucideShield, lucideAlertTriangle, lucideEye, lucideCode }),
40+
],
41+
changeDetection: ChangeDetectionStrategy.OnPush,
42+
template: `
43+
<div class="flex flex-col gap-6 w-full p-4 md:p-6">
44+
<div class="text-center">
45+
<h3 class="text-xl font-semibold tracking-tight">Rich Text Previewer</h3>
46+
<p class="text-muted-foreground mt-1 text-sm">
47+
See how Angular's DomSanitizer strips dangerous HTML while keeping safe content.
48+
</p>
49+
</div>
50+
51+
<!-- Security Info -->
52+
<div class="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
53+
<ng-icon name="lucideAlertTriangle" class="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
54+
<div>
55+
<h4 class="text-sm font-semibold">Security Note</h4>
56+
<p class="text-xs text-muted-foreground mt-1">
57+
The sanitize pipe removes dangerous elements like scripts and event handlers.
58+
Always validate input server-side as well.
59+
</p>
60+
</div>
61+
</div>
62+
63+
<!-- Quick Examples -->
64+
<div>
65+
<label hlmLabel class="text-xs text-muted-foreground mb-2 block">Test Scenarios</label>
66+
<div class="flex flex-wrap gap-2">
67+
@for (example of examples; track example.label) {
68+
<button
69+
hlmBtn
70+
[variant]="example.danger === 'safe' ? 'outline' : example.danger === 'warning' ? 'outline' : 'outline'"
71+
size="sm"
72+
(click)="loadExample(example.html)"
73+
class="text-xs"
74+
[class.border-green-500/50]="example.danger === 'safe'"
75+
[class.border-amber-500/50]="example.danger === 'warning'"
76+
[class.border-red-500/50]="example.danger === 'danger'"
77+
>
78+
{{ example.label }}
79+
</button>
80+
}
81+
</div>
82+
</div>
83+
84+
<!-- Input -->
85+
<div class="grid w-full gap-1.5">
86+
<label hlmLabel for="html-sanitize-input">HTML Input</label>
87+
<textarea
88+
hlmInput
89+
id="html-sanitize-input"
90+
rows="4"
91+
class="w-full resize-none h-20 font-mono text-sm"
92+
[value]="inputHtml()"
93+
(input)="onInput($event)"
94+
placeholder="Enter HTML to sanitize..."
95+
></textarea>
96+
</div>
97+
98+
<!-- Output -->
99+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
100+
<!-- Rendered -->
101+
<div class="rounded-lg border border-border overflow-hidden">
102+
<div class="bg-muted/50 px-4 py-2 border-b border-border flex items-center gap-2">
103+
<ng-icon name="lucideEye" class="h-4 w-4 text-muted-foreground" />
104+
<span class="text-sm font-medium">Rendered Output</span>
105+
</div>
106+
<div class="p-4 min-h-[80px]">
107+
<div class="prose prose-sm dark:prose-invert max-w-none" [innerHTML]="inputHtml() | htmlSanitize"></div>
108+
</div>
109+
</div>
110+
111+
<!-- Source -->
112+
<div class="rounded-lg border border-border overflow-hidden">
113+
<div class="bg-muted/50 px-4 py-2 border-b border-border flex items-center gap-2">
114+
<ng-icon name="lucideCode" class="h-4 w-4 text-muted-foreground" />
115+
<span class="text-sm font-medium">Sanitized Source</span>
116+
</div>
117+
<div class="p-4 min-h-[80px]">
118+
<pre class="text-sm font-mono whitespace-pre-wrap break-all text-muted-foreground">{{ sanitizedSource() }}</pre>
119+
</div>
120+
</div>
121+
</div>
122+
123+
<!-- What was stripped -->
124+
@if (strippedElements().length > 0) {
125+
<div class="rounded-lg border border-red-500/30 overflow-hidden">
126+
<div class="bg-red-500/5 px-4 py-2 border-b border-red-500/30 flex items-center gap-2">
127+
<ng-icon name="lucideShield" class="h-4 w-4 text-red-500" />
128+
<span class="text-sm font-medium text-red-600 dark:text-red-400">Stripped Elements</span>
129+
</div>
130+
<div class="p-4">
131+
<div class="flex flex-wrap gap-2">
132+
@for (el of strippedElements(); track el) {
133+
<span class="inline-flex items-center rounded-md bg-red-500/10 px-2.5 py-1 text-xs font-mono text-red-600 dark:text-red-400 line-through">
134+
{{ el }}
135+
</span>
136+
}
137+
</div>
138+
</div>
139+
</div>
140+
}
141+
</div>
142+
`,
143+
})
144+
export class RichTextPreviewer {
145+
private sanitizer = inject(DomSanitizer);
146+
private sanitizePipe = inject(HtmlSanitizePipe);
147+
148+
inputHtml = signal(
149+
'<h2>Welcome</h2>\n<p>This is <b>safe</b> content.</p>\n<script>alert("XSS")</script>'
150+
);
151+
152+
sanitizedSource = computed(() => {
153+
const result = this.sanitizer.sanitize(0, this.inputHtml());
154+
return result || '';
155+
});
156+
157+
strippedElements = computed(() => {
158+
const input = this.inputHtml();
159+
const stripped: string[] = [];
160+
const dangerousPatterns = [
161+
{ regex: /<script[\s\S]*?<\/script>/gi, label: '<script>' },
162+
{ regex: /<iframe[\s\S]*?(?:<\/iframe>|\/>)/gi, label: '<iframe>' },
163+
{ regex: /<object[\s\S]*?(?:<\/object>|\/>)/gi, label: '<object>' },
164+
{ regex: /<embed[\s\S]*?(?:<\/embed>|\/>)/gi, label: '<embed>' },
165+
{ regex: /\bon\w+\s*=\s*["'][^"']*["']/gi, label: 'event handler' },
166+
{ regex: /style\s*=\s*["'][^"']*expression\([^)]*\)[^"']*["']/gi, label: 'CSS expression' },
167+
];
168+
for (const pattern of dangerousPatterns) {
169+
if (pattern.regex.test(input)) {
170+
stripped.push(pattern.label);
171+
}
172+
}
173+
return stripped;
174+
});
175+
176+
examples: SanitizeExample[] = [
177+
{
178+
label: 'Safe HTML',
179+
html: '<h2>Title</h2>\n<p>A paragraph with <em>emphasis</em> and <strong>bold</strong> text.</p>',
180+
danger: 'safe',
181+
},
182+
{
183+
label: 'Script Injection',
184+
html: '<p>Normal text</p>\n<script>document.cookie</script>\n<p>More text</p>',
185+
danger: 'danger',
186+
},
187+
{
188+
label: 'Event Handler',
189+
html: '<div onmouseover="alert(\'xss\')">Hover me</div>\n<button onclick="steal()">Click</button>',
190+
danger: 'danger',
191+
},
192+
{
193+
label: 'Mixed Content',
194+
html: '<h1>Blog Post</h1>\n<p>Safe content here.</p>\n<iframe src="evil.com"></iframe>\n<b>Bold is fine</b>',
195+
danger: 'warning',
196+
},
197+
];
198+
199+
onInput(event: Event): void {
200+
this.inputHtml.set((event.target as HTMLTextAreaElement).value);
201+
}
202+
203+
loadExample(html: string): void {
204+
this.inputHtml.set(html);
205+
}
206+
}

0 commit comments

Comments
 (0)