CVE-2026-44669
Description
FACTION is a PenTesting Report Generation and Collaboration Framework. Prior to 1.8.3, Faction is vulnerable to stored cross-site scripting (XSS) via attachment filenames in assessment file preview flows. User-supplied filename values are persisted and later rendered into HTML/attribute contexts without output encoding, allowing attacker-controlled JavaScript to execute in the browser of any user who views the affected page. Because the payload is stored server-side and rendered to other users, exploitation is persistent and can impact privileged accounts. This vulnerability is fixed in 1.8.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Stored XSS in Faction < 1.8.3 allows attackers to inject arbitrary JavaScript via crafted attachment filenames, affecting all users viewing assessment file previews.
Vulnerability
Faction versions prior to 1.8.3 are vulnerable to stored cross-site scripting (XSS) via attachment filenames in the assessment file preview flow. The vulnerability resides in FileUploadManager.java where user-supplied filenames are stored without sanitization (line 214), then later reflected into JSON preview fields and HTML/attribute contexts without proper output encoding (lines 246-249). The fileinput.js template performs raw token substitution, allowing attacker-controlled JavaScript to execute [1]. Customers using any version before 1.8.3 are affected.
Exploitation
An attacker with the ability to upload files to an assessment (e.g., assessor or manager role) can craft a filename containing HTML or JavaScript, such as ``. The filename is persisted server-side and, when any user (including privileged accounts) views the assessment file preview page, the payload is rendered without encoding, executing in the victim's browser context [1]. No additional user interaction beyond viewing the page is required.
Impact
Successful exploitation leads to persistent stored XSS. An attacker can execute arbitrary JavaScript in the context of any authenticated user who views the affected page, including administrators. This can be used to steal session cookies, exfiltrate sensitive data, perform actions on behalf of the victim, or deface the application [1]. The impact is amplified because the payload affects all users of the system.
Mitigation
The vulnerability is fixed in Faction version 1.8.3, released on the same date as this advisory [2]. The fix introduces context-appropriate escaping for filenames (HTML attributes via escapeHtml4, JSON strings via escapeJson, URL parameters via URLEncoder) and server-side validation that rejects uploads containing HTML or script characters (whitelist: alphanumeric, spaces, dots, hyphens, underscores) [2]. All users should upgrade to 1.8.3 or later. No workarounds are provided for older versions.
AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: <1.8.3
Patches
1825cc330ad14Backlog release (#130)
10 files changed · +246 −20
src/com/fuse/actions/admin/CMS.java+38 −1 modified@@ -1,8 +1,10 @@ package com.fuse.actions.admin; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -66,6 +68,8 @@ public class CMS extends FSActionSupport { private String reportExtension; private List<List<String>> reportSections = new ArrayList<>(); private String reportSection; + private InputStream templateStream; + private String downloadFilename; @Action(value = "cms", results = { @Result(name = "templateUpload", location = "/WEB-INF/jsp/cms/TemplateUpload.jsp"), @@ -226,6 +230,32 @@ public String execute() throws IOException { return SUCCESS; } + @Action(value = "downloadTemplate", results = { + @Result( + name = "download", + type = "stream", + params = { + "inputName", "templateStream", + "contentType", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "bufferSize", "1024", + "contentDisposition", "attachment;filename=\"${downloadFilename}\"" + } + ) + }) + public String downloadTemplate() { + if (!(this.isAcadmin() || this.isAcengagement() || this.isAcassessor())) + return LOGIN; + if (id == null) return ERROR; + selectedTemplate = (ReportTemplates) em.createQuery("from ReportTemplates where id = :id") + .setParameter("id", id).getResultList().stream().findFirst().orElse(null); + if (selectedTemplate == null || !selectedTemplate.getSaveInDB() + || selectedTemplate.getBase64EncodedTemplate() == null) + return ERROR; + templateStream = selectedTemplate.getTemplate(); + downloadFilename = selectedTemplate.getFilename(); + return "download"; + } + @Action(value = "checkReportValues", results = { @Result( name="ok", type = "httpheader", params = { "status", "202"} ), @Result( name="none",type = "httpheader", params = { "status", "404"} ), @@ -572,6 +602,13 @@ public List<List<String>> getReportSections() { public void setReportSection(String reportSection) { this.reportSection = reportSection; } - + + public InputStream getTemplateStream() { + return templateStream; + } + + public String getDownloadFilename() { + return downloadFilename; + } }
src/com/fuse/actions/assessment/AssessmentView.java+98 −5 modified@@ -1,7 +1,15 @@ package com.fuse.actions.assessment; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; + +import org.apache.commons.codec.binary.Base64; + +import com.fuse.dao.FinalReport; +import com.fuse.dao.HibHelper; + import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; @@ -17,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import javax.mail.MessagingException; @@ -93,6 +102,9 @@ public class AssessmentView extends FSActionSupport { private List<Status> statuses = new ArrayList<>(); private Long status; private Boolean hasTemplate = false; + private File uploadReport; + private String uploadReportContentType; + private String uploadReportFilename; @Action(value = "Assessment", @@ -617,6 +629,80 @@ public String SendToPR() { } + @Action(value = "UploadFinalReport") + public String uploadReport() throws Exception { + if (!(this.isAcassessor() || this.isAcmanager())) + return LOGIN; + + User user = this.getSessionUser(); + Long asmtId = SessionAsmtId(); + + Assessment asmt; + if (this.isAcmanager()) { + asmt = AssessmentQueries.getAssessmentById(em, asmtId); + } else { + asmt = AssessmentQueries.getAssessmentByUserId(em, user.getId(), asmtId, AssessmentQueries.All); + } + + if (!this.testToken(false)) + return this.ERRORJSON; + + if (asmt == null || asmt.getCompleted() != null) { + this._message = "Assessment not found or already finalized."; + return this.ERRORJSON; + } + + if (!this.isAcmanager() && asmt.getAssessor().stream().noneMatch(u -> u.getId() == user.getId())) { + this._message = "You are not an assessor on this assessment."; + return this.ERRORJSON; + } + + if (uploadReport == null) { + this._message = "No file uploaded."; + return this.ERRORJSON; + } + + String ct = uploadReportContentType == null ? "" : uploadReportContentType.toLowerCase(); + String fn = uploadReportFilename == null ? "" : uploadReportFilename.toLowerCase(); + boolean isPdf = ct.contains("pdf") || fn.endsWith(".pdf"); + boolean isDocx = ct.contains("wordprocessingml") || fn.endsWith(".docx"); + + if (!isPdf && !isDocx) { + this._message = "Only .docx and .pdf files are allowed."; + return this.ERRORJSON; + } + + byte[] fileBytes = new byte[(int) uploadReport.length()]; + try (FileInputStream fis = new FileInputStream(uploadReport)) { + fis.read(fileBytes); + } + String b64 = Base64.encodeBase64String(fileBytes); + + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + + if (asmt.getFinalReport() == null) { + FinalReport fr = new FinalReport(); + fr.setRetest(false); + fr.setFilename(UUID.randomUUID().toString()); + fr.setBase64EncodedPdf(b64); + fr.setGentime(new Date()); + fr.setFileType(isPdf ? "pdf" : "docx"); + em.persist(fr); + asmt.setFinalReport(fr); + } else { + asmt.getFinalReport().setBase64EncodedPdf(b64); + asmt.getFinalReport().setFileType(isPdf ? "pdf" : "docx"); + asmt.getFinalReport().setGentime(new Date()); + } + + em.persist(asmt); + HibHelper.getInstance().commit(); + + AuditLog.audit(this, "Report uploaded for assessment " + asmt.getName(), AuditLog.CompAssessment, false); + return this.SUCCESSJSON; + } + private Map<String, String> jsonSuccessMessage; public Map<String, String> getJsonSuccessMessage() { @@ -960,10 +1046,17 @@ public List<Status> getStatuses(){ public void setStatus(Long status) { this.status = status; } - - - - - + + public void setUploadReport(File uploadReport) { + this.uploadReport = uploadReport; + } + + public void setUploadReportContentType(String uploadReportContentType) { + this.uploadReportContentType = uploadReportContentType; + } + + public void setUploadReportFilename(String uploadReportFilename) { + this.uploadReportFilename = uploadReportFilename; + } }
src/com/fuse/actions/assessment/BoilerPlateConfig.java+10 −1 modified@@ -60,6 +60,7 @@ public String searchTemplate() { @Action(value = "tempDelete") public String tempDelete() { + if (this.getSessionUser() == null) return LOGIN; BoilerPlate bp = (BoilerPlate) em.createQuery("from BoilerPlate where id = :id") .setParameter("id", this.tmpId).getResultList() .stream().findFirst().orElse(null); @@ -72,6 +73,7 @@ public String tempDelete() { } @Action(value = "tempActive") public String tempActive() { + if (this.getSessionUser() == null) return LOGIN; BoilerPlate bp = (BoilerPlate) em.createQuery("from BoilerPlate where id = :id") .setParameter("id", this.tmpId).getResultList() .stream().findFirst().orElse(null); @@ -87,7 +89,13 @@ public String tempActive() { @Action(value = "tempSearchDetail", results = { @Result(name = "tempSearchDetailJson", location = "/WEB-INF/jsp/assessment/tempSearchDetailJSON.jsp") }) public String searchTemplateDetail() { - BoilerPlate bp = em.find(BoilerPlate.class, this.tmpId); + if (this.getSessionUser() == null) return LOGIN; + BoilerPlate bp = (BoilerPlate) em + .createQuery("from BoilerPlate where id = :id and (global = true or user = :user)") + .setParameter("id", this.tmpId) + .setParameter("user", this.getSessionUser()) + .getResultList().stream().findFirst().orElse(null); + if (bp == null) return this.ERRORJSON; boilers = new ArrayList(); boilers.add(bp); @@ -96,6 +104,7 @@ public String searchTemplateDetail() { @Action(value = "globalSave", results = { @Result(name = "tempSearchJson", location = "/WEB-INF/jsp/assessment/tempSearchJSON.jsp") }) public String globalSaveTemplate() { + if (this.getSessionUser() == null) return LOGIN; BoilerPlate bp = (BoilerPlate) em .createQuery( "from BoilerPlate where id=:id and global=true")
src/com/fuse/servlets/fileUpload.java+18 −6 modified@@ -15,12 +15,15 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.Part; +import java.net.URLEncoder; + import org.apache.commons.fileupload.servlet.ServletFileUpload; import org.apache.commons.lang3.StringEscapeUtils; import com.fuse.dao.Files; import com.fuse.dao.HibHelper; import com.fuse.dao.User; +import com.fuse.utils.FSUtils; /** * Servlet implementation class fileUpload @@ -105,7 +108,12 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) Files f = new Files(); f.setUuid(uuid); f.setContentType(filePart.getContentType()); - f.setName(getFileName(filePart)); + String fileName = getFileName(filePart); + if (!isSafeFileName(fileName)) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid file name"); + return; + } + f.setName(fileName); byte[] bytes = new byte[(int) filePart.getSize()]; filePart.getInputStream().read(bytes); f.setRealFile(bytes); @@ -155,14 +163,14 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) + "' class='file-preview-image' />\"], "; } else if (f.getContentType().contains("text")) { // try { - json += "\"initialPreview\": [\"<pre class='file-preview-text' title='" + f.getName() + json += "\"initialPreview\": [\"<pre class='file-preview-text' title='" + StringEscapeUtils.escapeHtml4(f.getName()) + "' style='width:100%;height:158px;' >" + StringEscapeUtils.escapeJson(new String(f.getRealFile())) + "</pre>"; json += "\"], "; } else { json += "\"initialPreview\": [\"<object class='file-object' type='" + f.getContentType() - + "' height='160px' width='160px'>" + "<param name='movie' value='" + f.getName() + "'>" + + "' height='160px' width='160px'>" + "<param name='movie' value='" + StringEscapeUtils.escapeHtml4(f.getName()) + "'>" + "<param name='controller' value='true'>" + "<param name='allowFullScreen' value='true'>" + "<param name='allowScriptAccess' value='always'>" + "<param name='autoPlay' value='false'>" + "<param name='autoStart' value='false'>" + "<param name='quality' value='high'>" @@ -175,12 +183,12 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) json += "\"], "; } if (apid != null && !apid.equals("")) { - json += "\"initialPreviewConfig\" : [" + "{ " + "\"caption\": \"" + f.getName() + "\", " + json += "\"initialPreviewConfig\" : [" + "{ " + "\"caption\": \"" + StringEscapeUtils.escapeJson(f.getName()) + "\", " + "\"width\" : \"100px\", " + "\"url\" : \"../service/fileUpload?delid=" + f.getUuid() - + "&apid=" + f.getEntityId() + "&name=" + f.getName() + "\", " + + "&apid=" + f.getEntityId() + "&name=" + URLEncoder.encode(f.getName(), "UTF-8") + "\", " + "\"downloadUrl\": \"../service/fileUpload?id=" + f.getUuid() + "\",\"key\" : 1}" + "]" + "}"; } else { - json += "\"initialPreviewConfig\" : [{ \"caption\": \"" + f.getName() + json += "\"initialPreviewConfig\" : [{ \"caption\": \"" + StringEscapeUtils.escapeJson(f.getName()) + "\", \"width\" : \"100px\", \"url\" : \"../service/fileUpload?name=" + uuid + "\", " + "\"downloadUrl\": \"../service/fileUpload?id=" + f.getUuid() + "\",\"key\" : 1}]}"; } @@ -242,6 +250,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } + private boolean isSafeFileName(String name) { + return name != null && !name.isEmpty() && name.matches("[\\w\\s.\\-]+"); + } + private String getFileName(Part p) { String header = p.getHeader("Content-Disposition"); String[] headers = header.split(";");
src/com/fuse/tasks/ReportGenThread.java+2 −3 modified@@ -33,14 +33,13 @@ public class ReportGenThread implements Runnable{ public ReportGenThread(String host, Assessment asmt, List<User> notifiers, boolean retest){ this.host = host; this.asmt = asmt; - this.notifiers = notifiers; + this.notifiers = new ArrayList<>(notifiers); this.isRetest=retest; } public ReportGenThread(String host, Assessment asmt, List<User> notifiers){ this.host = host; this.asmt = asmt; - this.notifiers = notifiers; - + this.notifiers = new ArrayList<>(notifiers); } public ReportGenThread(String host, Assessment asmt, User notifiers){ this.host = host;
src/com/fuse/utils/AccessControlInterceptor.java+8 −2 modified@@ -19,14 +19,20 @@ public String intercept(ActionInvocation invocation) throws Exception { User user = (User) ServletActionContext.getRequest().getSession(true).getAttribute("user"); - if(user != null){ + if (user != null) { ActionContext.getContext().put("user", user); ActionContext.getContext().put("isAdmin", user.getPermissions().isAdmin()); ActionContext.getContext().put("isManager", user.getPermissions().isManager()); ActionContext.getContext().put("isAssessor", user.getPermissions().isAssessor()); ActionContext.getContext().put("isEngagement", user.getPermissions().isEngagement()); ActionContext.getContext().put("isRemediation", user.getPermissions().isRemediation()); - + } else { + // Allow unauthenticated access only to the root namespace (login, reset, setup). + // All other namespaces require an active session. + String namespace = invocation.getProxy().getNamespace(); + if (!"/".equals(namespace)) { + return "login"; + } } return invocation.invoke(); }
WebContent/dist/js/overview.js+1 −1 modifiedWebContent/src/assessment/overview.js+41 −0 modified@@ -405,6 +405,47 @@ $(function() { }); }); + $("#uploadReportBtn").click(function() { + $('#uploadReportError').hide(); + $('#uploadReportFile').val(''); + $('#uploadReportModal').modal('show'); + }); + + $("#doUploadReport").click(function() { + var file = $('#uploadReportFile')[0].files[0]; + if (!file) { + $('#uploadReportError').text('Please select a file.').show(); + return; + } + var ext = file.name.split('.').pop().toLowerCase(); + if (ext !== 'docx' && ext !== 'pdf') { + $('#uploadReportError').text('Only .docx and .pdf files are allowed.').show(); + return; + } + var formData = new FormData(); + formData.append('uploadReport', file); + formData.append('_token', global._token); + $('#doUploadReport').prop('disabled', true).text('Uploading...'); + $.ajax({ + url: 'UploadFinalReport', + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(resp) { + global._token = resp.token; + $('#uploadReportModal').modal('hide'); + location.reload(); + }, + error: function(xhr) { + var msg = 'Upload failed.'; + try { msg = JSON.parse(xhr.responseText).message || msg; } catch(e) {} + $('#uploadReportError').text(msg).show(); + $('#doUploadReport').prop('disabled', false).text('Upload'); + } + }); + }); + $("#finalize").click(function() { $(".content").loading({ overlay: true, base: 0.3 }); $.confirm({
WebContent/WEB-INF/jsp/assessment/Finalize.jsp+29 −0 modified@@ -61,6 +61,9 @@ </span> </div> </s:if> + <div style="margin-top:10px;"> + <bs:button color="info" size="md" colsize="3" text="Upload Report" id="uploadReportBtn"></bs:button> + </div> </div> </li> @@ -154,3 +157,29 @@ </bs:mco> </bs:row> +<!-- Upload Report Modal --> +<div class="modal fade" id="uploadReportModal" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content" style="background-color:#192338; color:#fff;"> + <div class="modal-header" style="border-bottom-color:#0f1a2b;"> + <button type="button" class="close" style="color:#fff; opacity:0.8;" data-dismiss="modal">×</button> + <h4 class="modal-title">Upload Report</h4> + </div> + <div class="modal-body"> + <p>Upload a <strong>.docx</strong> or <strong>.pdf</strong> to replace the current report.</p> + <form id="uploadReportForm" enctype="multipart/form-data"> + <div class="form-group"> + <input type="file" name="uploadReport" id="uploadReportFile" accept=".docx,.pdf" required="" class="form-control" style="background-color: #192338;color:#fff;border-color:#0f1a2b;"> + </div> + <div id="uploadReportError" class="alert alert-danger" style="display:none;"></div> + </form> + </div> + <div class="modal-footer" style="border-top-color:#0f1a2b;"> + <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-primary" id="doUploadReport">Upload</button> + </div> + </div> + </div> +</div> + +
WebContent/WEB-INF/jsp/cms/TemplateUpload.jsp+1 −1 modified@@ -71,7 +71,7 @@ <bs:mco colsize="12"> <br> - <span><i> Currently Uploaded to : <br>${selectedTemplate.filename }</i></span> + <span><i> Download Template: <br><s:if test="selectedTemplate.filename != null && !selectedTemplate.filename.isEmpty() && selectedTemplate.saveInDB"><a href="downloadTemplate?id=${selectedTemplate.id}">${selectedTemplate.filename}</a></s:if><s:else>${selectedTemplate.filename}</s:else></i></span> </bs:mco> </form>
Vulnerability mechanics
Root cause
"Untrusted attachment filenames are stored without validation and later concatenated into HTML/JSON preview strings without output encoding, enabling stored XSS."
Attack vector
An attacker who can upload an attachment to an assessment (requires a valid user session assigned to that assessment) modifies the filename parameter in the upload request to include a JavaScript payload, such as `">
Affected code
The vulnerability exists in the attachment upload and preview pipeline. In `src/com/fuse/actions/FileUploadManager.java` (lines 211-215), the untrusted `file_dataFileName` is stored directly into the `Files` entity without validation or encoding. The same file's `getName()` value is later concatenated into JSON preview strings without context-safe escaping (lines 246-254). The client-side `fileinput.js` template engine performs raw token substitution, injecting the filename into HTML/attribute contexts such as `title="{caption}"` and `alt="{caption}"` without sanitization [ref_id=1].
What the fix does
The patch applies output encoding in `fileUpload.java` by wrapping `f.getName()` with `StringEscapeUtils.escapeHtml4()` when the filename is placed into HTML attributes and with `StringEscapeUtils.escapeJson()` when placed into JSON string values. It also adds a `isSafeFileName()` validation that rejects filenames containing characters outside `[\w\s.\-]`, blocking the attack at the upload boundary. These changes ensure that even if a malicious filename is persisted, it cannot break out of its HTML or JSON context when rendered in the preview component [patch_id=2566845].
Preconditions
- authAttacker must have a valid user session assigned to an assessment
- inputAttacker must be able to upload a file attachment to the assessment
- inputVictim user (potentially a manager or admin) must view the assessment page that renders the attachment preview
Reproduction
1. Clone and build Faction from the official repository using `mvn clean package -DskipTests && docker compose -f docker-compose-dev.yml build`. 2. Start the container with `docker compose -f docker-compose-dev.yml up`. 3. Log in as a low-privilege user assigned to an assessment. 4. Navigate to Assessments, select an assessment, and scroll to the "Engagement Info" section. 5. Click "Browse", choose any file, and intercept the upload request. 6. Modify the filename to `">
Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.