Nginx UIにおいて、機密性の高い変更操作を行う際に、OTP(ワンタイムパスワード)によるセキュアセッションの強化機能が一部で欠けている。

報告者:Jagadesh Achanta

対象プロジェクト:`0xJacky/nginx-ui`

影響を受けるバージョン/テスト済みコミット:
- アプリケーションバージョン:`2.3.10`
- コミット:`4948fba8744e82c3d7003f68ee164e9f49421066`
- 報告経路:GitHubの非公開脆弱性報告
- 認証情報参照:`cred:github-jagstack-gh-cli`

## AIによる支援に関する記述

この報告書は、Jagadesh Achantaによって作成され、OpenAI/Codexの支援を受けて、公開されているソースコードのレビュー、ローカルでの証拠収集、および報告書の作成が行われた。

## 概要

Nginx UIでは、2段階認証(2FA)が有効なユーザーに対して、一時的なOTP/パスキーによる「セキュアセッション」の強化機能が適用される。`RequireSecureSession()`は、有効な`X-Secure-Session-ID`が、最新のOTP/パスキーの認証後に発行された場合にのみ、OTPを有効にしたユーザーからのリクエストを許可する。

この強化機能は、すでに以下の機密性の高い操作に適用されている:

- Nginxの設定の作成/編集/名前変更/削除
- サイトの保存/削除/有効化/無効化/名前変更/複製
- ストリームの保存/削除/有効化/無効化/名前変更/複製
- 設定の保存と保護されたシークレットの読み取り
- バックアップの復元

しかし、同様に機密性の高い認証済み変更操作を行う経路が、`RequireSecureSession()`の適用範囲外となっている。攻撃者が、2FAが有効なユーザーの認証済みJWT/セッション情報を取得できた場合でも、そのユーザーの最新のOTP/パスキーによるセキュアセッショントークンを持っていなければ、これらの操作を実行できてしまう可能性がある。

影響を受ける経路のグループには、以下が含まれる:

- 自動バックアップの作成/変更/削除/復元/S3テスト操作
- DNS認証情報の作成/編集/削除操作
- 証明書およびACMEユーザーの作成/変更/削除/登録操作
- DNSドメイン、レコード、およびDDNSの作成/更新/削除操作
- Nginxのリロード/再起動、stub-statusの切り替え、パフォーマンス設定の更新、およびモジュールキャッシュの更新操作
- サイト/ストリームの高度なモードの切り替えと一括更新
- サイトの自動証明書バインディング/アンバインディング
- サイトのナビゲーション順序とヘルスチェックの更新。

このレポートは、以前に作成したNginx UIに関する、自動バックアップのパス制御に関するレポートとは異なります。このレポートは、機密性の高い認証済みデータ変更処理において、2段階認証(2FA)の追加認証が欠落している問題についてです。

## なぜこれがセキュリティ上の境界であるか

`internal/middleware/secure_session.go`では、OTP(ワンタイムパスワード)が有効になっているユーザーに対してのみ、セキュアセッションのトークンを要求しています。OTPを使用しているユーザーの場合、ヘッダーまたはクエリ文字列から`X-Secure-Session-ID`を読み込み、セキュアセッションが存在しない、または無効な場合は、`401 Unauthorized`エラーで処理を中断します。

```go
if !cUser.EnabledOTP() {
c.Next()
return
}
ssid := c.GetHeader("X-Secure-Session-ID")
if ssid == "" {
ssid = c.Query("X-Secure-Session-ID")
}
if ssid == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"message": "Secure Session ID is empty",
})
return
}

if user.VerifySecureSessionID(ssid, cUser.ID) {
c.Next()
return
}
```

`internal/user/otp.go`では、セキュアセッションIDを10分間のみ有効にしています。

```go
func SetSecureSessionID(userId uint64) (sessionId string) {
sessionId = uuid.NewString()
cache.Set(secureSessionIDCacheKey(sessionId), userId, 10*time.Minute)
return
}
```

UI/サーバーは、すでにこの境界を利用して、機密性の高い操作を行っています。例えば、`api/config/router.go`では、設定の変更処理を`RequireSecureSession()`で保護しています。

```go
o := r.Group("", middleware.RequireSecureSession())
{
o.POST("configs", AddConfig)
o.POST("config", EditConfig)
o.POST("config_mkdir", Mkdir)
o.POST("config_rename", Rename)
o.POST("config_delete", DeleteConfig)
}
```

`api/sites/router.go`では、通常のサイトの変更処理も`RequireSecureSession()`で保護しています。

```go
o := r.Group("", middleware.RequireSecureSession())
{
o.POST("sites/:name/rename", RenameSite)
o.POST("sites/:name/enable", EnableSite)
o.POST("sites/:name/disable", DisableSite)
o.POST("sites/:name", SaveSite)
o.DELETE("sites/:name", DeleteSite)
o.POST("sites/:name/duplicate", DuplicateSite)
o.POST("sites/:name/maintenance", EnableMaintenanceSite)
}
```

`api/streams/router.go` は、通常のストリームの変更を保護し、`api/settings/router.go` は、機密情報の読み取りや設定の保存を保護します。

```go
o := r.Group("", middleware.RequireSecureSession())
{
o.POST("streams/:name", SaveStream)
o.POST("streams/:name/rename", RenameStream)
o.POST("streams/:name/enable", EnableStream)
o.POST("streams/:name/disable", DisableStream)
o.DELETE("streams/:name", DeleteStream)
o.POST("streams/:name/duplicate", Duplicate)
}
```

```go
r.GET("settings/protected", middleware.RequireSecureSession(), GetProtectedSetting)
r.POST("settings", middleware.RequireSecureSession(), SaveSettings)
```

このリポジトリ自身のテストでも、この期待される動作が検証されています。`api/config/security_test.go`、`api/sites/security_test.go`、および `api/streams/security_test.go` は、それぞれ、OTP(ワンタイムパスワード)を有効にしたユーザーが、セキュアなセッションなしで保護された変更を試みた場合に、`401 Unauthorized` が返されることを検証しています。

## セキュア認証境界外の影響を受けるルート

影響を受けるルートは、認証されたプライベートルーターの下にあります。

```go
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
```

しかし、これらのルートには `middleware.RequireSecureSession()` が追加されていません。

### 自動バックアップ管理

`api/backup/router.go` は、手動での復元を保護します。

```go
r.POST("/restore", middleware.AuthRequired(), middleware.RequireSecureSession(), middleware.EncryptedForm(), RestoreBackup)
```

しかし、自動バックアップ管理ルーターには、セキュアセッションのミドルウェアがありません。

```go
r.POST("/auto_backup", CreateAutoBackup)
r.POST("/auto_backup/:id", ModifyAutoBackup)
r.DELETE("/auto_backup/:id", DestroyAutoBackup)
r.PATCH("/auto_backup/:id", RestoreAutoBackup)
r.POST("/auto_backup/test_s3", TestS3Connection)
```

これらのハンドラーは、バックアップジョブの作成または変更、cronジョブの登録、攻撃者によって提供されたS3設定のテスト、およびS3の認証情報の保存を行います。

```go
"s3_access_key_id": "omitempty",
"s3_secret_access_key": "omitempty",
...
if ctx.Model.Enabled {
if err := cron.AddAutoBackupJob(ctx.Model.ID, ctx.Model.CronExpression); err != nil {
```

モデルは、S3の認証情報を保存します。

```go
S3AccessKeyID string `json:"s3_access_key_id" gorm:"comment:S3アクセスキーID;serializer:json[aes]"`
S3SecretAccessKey string `json:"s3_secret_access_key" gorm:"comment:S3シークレットアクセスキー;serializer:json[aes]"`
```

### 証明書、ACME、およびDNSの認証情報管理

`api/certificate/router.go` は、`RequireSecureSession()` なしで、認証情報と証明書の変更を登録します。

```go
r.POST("dns_credentials", AddDnsCredential)
r.POST("dns_credentials/:id", EditDnsCredential)
r.DELETE("dns_credentials/:id", DeleteDnsCredential)

r.POST("certs", AddCert)
r.POST("certs/:id", ModifyCert)
r.DELETE("certs/:id", RemoveCert)
r.PUT("cert_sync", SyncCertificate)

r.POST("acme_users", CreateAcmeUser)
r.POST("acme_users/:id", ModifyAcmeUser)
r.POST("acme_users/:id/register", RegisterAcmeUser)
r.DELETE("acme_users/:id", DestroyAcmeUser)
```

DNS認証情報のハンドラは、プロバイダーの設定を永続化します。

```go
dnsCredential := model.DnsCredential{
Name: json.Name,
Config: &json.Config,
Provider: json.Provider,
ProviderCode: providerCode,
}
...
err := d.Create(&dnsCredential)
```

モデルは、その設定をAESでシリアライズします。

```go
Config *dns.Config `json:"config,omitempty" gorm:"serializer:json[aes]"`
```

### DNSドメイン、レコード、およびDDNS

`api/dns/router.go` は、セキュアセッションミドルウェアなしで、DNSドメイン、レコード、およびDDNSの変更を登録します。

```go
group.POST("/domains", CreateDomain)
group.POST("/domains/:id", UpdateDomain)
group.DELETE("/domains/:id", DeleteDomain)
group.POST("/domains/:id/records", CreateRecord)
group.PUT("/domains/:id/records/:record_id", UpdateRecord)
group.DELETE("/domains/:id/records/:record_id", DeleteRecord)
group.PUT("/domains/:id/ddns", UpdateDDNSConfig)
group.DELETE("/domains/:id/ddns", DeleteDDNSConfig)
```

これらのハンドラは、DNSプロバイダサービスを呼び出し、DDNSジョブを修正します。

```go
record, err := svc.CreateRecord(c.Request.Context(), domainID, toRecordInput(payload))
...
record, err := svc.UpdateRecord(c.Request.Context(), domainID, recordID, toRecordInput(payload))
...
if err := cron.AddOrUpdateDDNSJob(domainID, cfg.IntervalSeconds); err != nil {
```

### Nginxの制御と設定に関する副作用

`api/nginx/router.go`は、`RequireSecureSession()`を使用せずに、いくつかのサーバー/Nginxの変更を行うエンドポイントを登録しています。

```go
r.POST("nginx/reload", Reload)
r.POST("nginx/restart", Restart)
r.POST("nginx/stub_status", ToggleStubStatus)
r.POST("nginx/performance", UpdatePerformanceSettings)
r.POST("nginx/modules/refresh", RefreshModulesCache)
```

これらのハンドラは、制御アクションを実行したり、Nginxに関連する変更を永続化したりします。

```go
func Reload(c *gin.Context) {
nginx.Control(nginx.Reload).Resp(c)
}

func Restart(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "ok",
})
go nginx.Restart()
}
```

```go
if json.Enable {
err = performance.EnableStubStatus()
} else {
err = performance.DisableStubStatus()
}
...
reloadOutput, err := nginx.Reload()
```

```go
err := performance.UpdatePerfOpt(&perfOpt)
```

### サイトとストリームに関する高度な/バッチ処理

`api/sites/router.go`では、セキュアセッショングループの前に、これらのサイトの変更処理が記述されています。

```go
r.PUT("sites", BatchUpdateSites)
r.POST("sites/:name/advance", DomainEditByAdvancedMode)
r.POST("auto_cert/:name", AddDomainToAutoCert)
r.DELETE("auto_cert/:name", RemoveDomainFromAutoCert)
r.POST("site_navigation/order", UpdateSiteOrder)
r.POST("site_navigation/health_check/:id", UpdateHealthCheck)
```

`DomainEditByAdvancedMode()`関数は、サイトの`Advanced`モードを永続化します。

```go
_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)
```

`AddDomainToAutoCert()`関数は、DNS認証情報ID、ドメイン、チャレンジ方法、およびACMEユーザーを含む、証明書自動化の設定を書き込みます。

```go
err = certModel.Updates(&model.Cert{
Name: name,
Domains: json.Domains,
AutoCert: model.AutoCertEnabled,
DnsCredentialID: json.DnsCredentialID,
ChallengeMethod: json.ChallengeMethod,
KeyType: helper.GetKeyType(json.KeyType),
ACMEUserID: json.ACMEUserID,
})
```

`api/streams/router.go`ファイルは、通常のストリームの保存/削除操作を保護しますが、バッチ更新や高度なモードは保護対象外です。

```go
r.PUT("streams", BatchUpdateStreams)
...
r.POST("streams/:name/advance", AdvancedEdit)
```

`AdvancedEdit()`関数は、ストリームの`Advanced`モードを永続化します。

```go
_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)
```

## 安全なローカル検証

私は、外部のNginx UIのデプロイメントにアクセスしていません。以下の検証は、チェックアウトされたソースコードツリーのみを検査し、以下のことを確認します。

- `RequireSecureSession()`は、実際のOTPによる10分間の段階的な認証メカニズムである。
- 高リスクの制御ルートは、意図的に他の場所で保護されている。
- 影響を受けるルートグループは認証されているが、`RequireSecureSession()`の範囲外である。
- 影響を受けるハンドラは、永続的な変更やサーバー側の副作用を引き起こす。

実行するコマンド:

```bash
python3 reports/nginx-ui-missing-secure-session-step-up-poc.py
```

出力例:

```json
{
"all_affected_groups_present": true,
"all_controls_present": true,
"all_side_effects_present": true,
"commit": "4948fba8744e82c3d7003f68ee164e9f49421066",
"result": "VULNERABLE_SOURCE_CONFIRMED",
"target": "0xJacky/nginx-ui"
}
```

完全なローカルでの検証結果は、私の作業環境にある `tmp/nginx-ui-missing-secure-session-step-up-poc-output.txt` ファイルで確認できます。

## HTTPによる再現手順の概要

これは、プロジェクトのセキュリティポリシーに沿って、隔離されたNginx UIテストインスタンスでのみ有効な再現手順の概要です。

1. OTP(ワンタイムパスワード)を有効にしたテストユーザーを作成します。
2. そのユーザーとしてログインし、通常の認証セッション/JWTのみを保持します。
3. 新しい `X-Secure-Session-ID` を取得または送信しないでください。
4. 期待される保護された機能の動作を確認します。

```http
POST /api/config
Authorization: <otp-user-session>
Content-Type: application/json

{ "name": "example.conf", "content": "server { listen 80; }" }
```

期待される結果: `401 Unauthorized`。これは、設定の変更には `RequireSecureSession()` が必要であるためです。

5. 同じOTPを有効にしたセッションを使用し、`X-Secure-Session-ID` を指定せずに、影響を受ける操作を試みます。例えば:

```http
POST /api/dns_credentials
Authorization: <otp-user-session>
Content-Type: application/json

{
"name": "attacker-controlled-test",
"provider": "cloudflare",
"provider_code": "cloudflare",
"token": "test-token"
}
```

期待されるセキュリティ上の動作: `401 Unauthorized`。これは、他の機密設定変更に対するセキュアセッション要件と一致します。

ソースコードのレビューから確認された実際の動作: リクエストは `AddDnsCredential()` に到達します。これは、DNS認証情報ルーティングが `AuthRequired()` の下にあるものの、`RequireSecureSession()` が適用されていないためです。

上記にリストされている他のルートグループにも、同様のセキュリティ強化の欠如が見られます。

## 影響

2段階認証(2FA)が有効なNginx UIのユーザーの認証済みWebセッション/JWTが攻撃者に取得された場合、セキュアセッションの境界は、攻撃者が新しいワンタイムパスワード(OTP)/パスキーの認証を通過するまで、彼らが実行できることを制限するはずです。現在の設定では、主要な管理操作がその境界の外にあるため、この制限が機能していません。

被害者のユーザーの権限とデプロイメント構成によっては、攻撃者は以下のような操作を実行できる可能性があります。

- DNSプロバイダーの認証情報を追加、編集、または削除する。
- 証明書やACMEの自動化を変更する。
- 自動バックアップジョブを作成、変更、削除、または復元する。
- S3のバックアップ先を設定し、S3接続テストを実行する。
- 設定されたプロバイダーを通じて、DNSドメイン、レコード、またはDDNSスケジュールを変更する。
- Nginxを再起動またはリロードする。
- stub-statusを有効/無効にする、またはパフォーマンス設定を変更する。
- サイト/ストリームを高度モードに切り替えたり、サイトの自動化設定を変更する。

これは、OTPが有効なアカウントに対するセキュアセッションの追加認証の価値を損なうものです。この問題は、認証前のアクセスやリモートコード実行を可能にするものではありません。問題は、機密性の高い認証済み操作に対する追加認証の許可が不足していることです。

## 提案される修正

推奨される対策:

1. サーバー、Nginx、証明書、DNS、バックアップ、認証情報、または自動化の状態を変更する、重要な認証済みAPIに対して、`middleware.RequireSecureSession()`を適用してください。
2. 少なくとも、以下の機能を保護してください。
- 自動バックアップの作成/変更/削除/復元/テスト(S3関連)
- DNS認証情報の作成/編集/削除
- 証明書およびACMEユーザー関連の変更
- DNSドメイン、レコード、およびDDNS関連の変更
- Nginxのリロード/再起動/ステータス確認/パフォーマンス調整/モジュールキャッシュ関連の変更
- サイト/ストリームの高度な設定、バッチ処理、自動証明書発行、ナビゲーション順序、およびヘルスチェック関連の変更
3. 既存のconfig/site/streamのセキュリティテストを反映した回帰テストを追加してください。
- OTP(ワンタイムパスワード)を有効にしたユーザーが`X-Secure-Session-ID`を持たない場合、`401`エラーを受け取る。
- OTPを有効にしたユーザーが有効なセキュアセッションIDを持っている場合、正常に処理される。
- OTPを有効にしていないユーザーは、意図された製品ポリシーに基づいて、既存の動作を維持する。
4. 「機密性の高い変更」のルーティングを集中化することを検討し、将来的な漏れを防ぎやすくする。

影響を受けるパッケージ:
- go github.com/0xJacky/Nginx-UI: 影響あり(バージョン1.9.10-0.20260703131214-23d1934099b0未満)、修正済み(バージョン1.9.10-0.20260703131214-23d1934099b0)