Formwork CMS Has a Stored Cross-Site Scripting (XSS) Vulnerability in Blog Tags
Description
Formwork is a flat file-based Content Management System (CMS). Prior to version 2.2.0, inserting unsanitized data into the blog tag field results in stored cross‑site scripting (XSS). Any user with credentials to the Formwork CMS who accesses or edits an affected blog post will have attacker‑controlled script executed in their browser. The issue is persistent and impacts privileged administrative workflows. This issue has been patched in version 2.2.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Stored XSS in Formwork CMS through unsanitized blog tag fields allows persistent script execution for authenticated users, fixed in v2.2.0.
Vulnerability
Overview CVE-2025-65956 is a stored cross-site scripting (XSS) vulnerability in Formwork, a flat-file CMS. The root cause is the use of innerHTML to insert untrusted data into the Document Object Model (DOM) without sanitization, specifically in the blog tag field and other input components. The official description indicates that unsanitized data inserted into the blog tag field results in stored XSS [2]. The advisory notes that the fix introduces an escapeHtml function and replaces many innerHTML assignments with innerText to prevent HTML injection [3]. The commit shown in Reference [1] demonstrates the change from innerHTML to innerText for a filename display and the addition of escapeHtml for search input handling, confirming the sanitization approach [1].
Attack
Vector and Prerequisites An attacker with valid credentials to the Formwork administration panel can store malicious JavaScript in the blog tag field. Because the input is not sanitized, the payload persists on the server. Any authenticated user who subsequently views or edits the affected blog post will have the attacker-controlled script executed in their browser. The vulnerability impacts privileged administrative workflows [2]. No special network position or additional user interaction beyond accessing the blog post is required for exploitation, as the script executes automatically upon rendering the tags.
Impact and
Mitigation Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of the victim's session within the Formwork admin panel. This can lead to theft of session cookies, defacement of the admin interface, or performance of administrative actions on behalf of the victim, potentially compromising the entire CMS installation. The issue has been patched in Formwork version 2.2.0 [2]. Administrators are strongly advised to upgrade to this patched version immediately. The commit and pull request provide the full remediation details [1][3].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
getformwork/formworkPackagist | < 2.2.0 | 2.2.0 |
Affected products
2- getformwork/formworkv5Range: < 2.2.0
Patches
14abcd60ae769Merge pull request #791 from getformwork/fix/escape-html-js
11 files changed · +167 −192
panel/src/ts/components/fileslist.ts+7 −7 modified@@ -1,5 +1,5 @@ import { $, $$ } from "../utils/selectors"; -import { escapeRegExp, makeDiacriticsRegExp } from "../utils/validation"; +import { escapeHtml, escapeRegExp, makeDiacriticsRegExp } from "../utils/validation"; import { app } from "../app"; import { debounce } from "../utils/events"; import { Form } from "./form"; @@ -35,7 +35,7 @@ export class FilesList { private initFileList() { const toggle = $(".form-togglegroup.files-list-view-as", this.element); - const searchInput = $(".files-search", this.element); + const searchInput = $(".files-search", this.element) as HTMLInputElement; if (toggle) { const formName = this.element.closest("form")?.dataset.form; @@ -120,21 +120,21 @@ export class FilesList { }); if (searchInput) { - const handleSearch = (event: Event) => { - const value = (event.target as HTMLInputElement).value; + const handleSearch = () => { + const value = escapeHtml(searchInput.value); ($(".files-item") as HTMLElement).classList.toggle("is-filtered", value.length > 0); $$(".files-item").forEach((element) => { let matches = 0; - for (const selector of [".file-name", ".file-parent-title"]) { + for (const selector of [".file-name a", ".file-parent-title"]) { const item = $(selector, element) as HTMLElement; if (!item) { continue; } - const text = item.textContent as string; + const text = escapeHtml(item.textContent); const regexp = value ? new RegExp(`${makeDiacriticsRegExp(escapeRegExp(value))}`, "gi") : null; @@ -211,7 +211,7 @@ export class FilesList { (item as HTMLElement).dataset.filename = response.data.filename; const anchor = $(".file-name a", item as HTMLElement) as HTMLAnchorElement; - anchor.innerHTML = response.data.filename; + anchor.innerText = response.data.filename; anchor.href = response.data.uri; ($("[data-command=infoFile]", item as HTMLElement) as HTMLAnchorElement).href = response.data.actions.info;
panel/src/ts/components/inputs/color-input.ts+1 −1 modified@@ -30,7 +30,7 @@ export class ColorInput { if (outputElement) { const updateValueLabel = (element: HTMLInputElement) => { - outputElement.innerHTML = element.value; + outputElement.innerText = element.value; }; this.element.addEventListener("change", () => updateValueLabel(this.element));
panel/src/ts/components/inputs/date-input.ts+3 −3 modified@@ -591,9 +591,9 @@ class Calendar { ($(".calendar-table", this.element) as HTMLElement).innerHTML = this.getInnerHTML(); if (this.input.options.time) { - ($(".calendar-hours", this.element) as HTMLElement).innerHTML = `${this.has12HourFormat(this.input.format) ? mod(this.hours, 12) || 12 : this.hours}`.padStart(2, "0"); - ($(".calendar-minutes", this.element) as HTMLElement).innerHTML = `${this.minutes}`.padStart(2, "0"); - ($(".calendar-meridiem", this.element) as HTMLElement).innerHTML = this.has12HourFormat(this.input.format) ? (this.hours < 12 ? "AM" : "PM") : ""; + ($(".calendar-hours", this.element) as HTMLElement).innerText = `${this.has12HourFormat(this.input.format) ? mod(this.hours, 12) || 12 : this.hours}`.padStart(2, "0"); + ($(".calendar-minutes", this.element) as HTMLElement).innerText = `${this.minutes}`.padStart(2, "0"); + ($(".calendar-meridiem", this.element) as HTMLElement).innerText = this.has12HourFormat(this.input.format) ? (this.hours < 12 ? "AM" : "PM") : ""; } $$(".calendar-day", this.element).forEach((element) => {
panel/src/ts/components/inputs/duration-input.ts+1 −1 modified@@ -129,7 +129,7 @@ export class DurationInput { private updateLabels() { Object.entries(this.innerInputs).forEach(([i, input]: [TimeInterval, HTMLInputElement]) => { - (this.labels[i] as HTMLLabelElement).innerHTML = this.options.labels[i][parseInt(input.value) === 1 ? 0 : 1]; + (this.labels[i] as HTMLLabelElement).innerText = this.options.labels[i][parseInt(input.value) === 1 ? 0 : 1]; }); }
panel/src/ts/components/inputs/range-input.ts+1 −1 modified@@ -30,7 +30,7 @@ export class RangeInput { element.style.setProperty("--progress", `${Math.round((parseInt(element.value) / (parseInt(element.max) - parseInt(element.min))) * 100)}%`); const outputElement = $(`output[for="${element.id}"]`); if (outputElement) { - outputElement.innerHTML = element.value; + outputElement.innerText = element.value; } };
panel/src/ts/components/inputs/tags-input.ts+3 −3 modified@@ -63,7 +63,7 @@ export class TagsInput { set value(value: string) { this.element.value = value; this.tags = value.split(", ").map((tag) => tag.trim()); - this.list.innerHTML = ""; + this.list.innerText = ""; this.tags.forEach((tag) => this.insertTag(tag, this.list)); this.updatePlaceholder(); this.updateDropdown(); @@ -170,7 +170,7 @@ export class TagsInput { const item = document.createElement("div"); item.className = "dropdown-item"; - item.innerHTML = option.label; + item.innerText = option.label; item.dataset.value = option.value; if (option.thumb) { @@ -415,7 +415,7 @@ export class TagsInput { const tag = document.createElement("span"); const tagRemove = document.createElement("i"); tag.className = "tag"; - tag.innerHTML = value; + tag.innerText = value; tag.style.marginRight = ".25rem"; parent.appendChild(tag);
panel/src/ts/components/inputs/upload-input.ts+2 −1 modified@@ -7,6 +7,7 @@ import { Notification } from "../notification"; import { Request } from "../../utils/request"; import { SelectInput } from "./select-input"; import { TagsInput } from "./tags-input"; +import { escapeHtml } from "../../utils/validation"; export class UploadInput { readonly element: HTMLInputElement; @@ -186,7 +187,7 @@ export class UploadInput { if (this.element.files && this.element.files.length > 0) { const filenames: string[] = []; for (const file of Array.from(this.element.files)) { - filenames.push(`${file.name} <span class="file-size-inline">(${this.formatFileSize(file.size)})</span>`); + filenames.push(`${escapeHtml(file.name)} <span class="file-size-inline">(${this.formatFileSize(file.size)})</span>`); } this.dropTargetLabel.innerHTML = filenames.join(", ");
panel/src/ts/components/views/backups.ts+5 −5 modified@@ -22,7 +22,7 @@ export class Backups { } spinner.className = "spinner"; - spinner.innerHTML = ""; + spinner.innerText = ""; return spinner; }; @@ -51,13 +51,13 @@ export class Backups { const node = template.content.cloneNode(true) as HTMLElement; ($(".backup-uri", node) as HTMLAnchorElement).href = response.data.uri; - ($(".backup-uri", node) as HTMLElement).innerHTML = response.data.filename; + ($(".backup-uri", node) as HTMLElement).innerText = response.data.filename; - ($(".backup-date", node) as HTMLElement).innerHTML = response.data.date; - ($(".backup-size", node) as HTMLElement).innerHTML = response.data.size; + ($(".backup-date", node) as HTMLElement).innerText = response.data.date; + ($(".backup-size", node) as HTMLElement).innerText = response.data.size; ($(".backup-delete", node) as HTMLElement).dataset.modalAction = response.data.deleteUri; - ($(".backup-last-time") as HTMLElement).innerHTML = app.config.Backups.labels.now; + ($(".backup-last-time") as HTMLElement).innerText = app.config.Backups.labels.now; ($("tbody", table) as HTMLElement).prepend(node);
panel/src/ts/components/views/pages.ts+6 −6 modified@@ -1,5 +1,5 @@ import { $, $$ } from "../../utils/selectors"; -import { escapeRegExp, makeDiacriticsRegExp, makeSlug } from "../../utils/validation"; +import { escapeHtml, escapeRegExp, makeDiacriticsRegExp, makeSlug } from "../../utils/validation"; import { app } from "../../app"; import { debounce } from "../../utils/events"; import { Form } from "../form"; @@ -15,7 +15,7 @@ export class Pages { const commandReorderPages = $("[data-command=reorder-pages]") as HTMLButtonElement; const commandPreview = $("[data-command=preview]") as HTMLButtonElement; - const searchInput = $(".page-search"); + const searchInput = $(".page-search") as HTMLInputElement; const newPageModal = app.modals["newPageModal"]; const deletePageItemModal = app.modals["deletePageItemModal"]; @@ -73,14 +73,14 @@ export class Pages { }); }); - const handleSearch = (event: Event) => { - const value = (event.target as HTMLInputElement).value; + const handleSearch = () => { + const value = escapeHtml(searchInput.value); if (value.length === 0) { ($(".pages-tree-root") as HTMLElement).classList.remove("is-filtered"); $$(".pages-tree-item").forEach((element) => { const title = $(".page-title a", element) as HTMLElement; - title.innerHTML = title.textContent as string; + title.innerText = title.textContent; ($(".pages-tree-row", element) as HTMLElement).style.display = ""; element.classList.toggle("is-expanded", element.dataset.expanded === "true"); }); @@ -91,7 +91,7 @@ export class Pages { $$(".pages-tree-item").forEach((element) => { const title = $(".page-title a", element) as HTMLElement; - const text = title.textContent as string; + const text = escapeHtml(title.textContent); const pagesItem = $(".pages-tree-row", element) as HTMLElement; if (text.match(regexp) !== null) {
panel/src/ts/components/views/updates.ts+5 −5 modified@@ -20,7 +20,7 @@ export class Updates { const showNewVersion = (name: string) => { spinner.classList.add("spinner-info"); insertIcon("info", spinner); - newVersionName.innerHTML = name; + newVersionName.innerText = name; newVersion.style.display = "block"; }; @@ -33,7 +33,7 @@ export class Updates { const showInstalledVersion = () => { spinner.classList.add("spinner-success"); insertIcon("check", spinner); - currentVersionName.innerHTML = newVersionName.innerHTML; + currentVersionName.innerText = newVersionName.innerText; currentVersion.style.display = "block"; }; @@ -47,7 +47,7 @@ export class Updates { data: data, }, (response) => { - updateStatus.innerHTML = response.message; + updateStatus.innerText = response.message; if (response.status === "success") { if (response.data.uptodate === false) { @@ -67,7 +67,7 @@ export class Updates { newVersion.style.display = "none"; spinner.classList.remove("spinner-info"); $(".icon", spinner)?.remove(); - updateStatus.innerHTML = updateStatus.dataset.installingText as string; + updateStatus.innerText = updateStatus.dataset.installingText as string; new Request( { @@ -79,7 +79,7 @@ export class Updates { const notification = new Notification(response.message, response.status, { icon: "check-circle" }); notification.show(); - updateStatus.innerHTML = response.data.status; + updateStatus.innerText = response.data.status; if (response.status === "success") { showInstalledVersion();
panel/src/ts/utils/validation.ts+133 −159 modified@@ -1,175 +1,149 @@ +const slugMap: Record<string, string> = { + "'": "-", + "’": "-", + "‘": "-", + '"': "-", + "“": "-", + "”": "-", + "-": "-", + "–": "-", + "—": "-", + "/": "-", + "\\": "-", + _: "-", + "~": "-", + À: "A", + Á: "A", + Â: "A", + Ã: "A", + Ä: "A", + Å: "A", + Æ: "Ae", + Ç: "C", + Ð: "D", + È: "E", + É: "E", + Ê: "E", + Ë: "E", + Ì: "I", + Í: "I", + Î: "I", + Ï: "I", + Ñ: "N", + Ò: "O", + Ó: "O", + Ô: "O", + Õ: "O", + Ö: "O", + Ø: "O", + Œ: "Oe", + Š: "S", + Þ: "Th", + Ù: "U", + Ú: "U", + Û: "U", + Ü: "U", + Ý: "Y", + à: "a", + á: "a", + â: "a", + ã: "a", + ä: "ae", + å: "a", + æ: "ae", + "¢": "c", + ç: "c", + ð: "d", + è: "e", + é: "e", + ê: "e", + ë: "e", + ì: "i", + í: "i", + î: "i", + ï: "i", + ñ: "n", + ò: "o", + ó: "o", + ô: "o", + õ: "o", + ö: "oe", + ø: "o", + œ: "oe", + š: "s", + ß: "ss", + þ: "th", + ù: "u", + ú: "u", + û: "u", + ü: "ue", + ý: "y", + ÿ: "y", + Ÿ: "y", +}; + +const diacriticsMap: Record<string, string> = { + a: "[aáàăâǎåäãȧąāảȁạ]", + b: "[bḃḅ]", + c: "[cćĉčċç]", + d: "[dďḋḑḍ]", + e: "[eéèĕêěëẽėȩęēẻȅẹ]", + g: "[gǵğĝǧġģḡ]", + h: "[hĥȟḧḣḩḥ]", + i: "[iiíìĭîǐïĩįīỉȉịı]", + j: "[jĵǰ]", + k: "[kḱǩķḳ]", + l: "[lĺľļḷ]", + m: "[mḿṁṃ]", + n: "[nńǹňñṅņṇ]", + o: "[oóòŏôǒöőõȯǿǫōỏȍơọ]", + p: "[pṕṗ]", + r: "[rŕřṙŗȑṛ]", + s: "[sśŝšṡşṣș]", + t: "[tťẗṫţṭț]", + u: "[uúùŭûǔůüűũųūủȕưụ]", + v: "[vṽṿ]", + w: "[wẃẁŵẘẅẇẉ]", + x: "[xẍẋ]", + y: "[yýỳŷẙÿỹẏȳỷỵ]", + z: "[zźẑžżẓ]", +}; + +const htmlEntityMap: Record<string, string> = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", + "`": "`", + "=": "=", +}; + export function escapeRegExp(string: string) { return string.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); } export function makeDiacriticsRegExp(string: string) { - const diacritics: Record<string, string> = { - a: "[aáàăâǎåäãȧąāảȁạ]", - b: "[bḃḅ]", - c: "[cćĉčċç]", - d: "[dďḋḑḍ]", - e: "[eéèĕêěëẽėȩęēẻȅẹ]", - g: "[gǵğĝǧġģḡ]", - h: "[hĥȟḧḣḩḥ]", - i: "[iiíìĭîǐïĩįīỉȉịı]", - j: "[jĵǰ]", - k: "[kḱǩķḳ]", - l: "[lĺľļḷ]", - m: "[mḿṁṃ]", - n: "[nńǹňñṅņṇ]", - o: "[oóòŏôǒöőõȯǿǫōỏȍơọ]", - p: "[pṕṗ]", - r: "[rŕřṙŗȑṛ]", - s: "[sśŝšṡşṣș]", - t: "[tťẗṫţṭț]", - u: "[uúùŭûǔůüűũųūủȕưụ]", - v: "[vṽṿ]", - w: "[wẃẁŵẘẅẇẉ]", - x: "[xẍẋ]", - y: "[yýỳŷẙÿỹẏȳỷỵ]", - z: "[zźẑžżẓ]", - }; - for (const char in diacritics) { - string = string.split(char).join(diacritics[char]); - string = string.split(char.toUpperCase()).join(diacritics[char].toUpperCase()); - } - return string; + return string.replace(/./g, (match) => diacriticsMap[match] || diacriticsMap[match.toLowerCase()]?.toUpperCase() || match); } export function makeSlug(string: string) { - const translate: Record<string, string> = { - "\t": "", - "\r": "", - "!": "", - '"': "", - "#": "", - $: "", - "%": "", - "'": "-", - "(": "", - ")": "", - "*": "", - "+": "", - ",": "", - ".": "", - ":": "", - ";": "", - "<": "", - "=": "", - ">": "", - "?": "", - "@": "", - "[": "", - "]": "", - "^": "", - "`": "", - "{": "", - "|": "", - "}": "", - "¡": "", - "£": "", - "¤": "", - "¥": "", - "¦": "", - "§": "", - "«": "", - "°": "", - "»": "", - "‘": "", - "’": "", - "“": "", - "”": "", - "\n": "-", - " ": "-", - "-": "-", - "–": "-", - "—": "-", - "/": "-", - "\\": "-", - _: "-", - "~": "-", - À: "A", - Á: "A", - Â: "A", - Ã: "A", - Ä: "A", - Å: "A", - Æ: "Ae", - Ç: "C", - Ð: "D", - È: "E", - É: "E", - Ê: "E", - Ë: "E", - Ì: "I", - Í: "I", - Î: "I", - Ï: "I", - Ñ: "N", - Ò: "O", - Ó: "O", - Ô: "O", - Õ: "O", - Ö: "O", - Ø: "O", - Œ: "Oe", - Š: "S", - Þ: "Th", - Ù: "U", - Ú: "U", - Û: "U", - Ü: "U", - Ý: "Y", - à: "a", - á: "a", - â: "a", - ã: "a", - ä: "ae", - å: "a", - æ: "ae", - "¢": "c", - ç: "c", - ð: "d", - è: "e", - é: "e", - ê: "e", - ë: "e", - ì: "i", - í: "i", - î: "i", - ï: "i", - ñ: "n", - ò: "o", - ó: "o", - ô: "o", - õ: "o", - ö: "oe", - ø: "o", - œ: "oe", - š: "s", - ß: "ss", - þ: "th", - ù: "u", - ú: "u", - û: "u", - ü: "ue", - ý: "y", - ÿ: "y", - Ÿ: "y", - }; - string = string.toLowerCase(); - for (const char in translate) { - string = string.split(char).join(translate[char]); - } return string - .replace(/[^a-z0-9-]/g, "") - .replace(/^-+|-+$/g, "") - .replace(/-+/g, "-"); + .toLowerCase() + .replace(/./g, (match) => slugMap[match] || match) + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$|[^a-z0-9-]/g, ""); +} + +export function escapeHtml(string: string) { + return string.replace(/[&<>"'`=/]/g, (match) => htmlEntityMap[match]); } export function validateSlug(slug: string) { return slug .toLowerCase() - .replace(" ", "-") + .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, ""); }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-7j46-f57w-76pjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-65956ghsaADVISORY
- github.com/getformwork/formwork/commit/4abcd60ae7692b46d316f956b0b20fb85336f3b2ghsax_refsource_MISCWEB
- github.com/getformwork/formwork/pull/791ghsax_refsource_MISCWEB
- github.com/getformwork/formwork/security/advisories/GHSA-7j46-f57w-76pjghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.