@37signals/lexxy 0.9.31-beta → 0.9.31

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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +60 -6
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -129,7 +129,8 @@ var Lexxy = {
129
129
  // Until 3.3.2 that never came up: attributes admitted by a *functional* ADD_ATTR
130
130
  // skipped URI validation entirely (GHSA-cjmm-f4jc-qw8r), which is how data:
131
131
  // URLs worked here — and, less happily, how `url="javascript:…"` survived too.
132
- // The fix restored validation for both.
132
+ // The fix restored validation for both. The bypass was reported to us by
133
+ // @petitpois via HackerOne.
133
134
  //
134
135
  // So `url` is marked URI-safe, which hands the decision to this hook. The hook
135
136
  // only ever removes an attribute — it never force-keeps one — so scoping stays
@@ -152,9 +153,41 @@ var Lexxy = {
152
153
  // ordinary prose like "Q4: results" is not a URI any regex here would accept.
153
154
  const URI_BEARING_ATTACHMENT_ATTRIBUTES = [ "url" ];
154
155
 
155
- // DOMPurify's own IS_ALLOWED_URI, reproduced rather than narrowed, so `url` on a
156
- // non-attachment tag is treated exactly as DOMPurify would have treated it.
157
- const ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i;
156
+ // DOMPurify's own IS_ALLOWED_URI scheme list, reproduced rather than narrowed, so
157
+ // `url` on a non-attachment tag is treated exactly as DOMPurify would have treated
158
+ // it. Kept as a source string so allowedUriRegexp() can widen it with an editor's
159
+ // declared schemes without re-deriving the rest of the pattern.
160
+ const BASE_URI_SCHEMES = "(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix";
161
+
162
+ // A scheme name (not a full pattern), so a caller can't inject regexp syntax.
163
+ const SCHEME_NAME = /^[a-z][a-z0-9+.-]*$/;
164
+
165
+ // The executable schemes, by DOMPurify's own IS_SCRIPT_OR_DATA definition
166
+ // (/^(?:\w+script|data):/i): javascript, vbscript, …script, and data. Widening the
167
+ // allowlist to admit one would let it survive on href/object[data], which the
168
+ // override otherwise defeats — DOMPurify does not re-block a scheme its
169
+ // ALLOWED_URI_REGEXP accepts. A caller declaring one is dropped, so it stays
170
+ // refused. This is a closed set (the schemes a browser executes), not a growing
171
+ // denylist.
172
+ const EXECUTABLE_SCHEME = /^(?:\w+script|data)$/i;
173
+
174
+ // Builds DOMPurify's default IS_ALLOWED_URI, optionally with extra schemes folded
175
+ // into the scheme alternation. Widening the recognised-safe scheme set is how a
176
+ // custom-scheme identifier (a mention's `gid://…`) passes validation without
177
+ // exempting any attribute from it. Passing no schemes reproduces DOMPurify's
178
+ // default exactly.
179
+ function allowedUriRegexp(extraSchemes = []) {
180
+ const extra = extraSchemes
181
+ .map(scheme => String(scheme).toLowerCase())
182
+ .filter(scheme => SCHEME_NAME.test(scheme) && !EXECUTABLE_SCHEME.test(scheme))
183
+ .map(scheme => scheme.replace(/[.+-]/g, "\\$&"));
184
+
185
+ const schemes = [ BASE_URI_SCHEMES, ...extra ].join("|");
186
+
187
+ return new RegExp(`^(?:(?:${schemes}):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))`, "i")
188
+ }
189
+
190
+ const ALLOWED_URI = allowedUriRegexp();
158
191
 
159
192
  // eslint-disable-next-line no-control-regex -- mirrors DOMPurify's own ATTR_WHITESPACE
160
193
  const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
@@ -351,7 +384,17 @@ function buildConfig(allowedElements = null) {
351
384
  // default tag and attribute policy stands. `ALLOWED_TAGS: []` would not be a
352
385
  // default, it would be a refusal: it strips every tag. An editor that declares
353
386
  // an empty allowlist still gets that refusal, because it asked for it.
354
- if (allowedElements) Object.assign(config, allowlistFor(allowedElements));
387
+ //
388
+ // uriSafeSchemes widens the scheme validation rather than exempting an attribute
389
+ // from it: ALLOWED_URI_REGEXP is set only when an editor declares custom schemes,
390
+ // so a value like a mention's `gid://…` passes while javascript:/data: stay
391
+ // refused on every attribute — including href and object[data]. Nothing is taken
392
+ // out of URI checking, so there is no attribute to guard against exempting.
393
+ if (allowedElements) {
394
+ const { uriSafeSchemes, ...tagPolicy } = allowlistFor(allowedElements);
395
+ if (uriSafeSchemes.length) config.ALLOWED_URI_REGEXP = allowedUriRegexp(uriSafeSchemes);
396
+ Object.assign(config, tagPolicy);
397
+ }
355
398
 
356
399
  // Always assigned, including when we have no policy — `null` is what
357
400
  // trustedTypesPolicy() returns then, and `TRUSTED_TYPES_POLICY: null` is
@@ -374,6 +417,7 @@ function allowlistFor(allowedElements) {
374
417
  // Object.prototype key: `tagAttributes["constructor"]` would answer with a
375
418
  // function, and ADD_ATTR would call .includes on it.
376
419
  const tagAttributes = Object.create(null);
420
+ const uriSafeSchemes = [];
377
421
 
378
422
  // Lowercased, because DOMPurify lowercases ALLOWED_TAGS and calls ADD_ATTR
379
423
  // with the lowercased tag and attribute names. Keeping the caller's casing
@@ -386,6 +430,15 @@ function allowlistFor(allowedElements) {
386
430
 
387
431
  tagAttributes[tag] ||= [];
388
432
  tagAttributes[tag].push(...attributes);
433
+
434
+ // A custom scheme a caller declares is folded into the editor's URI-scheme
435
+ // allowlist (ALLOWED_URI_REGEXP in buildConfig), so a value like a mention's
436
+ // `gid="gid://…"` — which DOMPurify otherwise drops as an unknown scheme, even
437
+ // once the `gid` name is allowed — passes validation. It widens the recognised
438
+ // schemes, it does not exempt an attribute: javascript:/data: stay refused
439
+ // everywhere, so there is no navigational attribute to guard. Declared per
440
+ // element for locality, but a scheme is editor-wide once allowed.
441
+ uriSafeSchemes.push(...(element.uriSafeSchemes ?? []));
389
442
  }
390
443
 
391
444
  // Only for tags the caller already permits — this widens what an allowed
@@ -397,7 +450,8 @@ function allowlistFor(allowedElements) {
397
450
  return {
398
451
  ALLOWED_TAGS: Object.keys(tagAttributes),
399
452
  ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
400
- ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute)
453
+ ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute),
454
+ uriSafeSchemes
401
455
  }
402
456
  }
403
457
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.31-beta",
3
+ "version": "0.9.31",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",