Skip to content
File

Blob: src/client/lib/email-html/dom.ts

typescript110 lines
1import { buildExternalLinkRedirectHref } from "@/client/lib/external-link";
2import { sanitizeCssValue, sanitizeSrcSet } from "./css";
3import { isAllowedInlineResourceUrl, isAllowedNavigationUrl, isRemoteResourceUrl, isUnsafeUrl } from "./url-policy";
4 
5export const BLOCKED_TAGS = new Set([
6 "base",
7 "button",
8 "canvas",
9 "embed",
10 "form",
11 "frame",
12 "iframe",
13 "input",
14 "link",
15 "math",
16 "meta",
17 "object",
18 "script",
19 "select",
20 "svg",
21 "textarea",
22]);
23 
24const AUTO_LOAD_ATTRIBUTES = new Set(["background", "poster", "src", "srcset"]);
25const URL_ATTRIBUTES = new Set(["action", "background", "formaction", "href", "poster", "src", "srcset", "xlink:href"]);
26const NAVIGATION_ATTRIBUTES = new Set(["action", "formaction", "href", "xlink:href"]);
27 
28export function createBlockedImagePlaceholder(doc: Document, altText: string | null) {
29 const placeholder = doc.createElement("div");
30 placeholder.setAttribute("data-remote-blocked", "image");
31 placeholder.textContent = altText
32 ? `Remote image blocked: ${altText}`
33 : "Remote image blocked. Load remote content to display it.";
34 return placeholder;
35}
36 
37export function sanitizeElementAttributes(element: Element, allowRemoteContent: boolean) {
38 let blockedRemoteContent = false;
39 
40 for (const attribute of Array.from(element.attributes)) {
41 const name = attribute.name.toLowerCase();
42 const value = attribute.value;
43 
44 if (name.startsWith("on") || name === "srcdoc") {
45 element.removeAttribute(attribute.name);
46 continue;
47 }
48 
49 if (name === "style") {
50 const sanitized = sanitizeCssValue(value, allowRemoteContent);
51 blockedRemoteContent = blockedRemoteContent || sanitized.blockedRemoteContent;
52 if (sanitized.css) {
53 element.setAttribute(attribute.name, sanitized.css);
54 } else {
55 element.removeAttribute(attribute.name);
56 }
57 continue;
58 }
59 
60 if (!URL_ATTRIBUTES.has(name)) {
61 continue;
62 }
63 
64 if (isUnsafeUrl(value)) {
65 element.removeAttribute(attribute.name);
66 continue;
67 }
68 
69 if (NAVIGATION_ATTRIBUTES.has(name)) {
70 if (!isAllowedNavigationUrl(value)) {
71 element.removeAttribute(attribute.name);
72 continue;
73 }
74 
75 if (isRemoteResourceUrl(value)) {
76 element.setAttribute(attribute.name, buildExternalLinkRedirectHref(value));
77 }
78 
79 continue;
80 }
81 
82 if (name === "srcset") {
83 const nextValue = sanitizeSrcSet(value, allowRemoteContent);
84 if (nextValue) {
85 element.setAttribute(attribute.name, nextValue);
86 } else {
87 blockedRemoteContent = blockedRemoteContent || value.trim().length > 0;
88 element.removeAttribute(attribute.name);
89 }
90 continue;
91 }
92 
93 if (isAllowedInlineResourceUrl(value)) {
94 continue;
95 }
96 
97 if (allowRemoteContent && isRemoteResourceUrl(value)) {
98 continue;
99 }
100 
101 if (AUTO_LOAD_ATTRIBUTES.has(name) && isRemoteResourceUrl(value)) {
102 blockedRemoteContent = true;
103 }
104 
105 element.removeAttribute(attribute.name);
106 }
107 
108 return blockedRemoteContent;
109}