CVE-2021-25977 (JavaScript, published 2021-10-25T13:15Z)
Description: value: In PiranhaCMS, versions 7.0.0 to 9.1.1 are vulnerable to stored XSS due to the page title improperly sanitized. By creating a page with a specially crafted page title, a low privileged user can trigger arbitrary JavaScript execution.
Severity: LOW (CVSS v3 base score: 5.4)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
Repository: https://github.com/PiranhaCMS/piranha.core
Files changed:
core/Piranha.Manager/assets/dist/js/piranha.pagelist.js
core/Piranha.Manager/assets/dist/js/piranha.pagelist.min.js
core/Piranha.Manager/assets/src/js/components/sitemap-item.vue
Commit message: Make sure page title isn't rendered as HTML. Fixes #1726


Vulnerable code:
  template: "\n<li class=\"dd-item\" :class=\"{ expanded: item.isExpanded || item.items.length === 0 }\" :data-id=\"item.id\">\n    <div class=\"sitemap-item\" :class=\"{ dimmed: item.isUnpublished || item.isScheduled }\">\n        <div class=\"handle dd-handle\"><i class=\"fas fa-ellipsis-v\"></i></div>\n        <div class=\"link\">\n            <span class=\"actions\">\n                <a v-if=\"item.items.length > 0 && item.isExpanded\" v-on:click.prevent=\"toggleItem(item)\" class=\"expand\" href=\"#\"><i class=\"fas fa-minus\"></i></a>\n                <a v-if=\"item.items.length > 0 && !item.isExpanded\" v-on:click.prevent=\"toggleItem(item)\" class=\"expand\" href=\"#\"><i class=\"fas fa-plus\"></i></a>\n            </span>\n            <a v-if=\"piranha.permissions.pages.edit\" :href=\"piranha.baseUrl + item.editUrl + item.id\">\n                <span v-html=\"item.title\"></span>\n                <span v-if=\"item.isRestricted\" class=\"icon-restricted text-secondary small\"><i class=\"fas fa-lock\"></i></span>\n                <span v-if=\"item.status\" class=\"badge badge-info\">{{ item.status }}</span>\n                <span v-if=\"item.isScheduled\" class=\"badge badge-info\">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if=\"item.isCopy\" class=\"badge badge-warning\">{{ piranha.resources.texts.copy }}</span>\n            </a>\n            <span v-else class=\"title\">\n                <span v-html=\"item.title\"></span>\n                <span v-if=\"item.isRestricted\" class=\"icon-restricted text-secondary small\"><i class=\"fas fa-lock\"></i></span>\n                <span v-if=\"item.status\" class=\"badge badge-info\">{{ item.status }}</span>\n                <span v-if=\"item.isScheduled\" class=\"badge badge-info\">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if=\"item.isCopy\" class=\"badge badge-warning\">{{ piranha.resources.texts.copy }}</span>\n            </span>\n        </div>\n        <div class=\"type d-none d-md-block\">{{ item.typeName }}</div>\n        <div class=\"date d-none d-lg-block\">{{ item.published }}</div>\n        <div class=\"actions\">\n            <a v-if=\"piranha.permissions.pages.add\" href=\"#\" v-on:click.prevent=\"piranha.pagelist.add(item.siteId, item.id, true)\"><i class=\"fas fa-angle-down\"></i></a>\n            <a v-if=\"piranha.permissions.pages.add\" href=\"#\" v-on:click.prevent=\"piranha.pagelist.add(item.siteId, item.id, false)\"><i class=\"fas fa-angle-right\"></i></a>\n            <a v-if=\"piranha.permissions.pages.delete && item.items.length === 0\" v-on:click.prevent=\"piranha.pagelist.remove(item.id)\" class=\"danger\" href=\"#\"><i class=\"fas fa-trash\"></i></a>\n        </div>\n    </div>\n    <ol v-if=\"item.items.length > 0\" class=\"dd-list\">\n        <sitemap-item v-for=\"child in item.items\" v-bind:key=\"child.id\" v-bind:item=\"child\">\n        </sitemap-item>\n    </ol>\n</li>\n"
Vue.component("pagecopy-item",{props:["item"],methods:{toggleItem:function(e){e.isExpanded=!e.isExpanded}},template:'\n<li class="dd-item" :class="{ expanded: item.isExpanded || item.items.length === 0 }">\n    <div class="sitemap-item expanded">\n        <div class="link" :class="{ readonly: item.isCopy }">\n            <a v-if="!item.isCopy" :href="piranha.baseUrl + \'manager/page/copyrelative/\' + item.id + \'/\' + piranha.pagelist.addPageId + \'/\' + piranha.pagelist.addAfter">\n                {{ item.title }}\n            </a>\n            <a href="#" v-else>\n                {{ item.title }}\n                <span v-if="item.isCopy" class="badge badge-warning">{{ piranha.resources.texts.copy }}</span>\n            </a>\n            <div class="content-blocker"></div>\n        </div>\n        <div class="type d-none d-md-block">\n            {{ item.typeName }}\n        </div>\n    </div>\n    <ol class="dd-list" v-if="item.items.length > 0">\n        <pagecopy-item v-for="child in item.items" v-bind:key="child.id" v-bind:item="child"></pagecopy-item>\n    </ol>\n</li>\n'}),Vue.component("sitemap-item",{props:["item"],methods:{toggleItem:function(e){e.isExpanded=!e.isExpanded}},template:'\n<li class="dd-item" :class="{ expanded: item.isExpanded || item.items.length === 0 }" :data-id="item.id">\n    <div class="sitemap-item" :class="{ dimmed: item.isUnpublished || item.isScheduled }">\n        <div class="handle dd-handle"><i class="fas fa-ellipsis-v"></i></div>\n        <div class="link">\n            <span class="actions">\n                <a v-if="item.items.length > 0 && item.isExpanded" v-on:click.prevent="toggleItem(item)" class="expand" href="#"><i class="fas fa-minus"></i></a>\n                <a v-if="item.items.length > 0 && !item.isExpanded" v-on:click.prevent="toggleItem(item)" class="expand" href="#"><i class="fas fa-plus"></i></a>\n            </span>\n            <a v-if="piranha.permissions.pages.edit" :href="piranha.baseUrl + item.editUrl + item.id">\n                <span v-html="item.title"></span>\n                <span v-if="item.isRestricted" class="icon-restricted text-secondary small"><i class="fas fa-lock"></i></span>\n                <span v-if="item.status" class="badge badge-info">{{ item.status }}</span>\n                <span v-if="item.isScheduled" class="badge badge-info">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if="item.isCopy" class="badge badge-warning">{{ piranha.resources.texts.copy }}</span>\n            </a>\n            <span v-else class="title">\n                <span v-html="item.title"></span>\n                <span v-if="item.isRestricted" class="icon-restricted text-secondary small"><i class="fas fa-lock"></i></span>\n                <span v-if="item.status" class="badge badge-info">{{ item.status }}</span>\n                <span v-if="item.isScheduled" class="badge badge-info">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if="item.isCopy" class="badge badge-warning">{{ piranha.resources.texts.copy }}</span>\n            </span>\n        </div>\n        <div class="type d-none d-md-block">{{ item.typeName }}</div>\n        <div class="date d-none d-lg-block">{{ item.published }}</div>\n        <div class="actions">\n            <a v-if="piranha.permissions.pages.add" href="#" v-on:click.prevent="piranha.pagelist.add(item.siteId, item.id, true)"><i class="fas fa-angle-down"></i></a>\n            <a v-if="piranha.permissions.pages.add" href="#" v-on:click.prevent="piranha.pagelist.add(item.siteId, item.id, false)"><i class="fas fa-angle-right"></i></a>\n            <a v-if="piranha.permissions.pages.delete && item.items.length === 0" v-on:click.prevent="piranha.pagelist.remove(item.id)" class="danger" href="#"><i class="fas fa-trash"></i></a>\n        </div>\n    </div>\n    <ol v-if="item.items.length > 0" class="dd-list">\n        <sitemap-item v-for="child in item.items" v-bind:key="child.id" v-bind:item="child">\n        </sitemap-item>\n    </ol>\n</li>\n'}),piranha.pagelist=new Vue({el:"#pagelist",data:{loading:!0,updateBindings:!1,items:[],sites:[],pageTypes:[],addSiteId:null,addSiteTitle:null,addPageId:null,addAfter:!0},methods:{load:function(){var e=this;piranha.permissions.load(function(){fetch(piranha.baseUrl+"manager/api/page/list").then(function(e){return e.json()}).then(function(i){e.sites=i.sites,e.pageTypes=i.pageTypes,e.updateBindings=!0}).catch(function(e){console.log("error:",e)})})},remove:function(e){var i=this;piranha.alert.open({title:piranha.resources.texts.delete,body:piranha.resources.texts.deletePageConfirm,confirmCss:"btn-danger",confirmIcon:"fas fa-trash",confirmText:piranha.resources.texts.delete,onConfirm:function(){fetch(piranha.baseUrl+"manager/api/page/delete/"+e).then(function(e){return e.json()}).then(function(e){piranha.notifications.push(e),i.load()}).catch(function(e){console.log("error:",e)})}})},bind:function(){var e=this;$(".sitemap-container").each(function(i,s){$(s).nestable({maxDepth:100,group:i,callback:function(i,s){fetch(piranha.baseUrl+"manager/api/page/move",{method:"post",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:$(s).attr("data-id"),items:$(i).nestable("serialize")})}).then(function(e){return e.json()}).then(function(i){piranha.notifications.push(i.status),"success"===i.status.type&&($(".sitemap-container").nestable("destroy"),e.sites=[],Vue.nextTick(function(){e.sites=i.sites,Vue.nextTick(function(){e.bind()})}))}).catch(function(e){console.log("error:",e)})}})})},add:function(e,i,s){var a=this;a.addSiteId=e,a.addPageId=i,a.addAfter=s,a.sites.forEach(function(i){i.id===e&&(a.addSiteTitle=i.title)}),$("#pageAddModal").modal("show")},selectSite:function(e){var i=this;i.addSiteId=e,i.sites.forEach(function(s){s.id===e&&(i.addSiteTitle=s.title)})},collapse:function(){for(var e=0;e<this.sites.length;e++)for(var i=0;i<this.sites[e].pages.length;i++)this.changeVisibility(this.sites[e].pages[i],!1)},expand:function(){for(var e=0;e<this.sites.length;e++)for(var i=0;i<this.sites[e].pages.length;i++)this.changeVisibility(this.sites[e].pages[i],!0)},changeVisibility:function(e,i){e.isExpanded=i;for(var s=0;s<e.items.length;s++)this.changeVisibility(e.items[s],i)}},created:function(){},updated:function(){this.updateBindings&&(this.bind(),this.updateBindings=!1),this.loading=!1}});
                    <span v-html="item.title"></span>
                    <span v-html="item.title"></span>

Fixed code:
  template: "\n<li class=\"dd-item\" :class=\"{ expanded: item.isExpanded || item.items.length === 0 }\" :data-id=\"item.id\">\n    <div class=\"sitemap-item\" :class=\"{ dimmed: item.isUnpublished || item.isScheduled }\">\n        <div class=\"handle dd-handle\"><i class=\"fas fa-ellipsis-v\"></i></div>\n        <div class=\"link\">\n            <span class=\"actions\">\n                <a v-if=\"item.items.length > 0 && item.isExpanded\" v-on:click.prevent=\"toggleItem(item)\" class=\"expand\" href=\"#\"><i class=\"fas fa-minus\"></i></a>\n                <a v-if=\"item.items.length > 0 && !item.isExpanded\" v-on:click.prevent=\"toggleItem(item)\" class=\"expand\" href=\"#\"><i class=\"fas fa-plus\"></i></a>\n            </span>\n            <a v-if=\"piranha.permissions.pages.edit\" :href=\"piranha.baseUrl + item.editUrl + item.id\">\n                <span>{{ item.title }}</span>\n                <span v-if=\"item.isRestricted\" class=\"icon-restricted text-secondary small\"><i class=\"fas fa-lock\"></i></span>\n                <span v-if=\"item.status\" class=\"badge badge-info\">{{ item.status }}</span>\n                <span v-if=\"item.isScheduled\" class=\"badge badge-info\">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if=\"item.isCopy\" class=\"badge badge-warning\">{{ piranha.resources.texts.copy }}</span>\n            </a>\n            <span v-else class=\"title\">\n                <span>{{ item.title }}</span>\n                <span v-if=\"item.isRestricted\" class=\"icon-restricted text-secondary small\"><i class=\"fas fa-lock\"></i></span>\n                <span v-if=\"item.status\" class=\"badge badge-info\">{{ item.status }}</span>\n                <span v-if=\"item.isScheduled\" class=\"badge badge-info\">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if=\"item.isCopy\" class=\"badge badge-warning\">{{ piranha.resources.texts.copy }}</span>\n            </span>\n        </div>\n        <div class=\"type d-none d-md-block\">{{ item.typeName }}</div>\n        <div class=\"date d-none d-lg-block\">{{ item.published }}</div>\n        <div class=\"actions\">\n            <a v-if=\"piranha.permissions.pages.add\" href=\"#\" v-on:click.prevent=\"piranha.pagelist.add(item.siteId, item.id, true)\"><i class=\"fas fa-angle-down\"></i></a>\n            <a v-if=\"piranha.permissions.pages.add\" href=\"#\" v-on:click.prevent=\"piranha.pagelist.add(item.siteId, item.id, false)\"><i class=\"fas fa-angle-right\"></i></a>\n            <a v-if=\"piranha.permissions.pages.delete && item.items.length === 0\" v-on:click.prevent=\"piranha.pagelist.remove(item.id)\" class=\"danger\" href=\"#\"><i class=\"fas fa-trash\"></i></a>\n        </div>\n    </div>\n    <ol v-if=\"item.items.length > 0\" class=\"dd-list\">\n        <sitemap-item v-for=\"child in item.items\" v-bind:key=\"child.id\" v-bind:item=\"child\">\n        </sitemap-item>\n    </ol>\n</li>\n"
Vue.component("pagecopy-item",{props:["item"],methods:{toggleItem:function(e){e.isExpanded=!e.isExpanded}},template:'\n<li class="dd-item" :class="{ expanded: item.isExpanded || item.items.length === 0 }">\n    <div class="sitemap-item expanded">\n        <div class="link" :class="{ readonly: item.isCopy }">\n            <a v-if="!item.isCopy" :href="piranha.baseUrl + \'manager/page/copyrelative/\' + item.id + \'/\' + piranha.pagelist.addPageId + \'/\' + piranha.pagelist.addAfter">\n                {{ item.title }}\n            </a>\n            <a href="#" v-else>\n                {{ item.title }}\n                <span v-if="item.isCopy" class="badge badge-warning">{{ piranha.resources.texts.copy }}</span>\n            </a>\n            <div class="content-blocker"></div>\n        </div>\n        <div class="type d-none d-md-block">\n            {{ item.typeName }}\n        </div>\n    </div>\n    <ol class="dd-list" v-if="item.items.length > 0">\n        <pagecopy-item v-for="child in item.items" v-bind:key="child.id" v-bind:item="child"></pagecopy-item>\n    </ol>\n</li>\n'}),Vue.component("sitemap-item",{props:["item"],methods:{toggleItem:function(e){e.isExpanded=!e.isExpanded}},template:'\n<li class="dd-item" :class="{ expanded: item.isExpanded || item.items.length === 0 }" :data-id="item.id">\n    <div class="sitemap-item" :class="{ dimmed: item.isUnpublished || item.isScheduled }">\n        <div class="handle dd-handle"><i class="fas fa-ellipsis-v"></i></div>\n        <div class="link">\n            <span class="actions">\n                <a v-if="item.items.length > 0 && item.isExpanded" v-on:click.prevent="toggleItem(item)" class="expand" href="#"><i class="fas fa-minus"></i></a>\n                <a v-if="item.items.length > 0 && !item.isExpanded" v-on:click.prevent="toggleItem(item)" class="expand" href="#"><i class="fas fa-plus"></i></a>\n            </span>\n            <a v-if="piranha.permissions.pages.edit" :href="piranha.baseUrl + item.editUrl + item.id">\n                <span>{{ item.title }}</span>\n                <span v-if="item.isRestricted" class="icon-restricted text-secondary small"><i class="fas fa-lock"></i></span>\n                <span v-if="item.status" class="badge badge-info">{{ item.status }}</span>\n                <span v-if="item.isScheduled" class="badge badge-info">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if="item.isCopy" class="badge badge-warning">{{ piranha.resources.texts.copy }}</span>\n            </a>\n            <span v-else class="title">\n                <span>{{ item.title }}</span>\n                <span v-if="item.isRestricted" class="icon-restricted text-secondary small"><i class="fas fa-lock"></i></span>\n                <span v-if="item.status" class="badge badge-info">{{ item.status }}</span>\n                <span v-if="item.isScheduled" class="badge badge-info">{{ piranha.resources.texts.scheduled }}</span>\n                <span v-if="item.isCopy" class="badge badge-warning">{{ piranha.resources.texts.copy }}</span>\n            </span>\n        </div>\n        <div class="type d-none d-md-block">{{ item.typeName }}</div>\n        <div class="date d-none d-lg-block">{{ item.published }}</div>\n        <div class="actions">\n            <a v-if="piranha.permissions.pages.add" href="#" v-on:click.prevent="piranha.pagelist.add(item.siteId, item.id, true)"><i class="fas fa-angle-down"></i></a>\n            <a v-if="piranha.permissions.pages.add" href="#" v-on:click.prevent="piranha.pagelist.add(item.siteId, item.id, false)"><i class="fas fa-angle-right"></i></a>\n            <a v-if="piranha.permissions.pages.delete && item.items.length === 0" v-on:click.prevent="piranha.pagelist.remove(item.id)" class="danger" href="#"><i class="fas fa-trash"></i></a>\n        </div>\n    </div>\n    <ol v-if="item.items.length > 0" class="dd-list">\n        <sitemap-item v-for="child in item.items" v-bind:key="child.id" v-bind:item="child">\n        </sitemap-item>\n    </ol>\n</li>\n'}),piranha.pagelist=new Vue({el:"#pagelist",data:{loading:!0,updateBindings:!1,items:[],sites:[],pageTypes:[],addSiteId:null,addSiteTitle:null,addPageId:null,addAfter:!0},methods:{load:function(){var e=this;piranha.permissions.load(function(){fetch(piranha.baseUrl+"manager/api/page/list").then(function(e){return e.json()}).then(function(i){e.sites=i.sites,e.pageTypes=i.pageTypes,e.updateBindings=!0}).catch(function(e){console.log("error:",e)})})},remove:function(e){var i=this;piranha.alert.open({title:piranha.resources.texts.delete,body:piranha.resources.texts.deletePageConfirm,confirmCss:"btn-danger",confirmIcon:"fas fa-trash",confirmText:piranha.resources.texts.delete,onConfirm:function(){fetch(piranha.baseUrl+"manager/api/page/delete/"+e).then(function(e){return e.json()}).then(function(e){piranha.notifications.push(e),i.load()}).catch(function(e){console.log("error:",e)})}})},bind:function(){var e=this;$(".sitemap-container").each(function(i,s){$(s).nestable({maxDepth:100,group:i,callback:function(i,s){fetch(piranha.baseUrl+"manager/api/page/move",{method:"post",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:$(s).attr("data-id"),items:$(i).nestable("serialize")})}).then(function(e){return e.json()}).then(function(i){piranha.notifications.push(i.status),"success"===i.status.type&&($(".sitemap-container").nestable("destroy"),e.sites=[],Vue.nextTick(function(){e.sites=i.sites,Vue.nextTick(function(){e.bind()})}))}).catch(function(e){console.log("error:",e)})}})})},add:function(e,i,s){var a=this;a.addSiteId=e,a.addPageId=i,a.addAfter=s,a.sites.forEach(function(i){i.id===e&&(a.addSiteTitle=i.title)}),$("#pageAddModal").modal("show")},selectSite:function(e){var i=this;i.addSiteId=e,i.sites.forEach(function(s){s.id===e&&(i.addSiteTitle=s.title)})},collapse:function(){for(var e=0;e<this.sites.length;e++)for(var i=0;i<this.sites[e].pages.length;i++)this.changeVisibility(this.sites[e].pages[i],!1)},expand:function(){for(var e=0;e<this.sites.length;e++)for(var i=0;i<this.sites[e].pages.length;i++)this.changeVisibility(this.sites[e].pages[i],!0)},changeVisibility:function(e,i){e.isExpanded=i;for(var s=0;s<e.items.length;s++)this.changeVisibility(e.items[s],i)}},created:function(){},updated:function(){this.updateBindings&&(this.bind(),this.updateBindings=!1),this.loading=!1}});
                    <span>{{ item.title }}</span>
                    <span>{{ item.title }}</span>