VYPR
Moderate severityNVD Advisory· Published Nov 9, 2023· Updated Sep 4, 2024

CVE-2023-45885

CVE-2023-45885

Description

A stored XSS vulnerability in NASA Open MCT's flexibleLayout plugin allows attackers to execute arbitrary JavaScript in users' browsers.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A stored XSS vulnerability in NASA Open MCT's flexibleLayout plugin allows attackers to execute arbitrary JavaScript in users' browsers.

Vulnerability

Overview CVE-2023-45885 is a stored cross-site scripting (XSS) vulnerability in NASA Open MCT (Open Mission Control Technologies) through version 3.1.0. The flaw resides in the flexibleLayout plugin, specifically in frame.vue at line 162 [1]. The bug occurs because the domainObject.name property, controlled by user input when creating a widget or component, is assigned unsanitized to another component's innerHTML property. This allows an attacker to inject malicious scripts that are rendered when the flexibleLayout component is in edit mode and a user drags its element between columns [1].

Attack

Vector and Prerequisites Exploitation requires an authenticated user with the ability to create flexibleLayout components (i.e., edit roles) in an instance that has persistence enabled. When the user creates a new component and provides a crafted payload as the name (e.g., ``), the malicious script is stored. The attack triggers when any other user (or the same user) drags the affected element within the flexibleLayout editor, executing the injected script [1]. The described version (3.1.0) also lacks Content Security Policy (CSP) flags and CSRF protection, which could amplify the impact [1].

Impact

Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of the victim's browser session. This could lead to data theft, session hijacking, or manipulation of the mission control interface. Since Open MCT is a web-based platform for visualizing spacecraft mission data and telemetry (as described on the project's GitHub [2]), an attacker could potentially read or exfiltrate sensitive telemetry data, alter displayed information, or compromise the integrity of the operator's view.

Mitigation

Status At the time of disclosure, NASA Open MCT developers have released a fix via a cherry-picked commit that adds the eslint-plugin-no-unsanitized rule and sanitizes URL inputs in the related summary-widget module [3]. This patch prevents the unsanitized assignment of user-controlled strings to DOM innerHTML properties. Users are strongly advised to update to the latest patched version or apply the commit manually [3].

AI Insight generated on May 20, 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.

PackageAffected versionsPatched versions
openmctnpm
<= 3.1.0

Affected products

2

Patches

1
ff8288d10ec7

cherry-pick(#7144): fix(#7143): add `eslint-plugin-no-unsanitized` and fix errors (#7148)

https://github.com/nasa/openmctJesse MazzellaOct 23, 2023via ghsa-ref
11 files changed · +115 65
  • .eslintrc.js+2 1 modified
    @@ -15,7 +15,8 @@ module.exports = {
         'plugin:compat/recommended',
         'plugin:vue/vue3-recommended',
         'plugin:you-dont-need-lodash-underscore/compatible',
    -    'plugin:prettier/recommended'
    +    'plugin:prettier/recommended',
    +    'plugin:no-unsanitized/DOM'
       ],
       parser: 'vue-eslint-parser',
       parserOptions: {
    
  • package.json+1 0 modified
    @@ -27,6 +27,7 @@
         "eslint": "8.48.0",
         "eslint-config-prettier": "9.0.0",
         "eslint-plugin-compat": "4.2.0",
    +    "eslint-plugin-no-unsanitized": "4.0.2",
         "eslint-plugin-playwright": "0.12.0",
         "eslint-plugin-prettier": "4.2.1",
         "eslint-plugin-simple-import-sort": "10.0.0",
    
  • src/plugins/flexibleLayout/components/Frame.vue+4 1 modified
    @@ -161,8 +161,11 @@ export default {
             let originalClassName = this.dragGhost.classList[0];
             this.dragGhost.className = '';
             this.dragGhost.classList.add(originalClassName, iconClass);
    +        this.dragGhost.textContent = '';
    +        const span = document.createElement('span');
    +        span.textContent = this.domainObject.name;
    +        this.dragGhost.appendChild(span);
     
    -        this.dragGhost.innerHTML = `<span>${this.domainObject.name}</span>`;
             event.dataTransfer.setDragImage(this.dragGhost, 0, 0);
           }
     
    
  • src/plugins/plot/chart/MctChart.vue+16 7 modified
    @@ -20,12 +20,10 @@
      at runtime from the About dialog for additional information.
     -->
     
    -<!-- eslint-disable vue/no-v-html -->
    -
     <template>
       <div class="gl-plot-chart-area">
    -    <span v-html="canvasTemplate"></span>
    -    <span v-html="canvasTemplate"></span>
    +    <canvas :style="canvasStyle"></canvas>
    +    <canvas :style="canvasStyle"></canvas>
         <div ref="limitArea" class="js-limit-area">
           <limit-label
             v-for="(limitLabel, index) in visibleLimitLabels"
    @@ -146,12 +144,20 @@ export default {
       },
       data() {
         return {
    -      canvasTemplate:
    -        '<canvas style="position: absolute; background: none; width: 100%; height: 100%;"></canvas>',
           visibleLimitLabels: [],
           visibleLimitLines: []
         };
       },
    +  computed: {
    +    canvasStyle() {
    +      return {
    +        position: 'absolute',
    +        background: 'none',
    +        width: '100%',
    +        height: '100%'
    +      };
    +    }
    +  },
       watch: {
         highlights: {
           handler() {
    @@ -487,7 +493,10 @@ export default {
           // Have to throw away the old canvas elements and replace with new
           // canvas elements in order to get new drawing contexts.
           const div = document.createElement('div');
    -      div.innerHTML = this.canvasTemplate + this.canvasTemplate;
    +      div.innerHTML = `
    +      <canvas style="position: absolute; background: none; width: 100%; height: 100%;"></canvas>
    +      <canvas style="position: absolute; background: none; width: 100%; height: 100%;"></canvas>
    +      `;
           const mainCanvas = div.querySelectorAll('canvas')[1];
           const overlayCanvas = div.querySelectorAll('canvas')[0];
           this.canvas.parentNode.replaceChild(mainCanvas, this.canvas);
    
  • src/plugins/summaryWidget/src/Condition.js+8 4 modified
    @@ -214,7 +214,7 @@ define([
               const options = this.generateSelectOptions();
     
               newInput = document.createElement('select');
    -          newInput.innerHTML = options;
    +          newInput.appendChild(options);
     
               emitChange = true;
             } else {
    @@ -244,12 +244,16 @@ define([
     
       Condition.prototype.generateSelectOptions = function () {
         let telemetryMetadata = this.conditionManager.getTelemetryMetadata(this.config.object);
    -    let options = '';
    +    let fragment = document.createDocumentFragment();
    +
         telemetryMetadata[this.config.key].enumerations.forEach((enumeration) => {
    -      options += '<option value="' + enumeration.value + '">' + enumeration.string + '</option>';
    +      const option = document.createElement('option');
    +      option.value = enumeration.value;
    +      option.textContent = enumeration.string;
    +      fragment.appendChild(option);
         });
     
    -    return options;
    +    return fragment;
       };
     
       return Condition;
    
  • src/plugins/summaryWidget/src/input/Palette.js+6 5 modified
    @@ -44,11 +44,12 @@ define([
         self.setNullOption(this.nullOption);
     
         self.items.forEach(function (item) {
    -      const itemElement = `<div class = "c-palette__item ${item}" data-item = "${item}"></div>`;
    -      const temp = document.createElement('div');
    -      temp.innerHTML = itemElement;
    -      self.itemElements[item] = temp.firstChild;
    -      self.domElement.querySelector('.c-palette__items').appendChild(temp.firstChild);
    +      const itemElement = document.createElement('div');
    +      itemElement.className = 'c-palette__item ' + item;
    +      itemElement.setAttribute('data-item', item);
    +
    +      self.itemElements[item] = itemElement;
    +      self.domElement.querySelector('.c-palette__items').appendChild(itemElement);
         });
     
         self.domElement.querySelector('.c-menu').style.display = 'none';
    
  • src/plugins/summaryWidget/src/Rule.js+6 5 modified
    @@ -167,7 +167,8 @@ define([
             const ruleHeader = self.domElement
               .querySelectorAll('.widget-rule-header')[0]
               .cloneNode(true);
    -        indicator.innerHTML = ruleHeader;
    +        indicator.textContent = '';
    +        indicator.appendChild(ruleHeader);
           });
           self.widgetDnD.setDragImage(
             self.domElement.querySelectorAll('.widget-rule-header')[0].cloneNode(true)
    @@ -239,8 +240,8 @@ define([
         this.listenTo(this.toggleConfigButton, 'click', toggleConfig);
         this.listenTo(this.trigger, 'change', onTriggerInput);
     
    -    this.title.innerHTML = self.config.name;
    -    this.description.innerHTML = self.config.description;
    +    this.title.innerText = self.config.name;
    +    this.description.innerText = self.config.description;
         this.trigger.value = self.config.trigger;
     
         this.listenTo(this.grippy, 'mousedown', onDragStart);
    @@ -456,7 +457,7 @@ define([
             const lastOfType = self.conditionArea.querySelector('li:last-of-type');
             lastOfType.parentNode.insertBefore($condition, lastOfType);
             if (loopCnt > 0) {
    -          $condition.querySelector('.t-condition-context').innerHTML = triggerContextStr + ' when';
    +          $condition.querySelector('.t-condition-context').innerText = triggerContextStr + ' when';
             }
     
             loopCnt++;
    @@ -528,7 +529,7 @@ define([
         }
     
         description = description === '' ? this.config.description : description;
    -    this.description.innerHTML = self.config.description;
    +    this.description.innerText = self.config.description;
         this.config.description = description;
       };
     
    
  • src/plugins/summaryWidget/src/SummaryWidget.js+2 1 modified
    @@ -247,9 +247,10 @@ define([
       SummaryWidget.prototype.updateWidget = function () {
         const WIDGET_ICON_CLASS = 'c-sw__icon js-sw__icon';
         const activeRule = this.rulesById[this.activeId];
    +
         this.applyStyle(this.domElement.querySelector('#widget'), activeRule.getProperty('style'));
         this.domElement.querySelector('#widget').title = activeRule.getProperty('message');
    -    this.domElement.querySelector('#widgetLabel').innerHTML = activeRule.getProperty('label');
    +    this.domElement.querySelector('#widgetLabel').textContent = activeRule.getProperty('label');
         this.domElement.querySelector('#widgetIcon').classList =
           WIDGET_ICON_CLASS + ' ' + activeRule.getProperty('icon');
       };
    
  • src/plugins/summaryWidget/src/views/summary-widget.html+0 4 removed
    @@ -1,4 +0,0 @@
    -<a class="t-summary-widget c-summary-widget js-sw u-links u-fills-container">
    -  <div id="widgetIcon" class="c-sw__icon js-sw__icon"></div>
    -  <div id="widgetLabel" class="c-sw__label js-sw__label">Loading...</div>
    -</a>
    
  • src/plugins/summaryWidget/src/views/SummaryWidgetView.js+58 34 modified
    @@ -1,35 +1,60 @@
    -define(['./summary-widget.html', '@braintree/sanitize-url'], function (
    -  summaryWidgetTemplate,
    -  urlSanitizeLib
    -) {
    -  const WIDGET_ICON_CLASS = 'c-sw__icon js-sw__icon';
    +import * as urlSanitizeLib from '@braintree/sanitize-url';
     
    -  function SummaryWidgetView(domainObject, openmct) {
    +const WIDGET_ICON_CLASS = 'c-sw__icon js-sw__icon';
    +
    +class SummaryWidgetView {
    +  #createSummaryWidgetTemplate() {
    +    const anchor = document.createElement('a');
    +    anchor.classList.add(
    +      't-summary-widget',
    +      'c-summary-widget',
    +      'js-sw',
    +      'u-links',
    +      'u-fills-container'
    +    );
    +
    +    const widgetIcon = document.createElement('div');
    +    widgetIcon.id = 'widgetIcon';
    +    widgetIcon.classList.add('c-sw__icon', 'js-sw__icon');
    +    anchor.appendChild(widgetIcon);
    +
    +    const widgetLabel = document.createElement('div');
    +    widgetLabel.id = 'widgetLabel';
    +    widgetLabel.classList.add('c-sw__label', 'js-sw__label');
    +    widgetLabel.textContent = 'Loading...';
    +    anchor.appendChild(widgetLabel);
    +
    +    return anchor;
    +  }
    +
    +  constructor(domainObject, openmct) {
         this.openmct = openmct;
         this.domainObject = domainObject;
         this.hasUpdated = false;
         this.render = this.render.bind(this);
       }
     
    -  SummaryWidgetView.prototype.updateState = function (datum) {
    +  updateState(datum) {
         this.hasUpdated = true;
         this.widget.style.color = datum.textColor;
         this.widget.style.backgroundColor = datum.backgroundColor;
         this.widget.style.borderColor = datum.borderColor;
         this.widget.title = datum.message;
         this.label.title = datum.message;
    -    this.label.innerHTML = datum.ruleLabel;
    +    this.label.textContent = datum.ruleLabel;
         this.icon.className = WIDGET_ICON_CLASS + ' ' + datum.icon;
    -  };
    +  }
     
    -  SummaryWidgetView.prototype.render = function () {
    +  render() {
         if (this.unsubscribe) {
           this.unsubscribe();
         }
     
         this.hasUpdated = false;
     
    -    this.container.innerHTML = summaryWidgetTemplate;
    +    const anchor = this.#createSummaryWidgetTemplate();
    +    this.container.appendChild(anchor);
    +
         this.widget = this.container.querySelector('a');
         this.icon = this.container.querySelector('#widgetIcon');
         this.label = this.container.querySelector('.js-sw__label');
    @@ -49,33 +74,32 @@ define(['./summary-widget.html', '@braintree/sanitize-url'], function (
     
         const renderTracker = {};
         this.renderTracker = renderTracker;
    +
         this.openmct.telemetry
           .request(this.domainObject, {
             strategy: 'latest',
             size: 1
           })
    -      .then(
    -        function (results) {
    -          if (
    -            this.destroyed ||
    -            this.hasUpdated ||
    -            this.renderTracker !== renderTracker ||
    -            results.length === 0
    -          ) {
    -            return;
    -          }
    -
    -          this.updateState(results[results.length - 1]);
    -        }.bind(this)
    -      );
    +      .then((results) => {
    +        if (
    +          this.destroyed ||
    +          this.hasUpdated ||
    +          this.renderTracker !== renderTracker ||
    +          results.length === 0
    +        ) {
    +          return;
    +        }
    +
    +        this.updateState(results[results.length - 1]);
    +      });
     
         this.unsubscribe = this.openmct.telemetry.subscribe(
           this.domainObject,
           this.updateState.bind(this)
         );
    -  };
    +  }
     
    -  SummaryWidgetView.prototype.show = function (container) {
    +  show(container) {
         this.container = container;
         this.render();
         this.removeMutationListener = this.openmct.objects.observe(
    @@ -84,14 +108,14 @@ define(['./summary-widget.html', '@braintree/sanitize-url'], function (
           this.onMutation.bind(this)
         );
         this.openmct.time.on('timeSystem', this.render);
    -  };
    +  }
     
    -  SummaryWidgetView.prototype.onMutation = function (domainObject) {
    +  onMutation(domainObject) {
         this.domainObject = domainObject;
         this.render();
    -  };
    +  }
     
    -  SummaryWidgetView.prototype.destroy = function (container) {
    +  destroy() {
         this.unsubscribe();
         this.removeMutationListener();
         this.openmct.time.off('timeSystem', this.render);
    @@ -100,7 +124,7 @@ define(['./summary-widget.html', '@braintree/sanitize-url'], function (
         delete this.label;
         delete this.openmct;
         delete this.domainObject;
    -  };
    +  }
    +}
     
    -  return SummaryWidgetView;
    -});
    +export default SummaryWidgetView;
    
  • src/utils/template/templateHelpers.js+12 3 modified
    @@ -1,8 +1,17 @@
     export function convertTemplateToHTML(templateString) {
    -  const template = document.createElement('template');
    -  template.innerHTML = templateString;
    +  const parser = new DOMParser();
    +  const doc = parser.parseFromString(templateString, 'text/html');
     
    -  return template.content.cloneNode(true).children;
    +  // Create a document fragment to hold the parsed content
    +  const fragment = document.createDocumentFragment();
    +
    +  // Append nodes from the parsed content to the fragment
    +  while (doc.body.firstChild) {
    +    fragment.appendChild(doc.body.firstChild);
    +  }
    +
    +  // Convert children of the fragment to an array and return
    +  return Array.from(fragment.children);
     }
     
     export function toggleClass(element, className) {
    

Vulnerability mechanics

Root cause

"Multiple instances of assigning unsanitized user-controllable data to innerHTML allow cross-site scripting (XSS) injection."

Attack vector

An attacker can inject arbitrary JavaScript by crafting a malicious payload in the `ruleLabel` property of telemetry data consumed by the summary widget's `updateState` method [CWE-79]. The vulnerable code path is triggered when the SummaryWidgetView renders telemetry results via `this.label.innerHTML = datum.ruleLabel`, which does not sanitize the input before inserting it into the DOM. The flexibleLayout plugin's Frame.vue also allows injection through `this.domainObject.name` when a component is dragged, as it used `innerHTML` with the object name. No authentication bypass is required if the attacker can supply or modify telemetry data or domain object names that are displayed by these components.

Affected code

The patch touches multiple files across the summaryWidget, plot, and flexibleLayout plugins. The core XSS sink is in `src/plugins/summaryWidget/src/views/SummaryWidgetView.js` where `this.label.innerHTML = datum.ruleLabel` assigned unsanitized telemetry data as innerHTML. Additional unsafe innerHTML assignments were fixed in `src/plugins/summaryWidget/src/SummaryWidget.js` (`this.domElement.querySelector('#widgetLabel').innerHTML`), `src/plugins/summaryWidget/src/Rule.js` (`.innerHTML` on title, description, and condition-context), `src/plugins/flexibleLayout/components/Frame.vue` (`.innerHTML` with `this.domainObject.name`), and `src/plugins/plot/chart/MctChart.vue` (v-html with a canvas template). The helper `src/utils/template/templateHelpers.js` was also hardened by replacing `innerHTML` assignment with DOMParser parsing.

What the fix does

The patch replaces all instances of `.innerHTML` assignment with safe alternatives such as `.textContent`, `.innerText`, or explicit DOM construction using `document.createElement` and `appendChild` [patch_id=1640553]. For example, in SummaryWidgetView.js, `this.label.innerHTML = datum.ruleLabel` becomes `this.label.textContent = datum.ruleLabel`, which prevents HTML/script injection. In Frame.vue, the drag ghost's innerHTML assignment is replaced by creating a span element and setting its textContent. The templateHelpers.js function `convertTemplateToHTML` is rewritten to use DOMParser instead of `innerHTML` on a template element. Additionally, the ESLint configuration adds `plugin:no-unsanitized/DOM` to statically prevent future uses of unsafe DOM APIs.

Preconditions

  • inputAttacker must be able to supply or control telemetry data (specifically the ruleLabel field) that is consumed by the summary widget, or control domain object names displayed in the flexible layout drag ghost.
  • networkThe victim must view a dashboard or layout that includes a summary widget or flexible layout component rendering the attacker-controlled data.

Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.