diff --git a/public/js/app-util.min.js b/public/js/app-util.min.js
index dcee249fd..4221da305 100644
--- a/public/js/app-util.min.js
+++ b/public/js/app-util.min.js
@@ -13,7 +13,7 @@ checkLevel:function(b){a.info("password:checkPassLevel");this.config.passLength=
b?d():a.info("Notifications disabled")})}}};return{decodeEntities:function(a){return $("").html(a).text()},resizeImage:function(a){var b=.9*$(window).width(),d=.9*$(window).height(),e={width:a.width(),height:a.height()},g={calc:0,main:0,secondary:0,factor:.9,rel:e.width/e.height},h=function(a){a.main>a.secondary?a.calc=a.main/a.rel:a.maina.secondary&&(a.main*=a.factor,h(a));return a},k=function(){g.main=b;g.secondary=d;var c=h(g);a.css({width:c.main,
height:c.calc});e.width=c.main;e.height=c.calc},l=function(){g.main=d;g.secondary=b;var c=h(g);a.css({width:c.calc,height:c.main});e.width=c.calc;e.height=c.main};e.width>b?k():e.height>d&&l();return e},fileUpload:function(b){var c=function(a){var b=$("#fileUploadForm");!1===a&&b.hide();a=b.find("input[type='file']");a.on("change",function(){"function"===typeof e.beforeSendAction&&e.beforeSendAction();h(this.files)});return a},d={actionId:b.data("action-id"),itemId:b.data("item-id"),sk:sysPassApp.sk.get()},
e={requestDoneAction:"",setRequestData:function(a){$.extend(d,a)},getRequestData:function(){return d},beforeSendAction:"",url:"",allowedMime:[]},g=function(a){if(void 0===e.url||""===e.url)return!1;var b=new FormData;b.append("inFile",a);b.append("isAjax",1);d.sk=sysPassApp.sk.get();Object.keys(d).forEach(function(a){b.append(a,d[a])});a=sysPassApp.requests.getRequestOpts();a.url=e.url;a.processData=!1;a.contentType=!1;a.data=b;sysPassApp.requests.getActionCall(a,function(a){var b=a.status;a=a.description;
-0===b?("function"===typeof e.requestDoneAction&&e.requestDoneAction(),sysPassApp.msg.ok(a)):10===b?sysPassApp.appActions().main.logout():sysPassApp.msg.error(a)})},h=function(a){if(5sysPassApp.config.FILES.MAX_SIZE)sysPassApp.msg.error(sysPassApp.config.LANG[18]+"
"+c.name+" (Max: "+sysPassApp.config.FILES.MAX_SIZE+")");else{a:{var d=void 0;var f=c.type;if(""===f)d=!0;
-else{for(d in e.allowedMime)if(-1!==f.indexOf(e.allowedMime[d])){d=!0;break a}d=!1}}d?g(a[b]):sysPassApp.msg.error(sysPassApp.config.LANG[19]+"
"+c.type)}}},k=function(){a.info("fileUpload:init");var d=c(!1);b.on("dragover dragenter",function(b){a.info("fileUpload:drag");b.stopPropagation();b.preventDefault()});b.on("drop",function(b){a.info("fileUpload:drop");b.stopPropagation();b.preventDefault();"function"===typeof e.beforeSendAction&&e.beforeSendAction();h(b.originalEvent.dataTransfer.files)});
+0===b?("function"===typeof e.requestDoneAction&&e.requestDoneAction(),sysPassApp.msg.ok(a)):10===b?sysPassApp.appActions().main.logout():sysPassApp.msg.error(a)})},h=function(a){if(5sysPassApp.config.FILES.MAX_SIZE)sysPassApp.msg.error(sysPassApp.config.LANG[18]+"
"+$("").text(c.name).html()+" (Max: "+sysPassApp.config.FILES.MAX_SIZE+")");else{a:{var d=void 0;var f=c.type;if(""===f)d=!0;
+else{for(d in e.allowedMime)if(-1!==f.indexOf(e.allowedMime[d])){d=!0;break a}d=!1}}d?g(a[b]):sysPassApp.msg.error(sysPassApp.config.LANG[19]+"
"+$("").text(c.type).html())}}},k=function(){a.info("fileUpload:init");var d=c(!1);b.on("dragover dragenter",function(b){a.info("fileUpload:drag");b.stopPropagation();b.preventDefault()});b.on("drop",function(b){a.info("fileUpload:drop");b.stopPropagation();b.preventDefault();"function"===typeof e.beforeSendAction&&e.beforeSendAction();h(b.originalEvent.dataTransfer.files)});
b.on("click",function(){d.click()})};window.File&&window.FileList&&window.FileReader?k():c(!0);return e},scrollUp:function(){$("html, body").animate({scrollTop:0},"slow")},setContentSize:function(){var a=$("#container");a.hasClass("content-no-auto-resize")||a.css("height",$("#content").height()+200)},redirect:function(a){window.location.replace(a)},uniqueId:function(){var a=String.fromCharCode(Math.floor(25*Math.random()+65));do{var c=Math.floor(42*Math.random()+48);if(58>c||64
a.length);return a.toLowerCase()},getUrl:function(a,c){return a+"?"+Object.keys(c).map(function(a){return Array.isArray(c[a])?a+"="+c[a].join("/"):a+"="+c[a]}).join("&")},focus:function(b){a.debug("focus");b.find("input:not([id*=selectized]):visible:first").focus()},sendNotification:e.send,password:d,hash:{md5:function(a){return SparkMD5.hash(a,!1)}}}};
diff --git a/tests/Unit/Infrastructure/Adapter/In/Web/View/FileNamesReachToastsAsTextTest.php b/tests/Unit/Infrastructure/Adapter/In/Web/View/FileNamesReachToastsAsTextTest.php
new file mode 100644
index 000000000..f3498a1a5
--- /dev/null
+++ b/tests/Unit/Infrastructure/Adapter/In/Web/View/FileNamesReachToastsAsTextTest.php
@@ -0,0 +1,85 @@
+.
+ */
+
+namespace SP\Tests\Unit\Infrastructure\Adapter\In\Web\View;
+
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * A file the user chose reaches a toast as text, not markup.
+ *
+ * `toasts.min.js` renders every message with `innerHTML`, deliberately, because messages carry
+ * `
` separators. So whatever is concatenated into one has to be escaped first, and the file
+ * upload's two rejection messages — "too large" and "type not allowed" — concatenated the dropped
+ * `File`'s `name` and `type` raw. A file named `
` ran its handler.
+ *
+ * Worth being exact about the grade: it is the user's own local file, so on its own this is
+ * self-XSS, reachable only by persuading somebody to upload a crafted name. It is pinned anyway
+ * because the sink is real, the fix is one expression, and the next message to interpolate a
+ * value into a toast would inherit the same mistake.
+ *
+ * `$("").text(value).html()` sets the value as text and reads it back escaped.
+ */
+#[Group('unitary')]
+class FileNamesReachToastsAsTextTest extends TestCase
+{
+ private const FILE = REAL_APP_ROOT . '/public/js/app-util.min.js';
+
+ #[Test]
+ public function aRejectedFilesNameAndTypeAreEscapedBeforeTheToastRendersThem(): void
+ {
+ $source = (string)file_get_contents(self::FILE);
+
+ // Every place the upload code puts the chosen file's name or type into a toast message.
+ // Checked by what surrounds each occurrence rather than by matching the whole call: the
+ // escape itself contains parentheses, so a pattern that ends at the first `)` stops inside
+ // it and never sees the `.html()`.
+ preg_match_all(
+ '/sysPassApp\.msg\.\w+\([^;]*?(?\bc\.(?:name|type)\b)/',
+ $source,
+ $matches,
+ PREG_OFFSET_CAPTURE
+ );
+
+ self::assertNotEmpty(
+ $matches['value'],
+ 'the upload rejection messages were not found; this test is looking at the wrong thing'
+ );
+
+ foreach ($matches['value'] as [$value, $offset]) {
+ $before = substr($source, $offset - strlen('.text('), strlen('.text('));
+ $after = substr($source, $offset + strlen($value), strlen(').html()'));
+
+ self::assertSame(
+ ['.text(', ').html()'],
+ [$before, $after],
+ sprintf('%s reaches a toast unescaped, at byte %d', $value, $offset)
+ );
+ }
+ }
+}