email-editor-core 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +260 -0
- package/dist/renderer/blocks/button.d.ts +18 -0
- package/dist/renderer/blocks/button.d.ts.map +1 -0
- package/dist/renderer/blocks/button.js +57 -0
- package/dist/renderer/blocks/divider.d.ts +18 -0
- package/dist/renderer/blocks/divider.d.ts.map +1 -0
- package/dist/renderer/blocks/divider.js +42 -0
- package/dist/renderer/blocks/highlight.d.ts +18 -0
- package/dist/renderer/blocks/highlight.d.ts.map +1 -0
- package/dist/renderer/blocks/highlight.js +49 -0
- package/dist/renderer/blocks/image.d.ts +18 -0
- package/dist/renderer/blocks/image.d.ts.map +1 -0
- package/dist/renderer/blocks/image.js +59 -0
- package/dist/renderer/blocks/paragraph.d.ts +18 -0
- package/dist/renderer/blocks/paragraph.d.ts.map +1 -0
- package/dist/renderer/blocks/paragraph.js +41 -0
- package/dist/renderer/blocks/title.d.ts +18 -0
- package/dist/renderer/blocks/title.d.ts.map +1 -0
- package/dist/renderer/blocks/title.js +49 -0
- package/dist/renderer/parseInlineFormatting.d.ts +14 -0
- package/dist/renderer/parseInlineFormatting.d.ts.map +1 -0
- package/dist/renderer/parseInlineFormatting.js +178 -0
- package/dist/renderer/renderBlock.d.ts +21 -0
- package/dist/renderer/renderBlock.d.ts.map +1 -0
- package/dist/renderer/renderBlock.js +44 -0
- package/dist/renderer/renderEmail.d.ts +26 -0
- package/dist/renderer/renderEmail.d.ts.map +1 -0
- package/dist/renderer/renderEmail.js +275 -0
- package/dist/sanitizer.d.ts +147 -0
- package/dist/sanitizer.d.ts.map +1 -0
- package/dist/sanitizer.js +533 -0
- package/dist/template-config.d.ts +38 -0
- package/dist/template-config.d.ts.map +1 -0
- package/dist/template-config.js +196 -0
- package/dist/test-formatting.d.ts +6 -0
- package/dist/test-formatting.d.ts.map +1 -0
- package/dist/test-formatting.js +132 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/validator.d.ts +86 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +435 -0
- package/package.json +17 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEXT SANITIZATION STRATEGY
|
|
3
|
+
* Ensures input safety and email client compatibility
|
|
4
|
+
*/
|
|
5
|
+
import type { AllowedInlineTag, TextSanitizationConfig, BlockType } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Global sanitization rules
|
|
8
|
+
* Applied to all text content
|
|
9
|
+
*/
|
|
10
|
+
export declare const GLOBAL_SANITIZATION_CONFIG: TextSanitizationConfig;
|
|
11
|
+
/**
|
|
12
|
+
* Block-type specific sanitization rules
|
|
13
|
+
* Some blocks allow more flexibility than others
|
|
14
|
+
*/
|
|
15
|
+
export declare const BLOCK_SANITIZATION_CONFIG: Record<BlockType, TextSanitizationConfig>;
|
|
16
|
+
/**
|
|
17
|
+
* Escape HTML special characters
|
|
18
|
+
* Prevents XSS by converting dangerous chars to entities
|
|
19
|
+
*
|
|
20
|
+
* @param text - Raw text to escape
|
|
21
|
+
* @returns Escaped text safe for HTML
|
|
22
|
+
*/
|
|
23
|
+
export declare function escapeHtml(text: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Validate URL protocol
|
|
26
|
+
* Ensures only safe protocols are used
|
|
27
|
+
*
|
|
28
|
+
* @param url - URL to validate
|
|
29
|
+
* @returns true if URL uses safe protocol
|
|
30
|
+
*/
|
|
31
|
+
export declare function isValidUrlProtocol(url: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Remove all HTML tags from text
|
|
34
|
+
* Used for blocks that don't allow any HTML
|
|
35
|
+
*
|
|
36
|
+
* @param text - HTML text
|
|
37
|
+
* @returns Plain text with all tags removed
|
|
38
|
+
*/
|
|
39
|
+
export declare function stripAllHtml(text: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Parse and validate HTML attributes
|
|
42
|
+
* Returns only allowed attributes with validated values
|
|
43
|
+
*
|
|
44
|
+
* @param tag - HTML tag name
|
|
45
|
+
* @param attributes - Raw attributes from HTML
|
|
46
|
+
* @param allowedAttrs - List of allowed attribute names
|
|
47
|
+
* @returns Clean attribute object
|
|
48
|
+
*/
|
|
49
|
+
export declare function sanitizeAttributes(tag: string, attributes: Record<string, string>, allowedAttrs: string[]): Record<string, string>;
|
|
50
|
+
/**
|
|
51
|
+
* Sanitize HTML content
|
|
52
|
+
* Removes dangerous tags/attributes while preserving allowed inline tags
|
|
53
|
+
*
|
|
54
|
+
* PSEUDOCODE:
|
|
55
|
+
* 1. Parse HTML into tokens (tags and text)
|
|
56
|
+
* 2. For each token:
|
|
57
|
+
* - If text: escape special characters
|
|
58
|
+
* - If tag:
|
|
59
|
+
* - If dangerous tag: remove
|
|
60
|
+
* - If not in allowed list: remove
|
|
61
|
+
* - If allowed: sanitize attributes and keep
|
|
62
|
+
* 3. Reconstruct HTML from clean tokens
|
|
63
|
+
* 4. Validate against allowed tag list
|
|
64
|
+
*
|
|
65
|
+
* @param html - Raw HTML input
|
|
66
|
+
* @param allowedTags - List of allowed tag names
|
|
67
|
+
* @param stripStyles - If true, remove all style attributes
|
|
68
|
+
* @returns Sanitized HTML
|
|
69
|
+
*/
|
|
70
|
+
export declare function sanitizeHtml(html: string, allowedTags: AllowedInlineTag[]): string;
|
|
71
|
+
/**
|
|
72
|
+
* Validate and sanitize text content per block type
|
|
73
|
+
* Main entry point for text sanitization
|
|
74
|
+
*
|
|
75
|
+
* PSEUDOCODE:
|
|
76
|
+
* 1. Get sanitization config for block type
|
|
77
|
+
* 2. If strict mode (no HTML allowed):
|
|
78
|
+
* - Strip all HTML
|
|
79
|
+
* - Escape characters
|
|
80
|
+
* 3. If HTML allowed:
|
|
81
|
+
* - Sanitize HTML keeping only allowed tags
|
|
82
|
+
* - Validate and clean attributes
|
|
83
|
+
* - Escape dangerous characters
|
|
84
|
+
* 4. Return clean text
|
|
85
|
+
*
|
|
86
|
+
* @param text - Raw input text
|
|
87
|
+
* @param blockType - Type of block containing the text
|
|
88
|
+
* @returns Sanitized text safe for email
|
|
89
|
+
*/
|
|
90
|
+
export declare function sanitizeTextContent(text: string, blockType: BlockType): string;
|
|
91
|
+
/**
|
|
92
|
+
* Validate URL for href/src attributes
|
|
93
|
+
* Ensures protocols are safe and format is valid
|
|
94
|
+
*
|
|
95
|
+
* @param url - URL to validate
|
|
96
|
+
* @param requireHttps - If true, reject http://
|
|
97
|
+
* @returns true if valid
|
|
98
|
+
*/
|
|
99
|
+
export declare function isValidUrl(url: string, requireHttps?: boolean): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Sanitize button label
|
|
102
|
+
* Buttons don't support HTML - only plain text
|
|
103
|
+
*
|
|
104
|
+
* @param label - Button label text
|
|
105
|
+
* @returns Sanitized label
|
|
106
|
+
*/
|
|
107
|
+
export declare function sanitizeButtonLabel(label: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Sanitize image alt text
|
|
110
|
+
* Should be plain text only
|
|
111
|
+
*
|
|
112
|
+
* @param alt - Alt text
|
|
113
|
+
* @returns Sanitized alt text
|
|
114
|
+
*/
|
|
115
|
+
export declare function sanitizeImageAlt(alt: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Batch sanitization for entire block object
|
|
118
|
+
* Applies type-specific sanitization to all text fields
|
|
119
|
+
*
|
|
120
|
+
* PSEUDOCODE:
|
|
121
|
+
* 1. Get block type
|
|
122
|
+
* 2. For each text field in block:
|
|
123
|
+
* - Apply appropriate sanitization function
|
|
124
|
+
* - Validate URLs if applicable
|
|
125
|
+
* - Check color formats
|
|
126
|
+
* 3. Return sanitized block copy
|
|
127
|
+
*
|
|
128
|
+
* @param block - Block object to sanitize
|
|
129
|
+
* @returns New block with sanitized content
|
|
130
|
+
*/
|
|
131
|
+
export declare function sanitizeBlock<T extends {
|
|
132
|
+
type: BlockType;
|
|
133
|
+
id: string;
|
|
134
|
+
}>(block: T): T;
|
|
135
|
+
export interface SanitizationResult {
|
|
136
|
+
success: boolean;
|
|
137
|
+
original: string;
|
|
138
|
+
sanitized: string;
|
|
139
|
+
removedCount: number;
|
|
140
|
+
warnings: string[];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get detailed sanitization report
|
|
144
|
+
* Useful for debugging and user feedback
|
|
145
|
+
*/
|
|
146
|
+
export declare function getSanitizationReport(original: string, blockType: BlockType): SanitizationResult;
|
|
147
|
+
//# sourceMappingURL=sanitizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitizer.d.ts","sourceRoot":"","sources":["../src/sanitizer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAMtF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAE,sBAqBxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,EAAE,MAAM,CAAC,SAAS,EAAE,sBAAsB,CAsG/E,CAAC;AA4GF;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQvD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAejD;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,YAAY,EAAE,MAAM,EAAE,GACrB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA+BxB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,gBAAgB,EAAE,GAAG,MAAM,CA4ClF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,GAAG,MAAM,CAgB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAgB9E;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQzD;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMpD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAyCpF;AAMD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,GACnB,kBAAkB,CA6BpB"}
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEXT SANITIZATION STRATEGY
|
|
3
|
+
* Ensures input safety and email client compatibility
|
|
4
|
+
*/
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// SANITIZATION CONFIGURATION
|
|
7
|
+
// ============================================================================
|
|
8
|
+
/**
|
|
9
|
+
* Global sanitization rules
|
|
10
|
+
* Applied to all text content
|
|
11
|
+
*/
|
|
12
|
+
export const GLOBAL_SANITIZATION_CONFIG = {
|
|
13
|
+
// Strip all HTML except explicitly allowed tags
|
|
14
|
+
stripAllHTMLExcept: ['strong', 'b', 'em', 'i', 'u', 'a', 'br'],
|
|
15
|
+
// Always strip inline styles
|
|
16
|
+
stripAllStyles: true,
|
|
17
|
+
// Escape unsafe characters to prevent XSS
|
|
18
|
+
escapeUnsafeCharacters: true,
|
|
19
|
+
// Per-tag restrictions
|
|
20
|
+
tagRestrictions: {
|
|
21
|
+
a: {
|
|
22
|
+
allowedAttributes: ['href'],
|
|
23
|
+
requireHttpProtocol: true,
|
|
24
|
+
},
|
|
25
|
+
img: {
|
|
26
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
27
|
+
requireHttpProtocol: true,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Block-type specific sanitization rules
|
|
33
|
+
* Some blocks allow more flexibility than others
|
|
34
|
+
*/
|
|
35
|
+
export const BLOCK_SANITIZATION_CONFIG = {
|
|
36
|
+
// Titles: plain text + basic emphasis
|
|
37
|
+
title: {
|
|
38
|
+
stripAllHTMLExcept: ['strong', 'b', 'em', 'i'],
|
|
39
|
+
stripAllStyles: true,
|
|
40
|
+
escapeUnsafeCharacters: true,
|
|
41
|
+
tagRestrictions: {
|
|
42
|
+
a: {
|
|
43
|
+
allowedAttributes: ['href'],
|
|
44
|
+
requireHttpProtocol: true,
|
|
45
|
+
},
|
|
46
|
+
img: {
|
|
47
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
48
|
+
requireHttpProtocol: true,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
// Paragraphs: most flexible - allow links and emphasis
|
|
53
|
+
paragraph: {
|
|
54
|
+
stripAllHTMLExcept: ['strong', 'b', 'em', 'i', 'u', 'a', 'br'],
|
|
55
|
+
stripAllStyles: true,
|
|
56
|
+
escapeUnsafeCharacters: true,
|
|
57
|
+
tagRestrictions: {
|
|
58
|
+
a: {
|
|
59
|
+
allowedAttributes: ['href'],
|
|
60
|
+
requireHttpProtocol: true,
|
|
61
|
+
},
|
|
62
|
+
img: {
|
|
63
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
64
|
+
requireHttpProtocol: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
// Images: no text content to sanitize
|
|
69
|
+
image: {
|
|
70
|
+
stripAllHTMLExcept: [],
|
|
71
|
+
stripAllStyles: true,
|
|
72
|
+
escapeUnsafeCharacters: true,
|
|
73
|
+
tagRestrictions: {
|
|
74
|
+
a: {
|
|
75
|
+
allowedAttributes: ['href'],
|
|
76
|
+
requireHttpProtocol: true,
|
|
77
|
+
},
|
|
78
|
+
img: {
|
|
79
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
80
|
+
requireHttpProtocol: true,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
// Buttons: plain text only, no HTML allowed
|
|
85
|
+
button: {
|
|
86
|
+
stripAllHTMLExcept: [],
|
|
87
|
+
stripAllStyles: true,
|
|
88
|
+
escapeUnsafeCharacters: true,
|
|
89
|
+
tagRestrictions: {
|
|
90
|
+
a: {
|
|
91
|
+
allowedAttributes: ['href'],
|
|
92
|
+
requireHttpProtocol: true,
|
|
93
|
+
},
|
|
94
|
+
img: {
|
|
95
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
96
|
+
requireHttpProtocol: true,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
// Dividers: no text content
|
|
101
|
+
divider: {
|
|
102
|
+
stripAllHTMLExcept: [],
|
|
103
|
+
stripAllStyles: true,
|
|
104
|
+
escapeUnsafeCharacters: true,
|
|
105
|
+
tagRestrictions: {
|
|
106
|
+
a: {
|
|
107
|
+
allowedAttributes: ['href'],
|
|
108
|
+
requireHttpProtocol: true,
|
|
109
|
+
},
|
|
110
|
+
img: {
|
|
111
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
112
|
+
requireHttpProtocol: true,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
// Highlight boxes: allow emphasis and links
|
|
117
|
+
'highlight-box': {
|
|
118
|
+
stripAllHTMLExcept: ['strong', 'b', 'em', 'i', 'u', 'a', 'br'],
|
|
119
|
+
stripAllStyles: true,
|
|
120
|
+
escapeUnsafeCharacters: true,
|
|
121
|
+
tagRestrictions: {
|
|
122
|
+
a: {
|
|
123
|
+
allowedAttributes: ['href'],
|
|
124
|
+
requireHttpProtocol: true,
|
|
125
|
+
},
|
|
126
|
+
img: {
|
|
127
|
+
allowedAttributes: ['src', 'alt', 'width', 'height'],
|
|
128
|
+
requireHttpProtocol: true,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// PERSONALIZATION TOKEN HANDLING
|
|
135
|
+
// ============================================================================
|
|
136
|
+
const ALLOWED_PERSONALIZATION_TOKENS = new Set(['firstName', 'lastName', 'email']);
|
|
137
|
+
/**
|
|
138
|
+
* Extract allowed personalization tokens and replace with safe placeholders
|
|
139
|
+
*/
|
|
140
|
+
function extractPersonalizationTokens(text) {
|
|
141
|
+
const tokens = new Map();
|
|
142
|
+
let index = 0;
|
|
143
|
+
const modified = text.replace(/\{\{([^}]+)\}\}/g, (fullMatch, tokenName) => {
|
|
144
|
+
const trimmed = tokenName.trim();
|
|
145
|
+
if (ALLOWED_PERSONALIZATION_TOKENS.has(trimmed)) {
|
|
146
|
+
const placeholder = `__PERSONALIZATION_TOKEN_${index}__`;
|
|
147
|
+
tokens.set(placeholder, fullMatch);
|
|
148
|
+
index++;
|
|
149
|
+
return placeholder;
|
|
150
|
+
}
|
|
151
|
+
return '';
|
|
152
|
+
});
|
|
153
|
+
return { text: modified, tokens };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Restore original personalization tokens after sanitization
|
|
157
|
+
*/
|
|
158
|
+
function restorePersonalizationTokens(text, tokens) {
|
|
159
|
+
let result = text;
|
|
160
|
+
tokens.forEach((original, placeholder) => {
|
|
161
|
+
result = result.split(placeholder).join(original);
|
|
162
|
+
});
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// SANITIZATION FUNCTIONS
|
|
167
|
+
// ============================================================================
|
|
168
|
+
/**
|
|
169
|
+
* List of potentially dangerous HTML tags that must be stripped
|
|
170
|
+
*/
|
|
171
|
+
const DANGEROUS_TAGS = [
|
|
172
|
+
'script',
|
|
173
|
+
'iframe',
|
|
174
|
+
'object',
|
|
175
|
+
'embed',
|
|
176
|
+
'form',
|
|
177
|
+
'input',
|
|
178
|
+
'button',
|
|
179
|
+
'textarea',
|
|
180
|
+
'style',
|
|
181
|
+
'link',
|
|
182
|
+
'meta',
|
|
183
|
+
'base',
|
|
184
|
+
];
|
|
185
|
+
/**
|
|
186
|
+
* List of dangerous attributes that carry scripts/code
|
|
187
|
+
*/
|
|
188
|
+
const DANGEROUS_ATTRIBUTES = [
|
|
189
|
+
'onload',
|
|
190
|
+
'onerror',
|
|
191
|
+
'onclick',
|
|
192
|
+
'onmouseover',
|
|
193
|
+
'onmouseout',
|
|
194
|
+
'onmousemove',
|
|
195
|
+
'onmouseenter',
|
|
196
|
+
'onmouseleave',
|
|
197
|
+
'onchange',
|
|
198
|
+
'onfocus',
|
|
199
|
+
'onblur',
|
|
200
|
+
'onsubmit',
|
|
201
|
+
'onkeydown',
|
|
202
|
+
'onkeyup',
|
|
203
|
+
'onkeypress',
|
|
204
|
+
'ondblclick',
|
|
205
|
+
'ondrag',
|
|
206
|
+
'ondrop',
|
|
207
|
+
'onwheel',
|
|
208
|
+
'onscroll',
|
|
209
|
+
'style',
|
|
210
|
+
'class',
|
|
211
|
+
'id',
|
|
212
|
+
];
|
|
213
|
+
/**
|
|
214
|
+
* Characters that need HTML entity escaping to prevent XSS
|
|
215
|
+
*/
|
|
216
|
+
const UNSAFE_CHARS = {
|
|
217
|
+
'&': '&',
|
|
218
|
+
'<': '<',
|
|
219
|
+
'>': '>',
|
|
220
|
+
'"': '"',
|
|
221
|
+
"'": ''',
|
|
222
|
+
'/': '/',
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Escape HTML special characters
|
|
226
|
+
* Prevents XSS by converting dangerous chars to entities
|
|
227
|
+
*
|
|
228
|
+
* @param text - Raw text to escape
|
|
229
|
+
* @returns Escaped text safe for HTML
|
|
230
|
+
*/
|
|
231
|
+
export function escapeHtml(text) {
|
|
232
|
+
return text.replace(/[&<>"'\/]/g, (char) => UNSAFE_CHARS[char] ?? char);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Validate URL protocol
|
|
236
|
+
* Ensures only safe protocols are used
|
|
237
|
+
*
|
|
238
|
+
* @param url - URL to validate
|
|
239
|
+
* @returns true if URL uses safe protocol
|
|
240
|
+
*/
|
|
241
|
+
export function isValidUrlProtocol(url) {
|
|
242
|
+
if (!url)
|
|
243
|
+
return false;
|
|
244
|
+
// Allowed protocols
|
|
245
|
+
const allowedProtocols = ['http://', 'https://', 'mailto:'];
|
|
246
|
+
const lowerUrl = url.toLowerCase().trim();
|
|
247
|
+
return allowedProtocols.some((protocol) => lowerUrl.startsWith(protocol));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Remove all HTML tags from text
|
|
251
|
+
* Used for blocks that don't allow any HTML
|
|
252
|
+
*
|
|
253
|
+
* @param text - HTML text
|
|
254
|
+
* @returns Plain text with all tags removed
|
|
255
|
+
*/
|
|
256
|
+
export function stripAllHtml(text) {
|
|
257
|
+
return text
|
|
258
|
+
.replace(/<[^>]*>/g, '') // Remove all tags
|
|
259
|
+
.replace(/&[a-zA-Z0-9]+;/g, (entity) => {
|
|
260
|
+
// Decode common entities
|
|
261
|
+
const entityMap = {
|
|
262
|
+
'&': '&',
|
|
263
|
+
'<': '<',
|
|
264
|
+
'>': '>',
|
|
265
|
+
'"': '"',
|
|
266
|
+
''': "'",
|
|
267
|
+
' ': ' ',
|
|
268
|
+
};
|
|
269
|
+
return entityMap[entity] ?? entity;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Parse and validate HTML attributes
|
|
274
|
+
* Returns only allowed attributes with validated values
|
|
275
|
+
*
|
|
276
|
+
* @param tag - HTML tag name
|
|
277
|
+
* @param attributes - Raw attributes from HTML
|
|
278
|
+
* @param allowedAttrs - List of allowed attribute names
|
|
279
|
+
* @returns Clean attribute object
|
|
280
|
+
*/
|
|
281
|
+
export function sanitizeAttributes(tag, attributes, allowedAttrs) {
|
|
282
|
+
const clean = {};
|
|
283
|
+
Object.entries(attributes).forEach(([attrName, attrValue]) => {
|
|
284
|
+
const lowerAttrName = attrName.toLowerCase();
|
|
285
|
+
// Reject dangerous attributes
|
|
286
|
+
if (DANGEROUS_ATTRIBUTES.includes(lowerAttrName)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Only allow explicitly allowed attributes
|
|
290
|
+
if (!allowedAttrs.includes(lowerAttrName)) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Validate specific attributes
|
|
294
|
+
if (lowerAttrName === 'href' || lowerAttrName === 'src') {
|
|
295
|
+
if (!isValidUrlProtocol(attrValue)) {
|
|
296
|
+
return; // Skip invalid URLs
|
|
297
|
+
}
|
|
298
|
+
clean[lowerAttrName] = escapeHtml(attrValue);
|
|
299
|
+
}
|
|
300
|
+
else if (lowerAttrName === 'alt') {
|
|
301
|
+
clean[lowerAttrName] = escapeHtml(attrValue);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// For other allowed attributes, escape and include
|
|
305
|
+
clean[lowerAttrName] = escapeHtml(attrValue);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return clean;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Sanitize HTML content
|
|
312
|
+
* Removes dangerous tags/attributes while preserving allowed inline tags
|
|
313
|
+
*
|
|
314
|
+
* PSEUDOCODE:
|
|
315
|
+
* 1. Parse HTML into tokens (tags and text)
|
|
316
|
+
* 2. For each token:
|
|
317
|
+
* - If text: escape special characters
|
|
318
|
+
* - If tag:
|
|
319
|
+
* - If dangerous tag: remove
|
|
320
|
+
* - If not in allowed list: remove
|
|
321
|
+
* - If allowed: sanitize attributes and keep
|
|
322
|
+
* 3. Reconstruct HTML from clean tokens
|
|
323
|
+
* 4. Validate against allowed tag list
|
|
324
|
+
*
|
|
325
|
+
* @param html - Raw HTML input
|
|
326
|
+
* @param allowedTags - List of allowed tag names
|
|
327
|
+
* @param stripStyles - If true, remove all style attributes
|
|
328
|
+
* @returns Sanitized HTML
|
|
329
|
+
*/
|
|
330
|
+
export function sanitizeHtml(html, allowedTags) {
|
|
331
|
+
if (!html)
|
|
332
|
+
return '';
|
|
333
|
+
// Simple tokenizer: split by tags
|
|
334
|
+
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
|
|
335
|
+
const allowedTagSet = new Set(allowedTags.map((t) => t.toLowerCase()));
|
|
336
|
+
const dangerousTagSet = new Set(DANGEROUS_TAGS.map((t) => t.toLowerCase()));
|
|
337
|
+
let result = '';
|
|
338
|
+
let lastIndex = 0;
|
|
339
|
+
const matches = html.matchAll(tagRegex);
|
|
340
|
+
for (const match of matches) {
|
|
341
|
+
// Add text before tag
|
|
342
|
+
const textBefore = html.substring(lastIndex, match.index);
|
|
343
|
+
result += escapeHtml(textBefore);
|
|
344
|
+
const fullTag = match[0];
|
|
345
|
+
const tagName = match[1].toLowerCase();
|
|
346
|
+
const isClosing = fullTag.startsWith('</');
|
|
347
|
+
// Skip dangerous tags
|
|
348
|
+
if (dangerousTagSet.has(tagName)) {
|
|
349
|
+
lastIndex = match.index + fullTag.length;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// Skip disallowed tags
|
|
353
|
+
if (!allowedTagSet.has(tagName)) {
|
|
354
|
+
lastIndex = match.index + fullTag.length;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// Keep allowed tag
|
|
358
|
+
result += fullTag;
|
|
359
|
+
lastIndex = match.index + fullTag.length;
|
|
360
|
+
}
|
|
361
|
+
// Add remaining text
|
|
362
|
+
const textAfter = html.substring(lastIndex);
|
|
363
|
+
result += escapeHtml(textAfter);
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Validate and sanitize text content per block type
|
|
368
|
+
* Main entry point for text sanitization
|
|
369
|
+
*
|
|
370
|
+
* PSEUDOCODE:
|
|
371
|
+
* 1. Get sanitization config for block type
|
|
372
|
+
* 2. If strict mode (no HTML allowed):
|
|
373
|
+
* - Strip all HTML
|
|
374
|
+
* - Escape characters
|
|
375
|
+
* 3. If HTML allowed:
|
|
376
|
+
* - Sanitize HTML keeping only allowed tags
|
|
377
|
+
* - Validate and clean attributes
|
|
378
|
+
* - Escape dangerous characters
|
|
379
|
+
* 4. Return clean text
|
|
380
|
+
*
|
|
381
|
+
* @param text - Raw input text
|
|
382
|
+
* @param blockType - Type of block containing the text
|
|
383
|
+
* @returns Sanitized text safe for email
|
|
384
|
+
*/
|
|
385
|
+
export function sanitizeTextContent(text, blockType) {
|
|
386
|
+
if (!text)
|
|
387
|
+
return '';
|
|
388
|
+
const { text: textWithPlaceholders, tokens } = extractPersonalizationTokens(text);
|
|
389
|
+
const config = BLOCK_SANITIZATION_CONFIG[blockType];
|
|
390
|
+
let sanitized;
|
|
391
|
+
if (config.stripAllHTMLExcept.length === 0) {
|
|
392
|
+
const plain = stripAllHtml(textWithPlaceholders);
|
|
393
|
+
sanitized = config.escapeUnsafeCharacters ? escapeHtml(plain) : plain;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
sanitized = sanitizeHtml(textWithPlaceholders, config.stripAllHTMLExcept);
|
|
397
|
+
}
|
|
398
|
+
return restorePersonalizationTokens(sanitized, tokens);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Validate URL for href/src attributes
|
|
402
|
+
* Ensures protocols are safe and format is valid
|
|
403
|
+
*
|
|
404
|
+
* @param url - URL to validate
|
|
405
|
+
* @param requireHttps - If true, reject http://
|
|
406
|
+
* @returns true if valid
|
|
407
|
+
*/
|
|
408
|
+
export function isValidUrl(url, requireHttps = false) {
|
|
409
|
+
if (!url || typeof url !== 'string')
|
|
410
|
+
return false;
|
|
411
|
+
try {
|
|
412
|
+
const urlObj = new URL(url);
|
|
413
|
+
const protocol = urlObj.protocol.slice(0, -1); // Remove trailing :
|
|
414
|
+
if (requireHttps) {
|
|
415
|
+
return protocol === 'https';
|
|
416
|
+
}
|
|
417
|
+
return ['http', 'https', 'mailto'].includes(protocol);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// Not a valid URL
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Sanitize button label
|
|
426
|
+
* Buttons don't support HTML - only plain text
|
|
427
|
+
*
|
|
428
|
+
* @param label - Button label text
|
|
429
|
+
* @returns Sanitized label
|
|
430
|
+
*/
|
|
431
|
+
export function sanitizeButtonLabel(label) {
|
|
432
|
+
if (!label)
|
|
433
|
+
return '';
|
|
434
|
+
// Strip all HTML
|
|
435
|
+
const plain = stripAllHtml(label);
|
|
436
|
+
// Trim and escape
|
|
437
|
+
return escapeHtml(plain.trim());
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Sanitize image alt text
|
|
441
|
+
* Should be plain text only
|
|
442
|
+
*
|
|
443
|
+
* @param alt - Alt text
|
|
444
|
+
* @returns Sanitized alt text
|
|
445
|
+
*/
|
|
446
|
+
export function sanitizeImageAlt(alt) {
|
|
447
|
+
if (!alt)
|
|
448
|
+
return '';
|
|
449
|
+
// Strip HTML and escape
|
|
450
|
+
const plain = stripAllHtml(alt);
|
|
451
|
+
return escapeHtml(plain.trim());
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Batch sanitization for entire block object
|
|
455
|
+
* Applies type-specific sanitization to all text fields
|
|
456
|
+
*
|
|
457
|
+
* PSEUDOCODE:
|
|
458
|
+
* 1. Get block type
|
|
459
|
+
* 2. For each text field in block:
|
|
460
|
+
* - Apply appropriate sanitization function
|
|
461
|
+
* - Validate URLs if applicable
|
|
462
|
+
* - Check color formats
|
|
463
|
+
* 3. Return sanitized block copy
|
|
464
|
+
*
|
|
465
|
+
* @param block - Block object to sanitize
|
|
466
|
+
* @returns New block with sanitized content
|
|
467
|
+
*/
|
|
468
|
+
export function sanitizeBlock(block) {
|
|
469
|
+
const sanitized = { ...block };
|
|
470
|
+
switch (block.type) {
|
|
471
|
+
case 'title':
|
|
472
|
+
case 'paragraph':
|
|
473
|
+
case 'highlight-box':
|
|
474
|
+
if ('content' in sanitized) {
|
|
475
|
+
sanitized.content = sanitizeTextContent(sanitized.content, block.type);
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
case 'button':
|
|
479
|
+
if ('label' in sanitized) {
|
|
480
|
+
sanitized.label = sanitizeButtonLabel(sanitized.label);
|
|
481
|
+
}
|
|
482
|
+
if ('href' in sanitized) {
|
|
483
|
+
if (!isValidUrl(sanitized.href)) {
|
|
484
|
+
throw new Error(`Invalid URL in button: ${sanitized.href}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
case 'image':
|
|
489
|
+
if ('alt' in sanitized) {
|
|
490
|
+
sanitized.alt = sanitizeImageAlt(sanitized.alt);
|
|
491
|
+
}
|
|
492
|
+
if ('src' in sanitized) {
|
|
493
|
+
if (!isValidUrl(sanitized.src, true)) {
|
|
494
|
+
// Images must use HTTPS
|
|
495
|
+
throw new Error(`Invalid HTTPS URL for image: ${sanitized.src}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
// Dividers don't have text content
|
|
500
|
+
case 'divider':
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
return sanitized;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Get detailed sanitization report
|
|
507
|
+
* Useful for debugging and user feedback
|
|
508
|
+
*/
|
|
509
|
+
export function getSanitizationReport(original, blockType) {
|
|
510
|
+
const warnings = [];
|
|
511
|
+
const sanitized = sanitizeTextContent(original, blockType);
|
|
512
|
+
// Count removed tags
|
|
513
|
+
const tagRegex = /<[^>]*>/g;
|
|
514
|
+
const config = BLOCK_SANITIZATION_CONFIG[blockType];
|
|
515
|
+
const allowedTagSet = new Set(config.stripAllHTMLExcept);
|
|
516
|
+
let removedCount = 0;
|
|
517
|
+
const matches = original.matchAll(tagRegex);
|
|
518
|
+
for (const match of matches) {
|
|
519
|
+
const tag = match[0];
|
|
520
|
+
const tagName = tag.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)/)?.[1]?.toLowerCase();
|
|
521
|
+
if (tagName && !allowedTagSet.has(tagName)) {
|
|
522
|
+
removedCount++;
|
|
523
|
+
warnings.push(`Removed disallowed tag: ${tag}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
success: removedCount === 0,
|
|
528
|
+
original,
|
|
529
|
+
sanitized,
|
|
530
|
+
removedCount,
|
|
531
|
+
warnings,
|
|
532
|
+
};
|
|
533
|
+
}
|