@richscripts/richtexteditor 2.11.1 → 2.12.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.
@@ -2538,6 +2538,29 @@ function RTE_Plugin_AIToolkit() {
2538
2538
  };
2539
2539
  }
2540
2540
 
2541
+ // HTML that arrives from a ledger or a persisted document came from someone else - another
2542
+ // reviewer, a shared store, an AI endpoint that can be prompt-injected. It is written straight
2543
+ // into the document on accept/reject, so clean it here, at the boundary, instead of relying on
2544
+ // whatever runs against the live DOM later. Script vectors go through the content sanitizer when
2545
+ // it is loaded; position:fixed/sticky is dropped either way, because a full-viewport overlay from
2546
+ // remote content survived every other pass (UI redress, measured 2026-09-18).
2547
+ function cleanRemoteHtml(html) {
2548
+ if (!html) return "";
2549
+ html = String(html);
2550
+ if (typeof editor.sanitizeHtml === "function") html = editor.sanitizeHtml(html);
2551
+ var doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
2552
+ var styled = doc.body.querySelectorAll("[style]");
2553
+ for (var i = 0; i < styled.length; i++) {
2554
+ var st = styled[i].style;
2555
+ if (/^(fixed|sticky)$/i.test(st.position)) {
2556
+ st.removeProperty("position");
2557
+ st.removeProperty("z-index");
2558
+ if (!styled[i].getAttribute("style")) styled[i].removeAttribute("style");
2559
+ }
2560
+ }
2561
+ return doc.body.innerHTML;
2562
+ }
2563
+
2541
2564
  function normalizeLedgerEntry(raw) {
2542
2565
  if (!raw || !raw.id) return null;
2543
2566
  var changeType = raw.changeType || "ai-preview";
@@ -2555,10 +2578,10 @@ function RTE_Plugin_AIToolkit() {
2555
2578
  name: author.name || author.id || "User",
2556
2579
  color: author.color || "#2563eb"
2557
2580
  },
2558
- originalHtml: raw.originalHtml || "",
2581
+ originalHtml: cleanRemoteHtml(raw.originalHtml),
2559
2582
  originalText: raw.originalText || "",
2560
2583
  resultText: raw.resultText || "",
2561
- resultHtml: raw.resultHtml || "",
2584
+ resultHtml: cleanRemoteHtml(raw.resultHtml),
2562
2585
  reason: raw.reason || "",
2563
2586
  suggestionType: raw.suggestionType || "",
2564
2587
  language: raw.language || "",
@@ -2595,10 +2618,10 @@ function RTE_Plugin_AIToolkit() {
2595
2618
  name: author.name || author.id || "User",
2596
2619
  color: author.color || "#2563eb"
2597
2620
  },
2598
- originalHtml: raw.originalHtml || textToInlineHtml(raw.originalText || ""),
2621
+ originalHtml: cleanRemoteHtml(raw.originalHtml) || textToInlineHtml(raw.originalText || ""),
2599
2622
  originalText: normalizeText(raw.originalText || ""),
2600
2623
  resultText: normalizeText(raw.resultText || ""),
2601
- resultHtml: raw.resultHtml || textToInlineHtml(raw.resultText || ""),
2624
+ resultHtml: cleanRemoteHtml(raw.resultHtml) || textToInlineHtml(raw.resultText || ""),
2602
2625
  reason: normalizeText(raw.reason || ""),
2603
2626
  suggestionType: getSuggestionTypeValue(raw.suggestionType || ""),
2604
2627
  language: raw.language || "",
@@ -3748,6 +3748,29 @@ function RTE_Plugin_AIToolkit() {
3748
3748
  };
3749
3749
  }
3750
3750
 
3751
+ // HTML that arrives from a ledger or a persisted document came from someone else - another
3752
+ // reviewer, a shared store, an AI endpoint that can be prompt-injected. It is written straight
3753
+ // into the document on accept/reject, so clean it here, at the boundary, instead of relying on
3754
+ // whatever runs against the live DOM later. Script vectors go through the content sanitizer when
3755
+ // it is loaded; position:fixed/sticky is dropped either way, because a full-viewport overlay from
3756
+ // remote content survived every other pass (UI redress, measured 2026-09-18).
3757
+ function cleanRemoteHtml(html) {
3758
+ if (!html) return "";
3759
+ html = String(html);
3760
+ if (typeof editor.sanitizeHtml === "function") html = editor.sanitizeHtml(html);
3761
+ var doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
3762
+ var styled = doc.body.querySelectorAll("[style]");
3763
+ for (var i = 0; i < styled.length; i++) {
3764
+ var st = styled[i].style;
3765
+ if (/^(fixed|sticky)$/i.test(st.position)) {
3766
+ st.removeProperty("position");
3767
+ st.removeProperty("z-index");
3768
+ if (!styled[i].getAttribute("style")) styled[i].removeAttribute("style");
3769
+ }
3770
+ }
3771
+ return doc.body.innerHTML;
3772
+ }
3773
+
3751
3774
  function normalizeLedgerEntry(raw) {
3752
3775
  if (!raw || !raw.id) return null;
3753
3776
  var changeType = raw.changeType || "ai-preview";
@@ -3765,10 +3788,10 @@ function RTE_Plugin_AIToolkit() {
3765
3788
  name: author.name || author.id || "User",
3766
3789
  color: author.color || "#2563eb"
3767
3790
  },
3768
- originalHtml: raw.originalHtml || "",
3791
+ originalHtml: cleanRemoteHtml(raw.originalHtml),
3769
3792
  originalText: raw.originalText || "",
3770
3793
  resultText: raw.resultText || "",
3771
- resultHtml: raw.resultHtml || "",
3794
+ resultHtml: cleanRemoteHtml(raw.resultHtml),
3772
3795
  reason: raw.reason || "",
3773
3796
  suggestionType: raw.suggestionType || "",
3774
3797
  language: raw.language || "",
@@ -3805,10 +3828,10 @@ function RTE_Plugin_AIToolkit() {
3805
3828
  name: author.name || author.id || "User",
3806
3829
  color: author.color || "#2563eb"
3807
3830
  },
3808
- originalHtml: raw.originalHtml || textToInlineHtml(raw.originalText || ""),
3831
+ originalHtml: cleanRemoteHtml(raw.originalHtml) || textToInlineHtml(raw.originalText || ""),
3809
3832
  originalText: normalizeText(raw.originalText || ""),
3810
3833
  resultText: normalizeText(raw.resultText || ""),
3811
- resultHtml: raw.resultHtml || textToInlineHtml(raw.resultText || ""),
3834
+ resultHtml: cleanRemoteHtml(raw.resultHtml) || textToInlineHtml(raw.resultText || ""),
3812
3835
  reason: normalizeText(raw.reason || ""),
3813
3836
  suggestionType: getSuggestionTypeValue(raw.suggestionType || ""),
3814
3837
  language: raw.language || "",
@@ -30812,10 +30835,12 @@ function RTE_Plugin_InsertEmoji() {
30812
30835
  //
30813
30836
  // config.galleryEndpoint URL returning { currentFolder, currentFolderDisplay,
30814
30837
  // parentFolder, folders: [{folder,name}],
30815
- // images: [{url,name,folder,size,source}] }.
30838
+ // images: [{url,name,folder,size,source,alt}] }.
30816
30839
  // Absent -> local mode: presets only, no request.
30817
30840
  // config.galleryImages Preset images. String, [url, text], or an object
30818
- // with url/src/href plus optional thumbnail/name/meta.
30841
+ // with url/src/href plus optional thumbnail/name/meta/alt.
30842
+ // alt (presets and server images) becomes the inserted
30843
+ // image's alt text; without it the attribute stays absent.
30819
30844
  //
30820
30845
  // Uploads use window.richTextBoxUploadFile when present (it accepts a target
30821
30846
  // folder), otherwise the generic window.rte_file_upload_handler.
@@ -30946,7 +30971,8 @@ function RTE_Plugin_InsertGallery() {
30946
30971
  url: url,
30947
30972
  thumbnail: item.thumbnail || item.thumb || item.preview || url,
30948
30973
  name: item.name || item.text || item.title || getFileName(url),
30949
- meta: item.meta || item.description || item.alt || ""
30974
+ meta: item.meta || item.description || item.alt || "",
30975
+ alt: typeof item.alt === "string" ? item.alt : undefined
30950
30976
  };
30951
30977
  }
30952
30978
 
@@ -30986,6 +31012,7 @@ function RTE_Plugin_InsertGallery() {
30986
31012
  folder: item.folder || "",
30987
31013
  source: item.source || "upload",
30988
31014
  size: item.size || 0,
31015
+ alt: typeof item.alt === "string" ? item.alt : undefined,
30989
31016
  searchText: (name + " " + item.url).toLowerCase()
30990
31017
  };
30991
31018
  }
@@ -31179,7 +31206,9 @@ function RTE_Plugin_InsertGallery() {
31179
31206
  return;
31180
31207
  }
31181
31208
 
31182
- editor.insertImageByUrl(selected.url);
31209
+ // Only an alt the host supplied (preset or server listing). The file name is
31210
+ // not alt text; leaving it absent lets the accessibility checker flag it.
31211
+ editor.insertImageByUrl(selected.url, selected.alt);
31183
31212
  closeDialog();
31184
31213
  editor.focus();
31185
31214
  }
@@ -31410,6 +31439,10 @@ function RTE_Plugin_InsertGallery() {
31410
31439
  render();
31411
31440
  }
31412
31441
 
31442
+ // Upload failures to show once the folder has been re-listed (applyResponse
31443
+ // clears the error, so it would otherwise vanish).
31444
+ var pendingError = "";
31445
+
31413
31446
  function loadFolder(folder, selectAfterLoad) {
31414
31447
  if (!serverMode) {
31415
31448
  state.images = presetImages.slice(0);
@@ -31423,6 +31456,7 @@ function RTE_Plugin_InsertGallery() {
31423
31456
 
31424
31457
  requestJson("GET", withFolder(endpoint, folder), null, function (payload, errorCode) {
31425
31458
  if (errorCode) {
31459
+ pendingError = "";
31426
31460
  loadFallback(errorCode);
31427
31461
  return;
31428
31462
  }
@@ -31431,6 +31465,8 @@ function RTE_Plugin_InsertGallery() {
31431
31465
  if (selectAfterLoad) {
31432
31466
  state.selectedUrl = selectAfterLoad;
31433
31467
  }
31468
+ state.error = pendingError;
31469
+ pendingError = "";
31434
31470
  state.loading = false;
31435
31471
  render();
31436
31472
  });
@@ -31465,67 +31501,120 @@ function RTE_Plugin_InsertGallery() {
31465
31501
  });
31466
31502
  }
31467
31503
 
31504
+ // config.maxUploadFileSize is enforced by the core only on its own insert
31505
+ // paths. The gallery calls the upload handler directly, so the limit a
31506
+ // customer set to protect their database was skipped for every image
31507
+ // uploaded through this dialog. The accept list is only a hint to the file
31508
+ // picker ("All files" bypasses it), so the format is checked here too.
31509
+ function rejectReason(file) {
31510
+ var name = String(file.name || "").toLowerCase();
31511
+ var dot = name.lastIndexOf(".");
31512
+ if (dot < 0 || GALLERY_ACCEPT.indexOf(name.substring(dot)) < 0) {
31513
+ return file.name + " is not an accepted image format.";
31514
+ }
31515
+ var max = parseInt(config.maxUploadFileSize, 10);
31516
+ if (max > 0 && file.size > max) {
31517
+ return file.name + " is " + humanSize(file.size) + ". The maximum allowed size is " + humanSize(max) + ".";
31518
+ }
31519
+ return "";
31520
+ }
31521
+
31468
31522
  function uploadFiles(fileList) {
31469
31523
  var files = [];
31524
+ var problems = [];
31470
31525
  var index;
31471
31526
  for (index = 0; index < fileList.length; index++) {
31472
- files.push(fileList[index]);
31527
+ var reason = rejectReason(fileList[index]);
31528
+ if (reason) {
31529
+ problems.push(reason);
31530
+ } else {
31531
+ files.push(fileList[index]);
31532
+ }
31473
31533
  }
31474
31534
 
31475
- if (!files.length || !canUpload) {
31535
+ if (!canUpload) {
31536
+ return;
31537
+ }
31538
+ if (!files.length) {
31539
+ if (problems.length) {
31540
+ state.error = problems.join(" ");
31541
+ render();
31542
+ }
31476
31543
  return;
31477
31544
  }
31478
31545
 
31479
- var lastUploadedUrl = "";
31546
+ // One failed file used to stop the batch, and in local mode only the
31547
+ // LAST success was added to the list - uploading three images showed
31548
+ // one. Now every file is attempted, every success is kept, and the
31549
+ // failures are reported together at the end.
31550
+ var uploadedUrls = [];
31480
31551
 
31481
- function uploadNext(nextIndex) {
31482
- if (nextIndex >= files.length) {
31483
- if (serverMode) {
31484
- loadFolder(state.currentFolder, lastUploadedUrl);
31485
- return;
31486
- }
31552
+ function finish() {
31553
+ var lastUploadedUrl = uploadedUrls.length ? uploadedUrls[uploadedUrls.length - 1] : "";
31487
31554
 
31488
- // No server to re-list from, so fold the upload into the
31489
- // preset list directly and keep it selected.
31490
- if (lastUploadedUrl) {
31491
- var uploaded = normalizePreset(lastUploadedUrl);
31492
- if (uploaded) {
31493
- uploaded.source = "upload";
31494
- presetImages.unshift(uploaded);
31495
- config.galleryImages.unshift(lastUploadedUrl);
31496
- }
31497
- state.selectedUrl = lastUploadedUrl;
31555
+ if (serverMode) {
31556
+ pendingError = problems.join(" ");
31557
+ loadFolder(state.currentFolder, lastUploadedUrl || state.selectedUrl);
31558
+ return;
31559
+ }
31560
+
31561
+ // No server to re-list from, so fold the uploads into the preset
31562
+ // list directly, newest first, and select the last one.
31563
+ for (var u = 0; u < uploadedUrls.length; u++) {
31564
+ var uploaded = normalizePreset(uploadedUrls[u]);
31565
+ if (uploaded) {
31566
+ uploaded.source = "upload";
31567
+ presetImages.unshift(uploaded);
31568
+ config.galleryImages.unshift(uploadedUrls[u]);
31498
31569
  }
31499
- state.images = presetImages.slice(0);
31500
- state.loading = false;
31501
- render();
31570
+ }
31571
+ if (lastUploadedUrl) {
31572
+ state.selectedUrl = lastUploadedUrl;
31573
+ }
31574
+ state.images = presetImages.slice(0);
31575
+ state.loading = false;
31576
+ state.error = problems.join(" ");
31577
+ render();
31578
+ }
31579
+
31580
+ function uploadNext(nextIndex) {
31581
+ if (nextIndex >= files.length) {
31582
+ finish();
31502
31583
  return;
31503
31584
  }
31504
31585
 
31505
31586
  var file = files[nextIndex];
31587
+ var settled = false;
31506
31588
 
31507
- function done(url, errorCode) {
31508
- if (!url) {
31509
- state.error = errorCode || ("Upload failed for " + file.name + ".");
31510
- state.loading = false;
31511
- render();
31589
+ function done(url, errorText) {
31590
+ // a handler that calls back twice must not upload the rest twice
31591
+ if (settled) {
31512
31592
  return;
31513
31593
  }
31514
-
31515
- lastUploadedUrl = url;
31594
+ settled = true;
31595
+ if (url) {
31596
+ uploadedUrls.push(url);
31597
+ } else {
31598
+ problems.push(typeof errorText === "string" && errorText ? errorText : ("Upload failed for " + file.name + "."));
31599
+ }
31516
31600
  uploadNext(nextIndex + 1);
31517
31601
  }
31518
31602
 
31519
- if (uploadToFolder) {
31520
- window.richTextBoxUploadFile(file, function (url, errorCode) {
31521
- done(errorCode ? "" : url, errorCode ? ("Upload failed for " + file.name + ".") : "");
31522
- }, { folder: state.currentFolder }, nextIndex, files);
31523
- return;
31524
- }
31603
+ try {
31604
+ if (uploadToFolder) {
31605
+ window.richTextBoxUploadFile(file, function (url, errorCode) {
31606
+ done(errorCode ? "" : url, errorCode ? ("Upload failed for " + file.name + ".") : "");
31607
+ }, { folder: state.currentFolder }, nextIndex, files);
31608
+ return;
31609
+ }
31525
31610
 
31526
- window.rte_file_upload_handler(file, function (url, error) {
31527
- done(url, error);
31528
- }, nextIndex, files);
31611
+ window.rte_file_upload_handler(file, function (url, error) {
31612
+ done(url, error);
31613
+ }, nextIndex, files);
31614
+ } catch (ex) {
31615
+ // a handler that throws must not leave the dialog stuck on "Loading"
31616
+ done("", "Upload failed for " + file.name + ".");
31617
+ }
31529
31618
  }
31530
31619
 
31531
31620
  state.loading = true;
@@ -39806,13 +39895,35 @@ function RTE_Plugin_Sanitizer() {
39806
39895
  // sanitizerAllowedIframeHosts is still the stronger control.
39807
39896
  var DEFAULT_IFRAME_SANDBOX = "allow-scripts allow-same-origin allow-presentation";
39808
39897
 
39898
+ // 2026-09-21 Two holes in the default sandbox, both closed here:
39899
+ // - it was only ADDED when the iframe had none, so injected markup that
39900
+ // brought its own sandbox="allow-top-navigation allow-popups allow-forms"
39901
+ // kept every permission the comment above says is withheld. An existing
39902
+ // sandbox is now narrowed to the default set, never trusted.
39903
+ // - allow-scripts + allow-same-origin isolates nothing when the frame is
39904
+ // served from the page's OWN origin: the framed page can reach the parent
39905
+ // and remove its own sandbox. Same-origin frames lose allow-same-origin.
39906
+ function applyDefaultSandbox(el, src) {
39907
+ var allowed = DEFAULT_IFRAME_SANDBOX.split(" ");
39908
+ var sameOrigin = false;
39909
+ try { sameOrigin = new URL(src, location.href).origin === location.origin; } catch (e) {}
39910
+ if (sameOrigin) allowed = allowed.filter(function (t) { return t !== "allow-same-origin"; });
39911
+ if (el.hasAttribute("sandbox")) {
39912
+ // keep only what the frame asked for AND the default permits; an empty
39913
+ // sandbox (the strictest) stays empty
39914
+ var asked = (el.getAttribute("sandbox") || "").toLowerCase().split(/\s+/);
39915
+ allowed = allowed.filter(function (t) { return asked.indexOf(t) >= 0; });
39916
+ }
39917
+ el.setAttribute("sandbox", allowed.join(" "));
39918
+ }
39919
+
39809
39920
  function iframeAllowed(el) {
39810
39921
  if (config.sanitizerAllowIframes === false) return false;
39811
39922
  var src = el.getAttribute("src") || "";
39812
39923
  if (!/^https?:/i.test(src)) return false;
39813
39924
  var hosts = config.sanitizerAllowedIframeHosts;
39814
39925
  if (!hosts || !hosts.length) {
39815
- if (!el.hasAttribute("sandbox")) el.setAttribute("sandbox", DEFAULT_IFRAME_SANDBOX);
39926
+ applyDefaultSandbox(el, src);
39816
39927
  return true;
39817
39928
  }
39818
39929
  try {