CVE-2026-44667
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 remediation verification file preview flows. User-supplied filename values are persisted and then rendered into HTML and attribute contexts without output encoding, allowing attacker-controlled JavaScript to execute in the browser of any user who opens the affected verification/remediation views. 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's remediation verification attachment filename preview allows persistent JavaScript execution, fixed in 1.8.3.
Vulnerability
Faction versions prior to 1.8.3 are vulnerable to stored cross-site scripting (XSS) via attachment filenames in the remediation verification file preview flow. The vulnerability exists because user-supplied filenames are persisted without output encoding and later rendered into HTML and JSON contexts in fileUpload.java (lines 178-185) and RemVulnData.java (lines 187-189, 204-206). The JSP fileInfoJson.jsp explicitly disables HTML escaping with escapeHtml="false", allowing attacker-controlled JavaScript to execute in the browser of any user who opens the affected verification or remediation views [1].
Exploitation
An attacker with the ability to upload files to a remediation verification flow can inject a malicious filename containing JavaScript payloads. The filename is stored server-side and later rendered to other users when they view the verification or remediation details. No additional user interaction beyond opening the affected view is required. Because the payload is stored and served to multiple users, exploitation is persistent and can target privileged accounts [1].
Impact
Successful exploitation results in stored XSS, allowing the attacker to execute arbitrary JavaScript in the context of the victim's session. This can lead to session hijacking, data exfiltration, or unauthorized actions performed on behalf of the victim. Since the vulnerability can impact privileged accounts, the scope of compromise is elevated [1].
Mitigation
The vulnerability is fixed in Faction version 1.8.3 [2]. The fix introduces context-appropriate escaping (HTML attributes via escapeHtml4, JSON strings via escapeJson, URL parameters via URLEncoder) and server-side filename validation that rejects uploads containing HTML or script characters, allowing only alphanumeric characters, spaces, dots, hyphens, and underscores [2]. Users should upgrade to version 1.8.3 or later. No workarounds are documented.
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
2825cc330ad14Backlog 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>
90b341744fb6[maven-release-plugin] prepare release 1.8.3
1 file changed · +2 −2
pom.xml+2 −2 modified@@ -2,15 +2,15 @@ <modelVersion>4.0.0</modelVersion> <groupId>org.faction</groupId> <artifactId>faction</artifactId> - <version>1.8.3-SNAPSHOT</version> + <version>1.8.3</version> <packaging>war</packaging> <name>Faction</name> <scm> <url>https://github.com/factionsecurity/faction.git</url> <connection>scm:git:https://github.com/factionsecurity/faction.git</connection> <developerConnection> scm:git:https://github.com/factionsecurity/faction.git</developerConnection> - <tag>1.7.5</tag> + <tag>1.8.3</tag> </scm> <properties> <aws.java.sdk.version>2.18.16</aws.java.sdk.version>
Vulnerability mechanics
Root cause
"Untrusted attachment filenames are persisted without output encoding and later rendered into HTML and JSON attribute contexts in the remediation verification file preview pipeline, enabling stored cross-site scripting."
Attack vector
An attacker with remediation privilege uploads a file via the `/service/fileUpload` endpoint and intercepts the request to replace the filename with a JavaScript payload such as `"><img src=x onerror=alert(document.domain)>` [ref_id=1]. The malicious filename is persisted server-side and later rendered without HTML escaping in the remediation verification preview views (`/portal/RemVulnData?action=getFiles&vulnId=<id>` and `/portal/VerificationEdit?...`) [ref_id=1]. When a privileged user (e.g., an admin or manager) opens the affected verification/remediation view, the payload executes in their browser session, enabling persistent XSS that can impact privileged accounts [ref_id=1].
Affected code
The primary vulnerable code is in `src/com/fuse/servlets/fileUpload.java` at lines 178-185, where `f.getName()` is concatenated into JSON preview strings without encoding [patch_id=2566847][ref_id=1]. The remediation metadata endpoint `src/com/fuse/actions/remediation/RemVulnData.java` lines 187-189 and 204-206 also build preview HTML/JSON using raw filenames [ref_id=1]. The JSP at `WebContent/WEB-INF/jsp/remediation/fileInfoJson.jsp` renders the JSON with `escapeHtml="false"`, and `WebContent/WEB-INF/jsp/remediation/VerificationEdit.jsp` injects filenames into preview markup via `${name}` without escaping [ref_id=1].
What the fix does
The patch applies `StringEscapeUtils.escapeHtml4()` and `StringEscapeUtils.escapeJson()` to `f.getName()` in `fileUpload.java` lines 178-185 where filenames are interpolated into preview HTML and JSON [patch_id=2566847]. It also adds a `isSafeFileName()` validation method that restricts filenames to alphanumeric characters, spaces, dots, and hyphens via the regex `[\\w\\s.\\-]+`, rejecting any filename that does not match [patch_id=2566847]. Additionally, the `downloadTemplate` action in `CMS.java` now uses Struts property interpolation (`${downloadFilename}`) with proper content-disposition handling rather than raw filename concatenation [patch_id=2566847].
Preconditions
- authAttacker must have a valid account with remediation privilege on the Faction platform.
- inputAttacker must be able to upload a file to the verification attachment endpoint and modify the filename in transit.
- configA privileged user (admin, manager, or another assessor) must view the affected verification/remediation page where the malicious filename is rendered.
Reproduction
1. Clone Faction, build with `mvn clean package -DskipTests && docker compose -f docker-compose-dev.yml build`, and run with `docker compose -f docker-compose-dev.yml up` [ref_id=1]. 2. Log in as a user with remediation privilege, navigate to Remediation > Search, click search, click a vulnerability, and assign it to another user [ref_id=1]. 3. Log in as the assigned user, navigate to Remediation > Queue, click the vulnerability, click "Browse" in "Assessment Files", choose a file to upload, intercept the request, and change the filename to `"><img src=x onerror=alert(document.domain)>` [ref_id=1]. 4. Forward the request, click "Save Verification", then log out and log in as an admin [ref_id=1]. 5. Navigate to Remediation > Search and click the vulnerability with the XSS payload; observe the alert popup [ref_id=1].
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.