case 4721 - client escaping of record text

Record text shown as HTML is escaped: unit warranty summary (vendor name,
receipt number), work-order address dialog (now plain text), search excerpt
(escaped, search terms highlighted as plain text, not as a regular
expression). Confirmation dialog messages are sanitised with DOMPurify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 17:19:52 -07:00
parent 04d3805fae
commit 66093db5fa
7 changed files with 73 additions and 21 deletions

View File

@@ -646,6 +646,20 @@ export default {
return str != null && typeof str.valueOf() === "string"; return str != null && typeof str.valueOf() === "string";
}, },
/////////////////////////////////////////////// ///////////////////////////////////////////////
// case 4721: escape text that goes into HTML (v-html) so it shows as text
//
escapeHtml: function(str) {
if (str === null || str === undefined) {
return "";
}
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
},
///////////////////////////////////////////////
// //
// //
// //

View File

@@ -31,10 +31,7 @@
<span v-if="options.title" class="ml-5"> {{ options.title }} </span> <span v-if="options.title" class="ml-5"> {{ options.title }} </span>
</v-card-title> </v-card-title>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<v-card-text <v-card-text class="text-body-1 text-sm-h6 my-5" v-html="safeMessage">
class="text-body-1 text-sm-h6 my-5"
v-html="options.message"
>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@@ -82,6 +79,7 @@
</div> </div>
</template> </template>
<script> <script>
import DOMPurify from "dompurify";
export default { export default {
data: () => ({ data: () => ({
width: 290, width: 290,
@@ -97,6 +95,12 @@ export default {
type: "info" //one of success, info, question, warning, and error, see v-alert docs for more info type: "info" //one of success, info, question, warning, and error, see v-alert docs for more info
} }
}), }),
computed: {
//case 4721: messages can hold formatting (line breaks), but never script: sanitised before it is shown
safeMessage() {
return DOMPurify.sanitize(this.options.message || "");
}
},
methods: { methods: {
open(options) { open(options) {
if (options.message.includes("\n")) { if (options.message.includes("\n")) {

View File

@@ -592,7 +592,9 @@ export default {
let PurchasedFrom = "-"; let PurchasedFrom = "-";
if (r.purchaseFromVendorId) { if (r.purchaseFromVendorId) {
PurchasedFrom = `<a href="/vendors/${r.purchaseFromVendorId}"> ${r.purchasedFromVendor}</a>`; PurchasedFrom = `<a href="/vendors/${
r.purchaseFromVendorId
}"> ${window.$gz.util.escapeHtml(r.purchasedFromVendor)}</a>`; //case 4721: escaped
} }
let PurchasedDate = "-"; let PurchasedDate = "-";
if (r.purchaseDate) { if (r.purchaseDate) {
@@ -609,7 +611,7 @@ export default {
"UnitPurchasedDate" "UnitPurchasedDate"
)}: ${PurchasedDate}<br/>${this.$ay.t( )}: ${PurchasedDate}<br/>${this.$ay.t(
"UnitReceipt" "UnitReceipt"
)}: ${r.purchaseReceiptNumber ?? "-"}`; )}: ${window.$gz.util.escapeHtml(r.purchaseReceiptNumber ?? "-")}`; //case 4721: escaped
let RecentWorkOrderList = ""; let RecentWorkOrderList = "";
if (r.recentWorkOrders.length > 0) { if (r.recentWorkOrders.length > 0) {

View File

@@ -584,7 +584,9 @@ export default {
let PurchasedFrom = "-"; let PurchasedFrom = "-";
if (r.purchaseFromVendorId) { if (r.purchaseFromVendorId) {
PurchasedFrom = `<a href="/vendors/${r.purchaseFromVendorId}"> ${r.purchasedFromVendor}</a>`; PurchasedFrom = `<a href="/vendors/${
r.purchaseFromVendorId
}"> ${window.$gz.util.escapeHtml(r.purchasedFromVendor)}</a>`; //case 4721: escaped
} }
let PurchasedDate = "-"; let PurchasedDate = "-";
if (r.purchaseDate) { if (r.purchaseDate) {
@@ -601,7 +603,7 @@ export default {
"UnitPurchasedDate" "UnitPurchasedDate"
)}: ${PurchasedDate}<br/>${this.$ay.t( )}: ${PurchasedDate}<br/>${this.$ay.t(
"UnitReceipt" "UnitReceipt"
)}: ${r.purchaseReceiptNumber ?? "-"}`; )}: ${window.$gz.util.escapeHtml(r.purchaseReceiptNumber ?? "-")}`; //case 4721: escaped
let RecentWorkOrderList = ""; let RecentWorkOrderList = "";
if (r.recentWorkOrders.length > 0) { if (r.recentWorkOrders.length > 0) {

View File

@@ -368,9 +368,9 @@
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title> <v-list-item-title>{{ item.name }}</v-list-item-title>
<!-- eslint-disable vue/no-v-html --> <!-- case 4721: plain text, the address is never HTML -->
<v-list-item-subtitle <v-list-item-subtitle
v-html="item.viz" v-text="item.viz"
></v-list-item-subtitle> ></v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>

View File

@@ -624,7 +624,9 @@ export default {
let PurchasedFrom = "-"; let PurchasedFrom = "-";
if (r.purchaseFromVendorId) { if (r.purchaseFromVendorId) {
PurchasedFrom = `<a href="/vendors/${r.purchaseFromVendorId}"> ${r.purchasedFromVendor}</a>`; PurchasedFrom = `<a href="/vendors/${
r.purchaseFromVendorId
}"> ${window.$gz.util.escapeHtml(r.purchasedFromVendor)}</a>`; //case 4721: escaped
} }
let PurchasedDate = "-"; let PurchasedDate = "-";
if (r.purchaseDate) { if (r.purchaseDate) {
@@ -641,7 +643,7 @@ export default {
"UnitPurchasedDate" "UnitPurchasedDate"
)}: ${PurchasedDate}<br/>${this.$ay.t( )}: ${PurchasedDate}<br/>${this.$ay.t(
"UnitReceipt" "UnitReceipt"
)}: ${r.purchaseReceiptNumber ?? "-"}`; )}: ${window.$gz.util.escapeHtml(r.purchaseReceiptNumber ?? "-")}`; //case 4721: escaped
let RecentWorkOrderList = ""; let RecentWorkOrderList = "";
if (r.recentWorkOrders.length > 0) { if (r.recentWorkOrders.length > 0) {

View File

@@ -206,16 +206,9 @@ export default {
vm.formState.serverError = res.error; vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm); window.$gz.form.setErrorBoxErrors(vm);
} else { } else {
let showInfo = res.data; //case 4721: the excerpt is record text, so it is escaped; the search terms are highlighted around it
const searchTerms = vm.searchPhrase.replace(/[*]/gi, "").split(" "); const searchTerms = vm.searchPhrase.replace(/[*]/gi, "").split(" ");
for (let i = 0; i < searchTerms.length; i++) { item.info = highlightTerms(res.data || "", searchTerms);
const regEx = new RegExp(searchTerms[i], "ig");
const replaceMask =
"<span class='v-list-item__mask'>" + searchTerms[i] + "</span>";
showInfo = showInfo.replace(regEx, replaceMask);
}
item.info = showInfo;
} }
} catch (error) { } catch (error) {
window.$gz.errorHandler.handleFormError(error, vm); window.$gz.errorHandler.handleFormError(error, vm);
@@ -357,4 +350,39 @@ async function populateSelectionLists(vm) {
window.$gz.form.addNoSelectionItem(vm.selectLists.objectTypes); window.$gz.form.addNoSelectionItem(vm.selectLists.objectTypes);
} }
} }
////////////////////
// case 4721: excerpt as escaped text with the search terms highlighted (case insensitive);
// the terms are matched as plain text, not as a regular expression
function highlightTerms(text, terms) {
const esc = window.$gz.util.escapeHtml;
const lowerText = text.toLowerCase();
const lowerTerms = terms.map(z => z.toLowerCase()).filter(z => z.length > 0);
let html = "";
let pos = 0;
while (pos < text.length) {
let best = -1;
let bestLength = 0;
lowerTerms.forEach(t => {
const i = lowerText.indexOf(t, pos);
if (
i !== -1 &&
(best === -1 || i < best || (i === best && t.length > bestLength))
) {
best = i;
bestLength = t.length;
}
});
if (best === -1) {
break;
}
html +=
esc(text.substring(pos, best)) +
"<span class='v-list-item__mask'>" +
esc(text.substring(best, best + bestLength)) +
"</span>";
pos = best + bestLength;
}
return html + esc(text.substring(pos));
}
</script> </script>