react-email-rails 0.4.0 → 0.4.1

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/dist/document.js CHANGED
@@ -69,6 +69,48 @@ async function loadRenderMarkdown() {
69
69
  }
70
70
  throw new Error("marked is missing the expected parse export; check the installed version");
71
71
  }
72
+ // The schema whitelists nodes and attributes but never validates URI protocols, so a
73
+ // javascript:/data: href on a link or button reaches content_json unchecked. Allow only safe schemes.
74
+ const ALLOWED_URI_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
75
+ // Characters browsers ignore when resolving a scheme (so "java\tscript:" runs as javascript:).
76
+ // Built numerically to keep the source free of literal control characters.
77
+ const URI_IGNORED_RANGES = [
78
+ [0x00, 0x20],
79
+ [0xa0, 0xa0],
80
+ [0x1680, 0x1680],
81
+ [0x180e, 0x180e],
82
+ [0x2000, 0x2029],
83
+ [0x205f, 0x205f],
84
+ [0x3000, 0x3000],
85
+ [0xfeff, 0xfeff],
86
+ ];
87
+ const escapeCodePoint = (code) => "\\u" + code.toString(16).padStart(4, "0");
88
+ const URI_IGNORED_CHARS = new RegExp("[" +
89
+ URI_IGNORED_RANGES.map(([lo, hi]) => escapeCodePoint(lo) + "-" + escapeCodePoint(hi)).join("") +
90
+ "]", "g");
91
+ function hasAllowedUriScheme(uri) {
92
+ // No scheme → relative/anchor/query; nothing to neutralize.
93
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri.replace(URI_IGNORED_CHARS, ""))?.[1];
94
+ return scheme === undefined || ALLOWED_URI_SCHEMES.has(scheme.toLowerCase());
95
+ }
96
+ // Blank disallowed hrefs (link marks and nodes like button) in place; the tree is fresh
97
+ // toJSON() output, so mutation is safe.
98
+ function neutralizeUnsafeUris(value) {
99
+ if (Array.isArray(value)) {
100
+ for (const item of value)
101
+ neutralizeUnsafeUris(item);
102
+ return;
103
+ }
104
+ if (value === null || typeof value !== "object")
105
+ return;
106
+ const node = value;
107
+ const attrs = node.attrs;
108
+ if (attrs && typeof attrs.href === "string" && !hasAllowedUriScheme(attrs.href)) {
109
+ attrs.href = "";
110
+ }
111
+ neutralizeUnsafeUris(node.marks);
112
+ neutralizeUnsafeUris(node.content);
113
+ }
72
114
  // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
73
115
  async function resolveHtmlInput(request, dependencies) {
74
116
  const hasHtml = request.html !== undefined;
@@ -116,6 +158,7 @@ export async function parseDocument(request, registry, dependencies = {}) {
116
158
  const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON());
117
159
  const parsed = parseHTML(html, extensions);
118
160
  const document = schema.nodeFromJSON(parsed).toJSON();
161
+ neutralizeUnsafeUris(document);
119
162
  return { document };
120
163
  }
121
164
  export function createParseDocument(generateJSON, renderMarkdown) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.4.0";
1
+ export declare const VERSION = "0.4.1";
2
2
  export declare const RENDER_PROTOCOL_VERSION = 3;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.4.0";
1
+ export const VERSION = "0.4.1";
2
2
  export const RENDER_PROTOCOL_VERSION = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email-rails",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Build and send emails using React and Rails",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/document.ts CHANGED
@@ -105,6 +105,7 @@ async function loadRenderMarkdown(): Promise<RenderMarkdown> {
105
105
  const mod = (await import(/* @vite-ignore */ "marked")) as {
106
106
  marked?: { parse?: (markdown: string) => string | Promise<string> }
107
107
  }
108
+
108
109
  const marked = mod.marked
109
110
  if (marked && typeof marked.parse === "function") {
110
111
  const parse = marked.parse.bind(marked)
@@ -119,6 +120,62 @@ async function loadRenderMarkdown(): Promise<RenderMarkdown> {
119
120
  throw new Error("marked is missing the expected parse export; check the installed version")
120
121
  }
121
122
 
123
+ // The schema whitelists nodes and attributes but never validates URI protocols, so a
124
+ // javascript:/data: href on a link or button reaches content_json unchecked. Allow only safe schemes.
125
+ const ALLOWED_URI_SCHEMES: ReadonlySet<string> = new Set(["http", "https", "mailto", "tel"])
126
+
127
+ // Characters browsers ignore when resolving a scheme (so "java\tscript:" runs as javascript:).
128
+ // Built numerically to keep the source free of literal control characters.
129
+ const URI_IGNORED_RANGES: ReadonlyArray<readonly [number, number]> = [
130
+ [0x00, 0x20],
131
+ [0xa0, 0xa0],
132
+ [0x1680, 0x1680],
133
+ [0x180e, 0x180e],
134
+ [0x2000, 0x2029],
135
+ [0x205f, 0x205f],
136
+ [0x3000, 0x3000],
137
+ [0xfeff, 0xfeff],
138
+ ]
139
+ const escapeCodePoint = (code: number): string => "\\u" + code.toString(16).padStart(4, "0")
140
+ const URI_IGNORED_CHARS = new RegExp(
141
+ "[" +
142
+ URI_IGNORED_RANGES.map(([lo, hi]) => escapeCodePoint(lo) + "-" + escapeCodePoint(hi)).join("") +
143
+ "]",
144
+ "g",
145
+ )
146
+
147
+ function hasAllowedUriScheme(uri: string): boolean {
148
+ // No scheme → relative/anchor/query; nothing to neutralize.
149
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(uri.replace(URI_IGNORED_CHARS, ""))?.[1]
150
+
151
+ return scheme === undefined || ALLOWED_URI_SCHEMES.has(scheme.toLowerCase())
152
+ }
153
+
154
+ // Blank disallowed hrefs (link marks and nodes like button) in place; the tree is fresh
155
+ // toJSON() output, so mutation is safe.
156
+ function neutralizeUnsafeUris(value: unknown): void {
157
+ if (Array.isArray(value)) {
158
+ for (const item of value) neutralizeUnsafeUris(item)
159
+ return
160
+ }
161
+
162
+ if (value === null || typeof value !== "object") return
163
+
164
+ const node = value as {
165
+ attrs?: Record<string, unknown>
166
+ marks?: unknown
167
+ content?: unknown
168
+ }
169
+
170
+ const attrs = node.attrs
171
+ if (attrs && typeof attrs.href === "string" && !hasAllowedUriScheme(attrs.href)) {
172
+ attrs.href = ""
173
+ }
174
+
175
+ neutralizeUnsafeUris(node.marks)
176
+ neutralizeUnsafeUris(node.content)
177
+ }
178
+
122
179
  // Both inputs converge on HTML: markdown is rendered first, then parsed like any HTML.
123
180
  async function resolveHtmlInput(
124
181
  request: ParseDocumentRequest,
@@ -191,6 +248,7 @@ export async function parseDocument(
191
248
  const parseHTML = dependencies.generateJSON ?? (await loadGenerateJSON())
192
249
  const parsed = parseHTML(html, extensions)
193
250
  const document = schema.nodeFromJSON(parsed).toJSON()
251
+ neutralizeUnsafeUris(document)
194
252
 
195
253
  return { document }
196
254
  }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.4.0"
1
+ export const VERSION = "0.4.1"
2
2
  export const RENDER_PROTOCOL_VERSION = 3