Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ graph TD
- **構成ファイル**:
- `model/virtual-path-resolver.ts` - `ResolverState` 型と純関数群(`createEmptyState` / `loadResolverState` / `toDiskPath` / `toLogicalPath` / `listLogicalPaths` / `listEntries` / `registerEntry` / `setLogicalPath` / `deleteEntry`)。論理パスは内部で先頭スラッシュが除去されて正規化される。エラー語彙は `PathConflictError`(論理パス衝突)/ `IdAlreadyExistsError`(id 既使用)/ `EmptyLogicalPathError`(正規化後に空)の 3 種で、route 層がそれぞれ 409 / 409 / 400 にマップする
- `model/file-tree.ts::buildFileTreeFromLogicalPaths` - 論理パス配列からツリー構造を組む純関数
- `route.tsx` - mode フラグの評価点。`GET /api/tree` / `POST /api/content/create` / `POST /api/content` の 3 エンドポイントが state を read-modify-write
- `route.tsx` - mode フラグの評価点。`GET /api/tree` / `POST /api/content/create` / `POST /api/content` の 3 エンドポイントが state を read-modify-write。論理パス入力は `isSafeLogicalPath` で `..` / `.` セグメントと NUL 文字を 400 で拒否し、ブラウザ正規化により孤児ファイル化する事故を API 境界で防ぐ
- `view/app.tsx` / `view/nav.tsx` - SSR 時に `virtualTreeEnabled` prop を hidden input + Nav の入力欄出し分けで埋め込む
- `client/nav-tree.ts` - `/api/tree` を fetch して `#nav-tree-mount` をハイドレート。仮想モードで `FileInfo.id` が乗っている葉は `<論理ファイル名> (<id>)` 形式(末尾 `.html` は除去)でラベル化し、id 部分は `.file-id` クラスの `<span>` として独立させてテーマ側でスタイル可能にする
- `client/new-file.ts` - hidden input で flag を読み、有効時のみ ID 入力を必須化して `/api/content/create` を叩く
Expand Down
3 changes: 1 addition & 2 deletions packages/@burger-editor/local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,14 +306,13 @@ Front Matter `path` の値は、**先頭スラッシュの有無に関わらず

- 編集ダイアログから新規作成するときに **ID と論理パスの両方をユーザが手入力** する必要があります(ID 自動採番は未対応)
- 同一の論理パスを複数ファイルに持たせることはできません
- 論理パスは仮想ツリー内のキーとしてのみ使われ、ディスクパスには影響しません。`..` を含めても documentRoot からの脱出は起きませんが、編集 UI から開けない孤児ファイルになるため注意([#755](https://github.com/d-zero-dev/BurgerEditor/issues/755) で UX 改善を予定)
- 論理パスは仮想ツリー内のキーとしてのみ使われ、ディスクパスには影響しません。`..` / `.` セグメントや NUL 文字を含む論理パスは API レイヤで `400` 拒否されるため、孤児ファイル化は起きません([#758](https://github.com/d-zero-dev/BurgerEditor/pull/758) 以降)。`loadResolverState` は boot 時にこのチェックを行わないので、旧バージョンで disk に書き込まれた `..` 入りの Front Matter は手動で修正する必要があります
- 並行更新の整合性は内部 mutex で守られます(シングルユーザー編集前提)

### 関連 issue

- [#753](https://github.com/d-zero-dev/BurgerEditor/issues/753) `client/create-editor.ts` のエラーパステスト整備
- [#754](https://github.com/d-zero-dev/BurgerEditor/issues/754) 起動失敗時の終了挙動テスト
- [#755](https://github.com/d-zero-dev/BurgerEditor/issues/755) 論理パスのサニタイズ(`..` 拒否)

詳細な採用手順とトラブルシュートは [`docs/virtual-tree.md`](./docs/virtual-tree.md) を参照してください。

Expand Down
14 changes: 11 additions & 3 deletions packages/@burger-editor/local/docs/virtual-tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,18 @@ ID やパスに `..`、`/`、`\` などのパス区切り / トラバーサル

## トラブルシュート

### 仮想ツリーに登録したはずのファイルが UI から開けない
### 論理パスに `..` / `.` / NUL を入れて 400 が返る

論理パス(Front Matter の `path`)に `..` や絶対パス風の値が含まれていないか確認する。論理パスはディスクへ届かないので脱出はしないが、ブラウザのリンク正規化で別 URL に飛ばされるため UI から開けなくなる。詳細は [#755](https://github.com/d-zero-dev/BurgerEditor/issues/755)。
`POST /api/content/create` および `POST /api/content` の `path` / `frontMatter[pathKey]` で、以下のいずれかに該当する値は **`400`** で拒否される。

- 任意位置の `..` / `.` セグメント(`../foo.html`、`foo/../bar.html`、`./foo.html`、`foo/./bar.html` など)
- NUL 文字 `\0`

理由: 論理パスは fs に届かないので documentRoot 脱出は起きないが、ブラウザは `<a href="/../foo.html">` のクリック時に URL を `/foo.html` へ正規化してしまう。一方サーバ側は登録キー `../foo.html` のままなので `toDiskPath` が引けず、ファイルは保存されているのに UI から二度と開けない孤児になる。これを防ぐため API 境界で拒否している([PR #758](https://github.com/d-zero-dev/BurgerEditor/pull/758))。

**対処**: パスから `..` / `.` セグメントを除き、ファイル名を含む通常の論理パス(`company/about.html` 等)に書き換える。

> **注意**: 旧バージョンで disk 上の Front Matter にすでに `..` を含むパスが書かれているケースは boot 時のチェック対象外。該当ファイルは編集 UI から開けないままになるので、Front Matter を直接編集して論理パスを修正してから再起動すること。

### 起動失敗のメッセージが Stack Trace に埋もれる

Expand All @@ -164,7 +173,6 @@ ID やパスに `..`、`/`、`\` などのパス区切り / トラバーサル
- [README.md の Virtual File Tree セクション](../README.md#virtual-file-tree仮想ファイルツリー) — API リファレンス
- [#753](https://github.com/d-zero-dev/BurgerEditor/issues/753) — `client/create-editor.ts` のテスト整備
- [#754](https://github.com/d-zero-dev/BurgerEditor/issues/754) — 起動失敗時の出口テスト
- [#755](https://github.com/d-zero-dev/BurgerEditor/issues/755) — 論理パスのサニタイズ
- [gray-matter](https://github.com/jonschlinkert/gray-matter) — 内部で使っている Front Matter パーサ
- 検索キーワード: `BurgerEditor virtualTree` / `PathConflictError` / `IdAlreadyExistsError` / `@burger-editor/local frontmatter path`

Expand Down
83 changes: 83 additions & 0 deletions packages/@burger-editor/local/src/route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,37 @@ describe('POST /api/content/create (virtual mode)', () => {
},
);

test.each([
{ label: 'leading "../"', logicalPath: '../foo.html' },
{ label: 'interior ".."', logicalPath: 'foo/../bar.html' },
{ label: 'leading "./"', logicalPath: './foo.html' },
{ label: 'interior "."', logicalPath: 'foo/./bar.html' },
{ label: 'just ".."', logicalPath: '..' },
{ label: 'just "."', logicalPath: '.' },
{ label: 'NUL byte', logicalPath: 'foo\0bar.html' },
])(
'rejects with 400 when logical path contains $label (regression: #755)',
async ({ logicalPath }) => {
// Without this guard the resolver registers e.g. "../foo.html" as a
// logical key, but the browser silently normalizes the corresponding
// `<a href>` away to "/foo.html", orphaning the disk file from the
// editor UI.
const app = await buildApp(documentRoot, assetsRoot, {
virtualTreeEnabled: true,
});
const res = await app.request('/api/content/create', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: '42.html', path: logicalPath }),
});
expect(res.status).toBe(400);

// And no disk file was created for the rejected entry.
const inside = await fs.readdir(documentRoot);
expect(inside).not.toContain('42.html');
},
);

test('returns 400 when virtualTree mode is disabled', async () => {
const app = await buildApp(documentRoot, assetsRoot, { virtualTreeEnabled: false });
const res = await app.request('/api/content/create', {
Expand Down Expand Up @@ -525,6 +556,58 @@ describe('POST /api/content (virtual mode, path change)', () => {
expect(res.status).toBe(400);
});

test.each([
{ label: 'leading "../"', value: '../foo.html' },
{ label: 'interior ".."', value: 'foo/../bar.html' },
{ label: 'leading "./"', value: './foo.html' },
{ label: 'NUL byte', value: 'foo\0bar.html' },
])(
'rejects with 400 when frontmatter pathKey contains $label (regression: #755)',
async ({ value }) => {
await fs.writeFile(
path.join(documentRoot, '1.html'),
'---\npath: about.html\n---\n<h1>About</h1>\n',
'utf8',
);
const app = await buildApp(documentRoot, assetsRoot, { virtualTreeEnabled: true });

const res = await app.request('/api/content', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
path: 'about.html',
content: '<h1>About</h1>',
frontMatter: { path: value },
}),
});
expect(res.status).toBe(400);

// Tree must not have advanced to the rejected path.
const treeRes = await app.request('/api/tree');
const body = (await treeRes.json()) as { tree: { name: string }[] };
expect(body.tree.map((n) => n.name)).toEqual(['about.html']);
},
);

test('rejects with 400 when the lookup path itself contains ".." (regression: #755)', async () => {
await fs.writeFile(
path.join(documentRoot, '1.html'),
'---\npath: about.html\n---\n<h1>About</h1>\n',
'utf8',
);
const app = await buildApp(documentRoot, assetsRoot, { virtualTreeEnabled: true });

const res = await app.request('/api/content', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
path: '../escape.html',
content: '<h1>Evil</h1>',
}),
});
expect(res.status).toBe(400);
});

test.each([
{ label: 'empty string', value: '' },
{ label: 'null', value: null },
Expand Down
38 changes: 36 additions & 2 deletions packages/@burger-editor/local/src/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,31 @@ import { App } from './view/app.js';

const clientFileDir = path.resolve(import.meta.dirname, '..', 'dist');

const LOGICAL_PATH_INVALID_MESSAGE =
'logical path must not contain "." or ".." segments or NUL bytes';

/**
* Reject logical paths that would canonicalize to a key the browser silently
* normalizes away (e.g. `../foo.html` → `/foo.html` in `<a href>` clicks),
* orphaning the disk file from the editor UI. See issue #755.
*
* Empty strings and "/"-only inputs are still accepted here; they are caught
* downstream by `EmptyLogicalPathError` to keep error surfaces consistent.
* @param input
*/
function isSafeLogicalPath(input: string): boolean {
if (input.includes('\0')) {
return false;
}
const stripped = input.replace(/^\/+/, '');
if (stripped.length === 0) {
return true;
}
return !stripped.split('/').some((seg) => seg === '.' || seg === '..');
}

const apiSchema = z.object({
path: z.string(),
path: z.string().refine(isSafeLogicalPath, { message: LOGICAL_PATH_INVALID_MESSAGE }),
content: z.string(),
frontMatter: z.record(z.string(), z.unknown()).optional(),
originalFrontMatter: z.string().optional(),
Expand All @@ -55,7 +78,10 @@ const createApiSchema = z.object({
'id must not contain path separators, NUL bytes, or be "." / ".." / a dotfile',
},
),
path: z.string().min(1),
path: z
.string()
.min(1)
.refine(isSafeLogicalPath, { message: LOGICAL_PATH_INVALID_MESSAGE }),
content: z.string().optional(),
frontMatter: z.record(z.string(), z.unknown()).optional(),
});
Expand Down Expand Up @@ -240,6 +266,14 @@ export function setRoute(
400,
);
}
if (!isSafeLogicalPath(newLogical)) {
return c.json(
{
error: `Front matter "${pathKey}" ${LOGICAL_PATH_INVALID_MESSAGE}`,
},
400,
);
}
try {
nextResolverState = setLogicalPath(resolverState, diskFile, newLogical);
} catch (error) {
Expand Down