From 232a5711576733a65f2f5c721074ac07b1eb6e4d Mon Sep 17 00:00:00 2001 From: fengruixiang <> Date: Thu, 29 Oct 2026 15:56:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20S2=20IP=20=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=EF=BC=88token=20=E7=AB=AF=E7=82=B9=20+=20sen?= =?UTF-8?q?d-txt=EF=BC=8C=E7=BB=8F=E7=9C=9F=20IIS=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 临时改 oauth_client(setsail).ip_whitelist 与 run\ixserver\Web.config 的 Http_TrustedProxyIps, 验证:白名单不含本机时 token 401 / send-txt 401、伪造 XFF 无效、真链路回复不漏发; 127.0.0.1 与 ::1 不互通;分隔符与 CIDR;可信代理下 XFF 取最右的非代理段。结束(含异常)还原。 S2 其余鉴权负例(错 secret / 没 scope / 过期 token / 别的服务号)未包含。 Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + scenarios/s2-auth-ip-whitelist.ps1 | 277 +++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 scenarios/s2-auth-ip-whitelist.ps1 diff --git a/README.md b/README.md index ba99980..3f8a09d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ ```powershell .\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到 +.\scenarios\s2-auth-ip-whitelist.ps1 # IP 白名单:token 端点 + send-txt 经真 IIS 放行 / 拒绝、可信代理的 XFF 规则;临时改 oauth_client 与 Web.config,结束还原,约 2~3 分钟 .\scenarios\s5-ig-handoff.ps1 # 机器人工单 → SCRM 开工 + 客服上线 → 下一条:echo 在前、问句在后 → 点 Yes → 新人工工单 → 客服回复 → 旧单迟到回复 409 → 下班 .\scenarios\s8-human-chain.ps1 # SCRM 开工 → 新 IG 用户 → 人工工单推送 → 指派 / 回复 → IG 收到 → 结束 → 下班 ``` diff --git a/scenarios/s2-auth-ip-whitelist.ps1 b/scenarios/s2-auth-ip-whitelist.ps1 new file mode 100644 index 0000000..a2bd922 --- /dev/null +++ b/scenarios/s2-auth-ip-whitelist.ps1 @@ -0,0 +1,277 @@ +<# + S2(IP 白名单部分):第三方 client 的 oauth_client.ip_whitelist 与 Http_TrustedProxyIps 经真 IIS Express 生效, + token 端点 /api/oauth/token 与回复 API send-txt 两条路都验。S2 其余鉴权负例(错 secret / 没 scope / 过期 token / + 别的服务号)还没写进来。 + + 组别: + 0 白名单为空:不限来源,IG → 第三方 echo → IG 正常 + A 白名单不含本机:token 401 invalid_client;未过期 token 调 send-txt 401(WWW-Authenticate + problem+json); + 伪造 X-Forwarded-For 无效;真链路里第三方的回复被拒、IG 用户一条都收不到 + B 白名单只有 127.0.0.1:127.0.0.1 放行,::1 被拒(IPv4 / IPv6 本机地址不互通;第三方客户端经 localhost 进来是 ::1) + C 混写「203.0.113.10; ::1 127.0.0.0/8」:分隔符与 CIDR 都认,真链路恢复 + D 本机登记为可信代理:XFF 被采信,取最右边的非代理段,夹着解析不了的段就退回直连地址 + + 会临时改两处,结束时(含失败、抛异常)都还原: + - iXserverLoop.oauth_client(setsail).ip_whitelist —— 还原成开跑前的值 + - run\ixserver\Web.config 的 Http_TrustedProxyIps —— 先备份成 Web.config.s2-bak,改动会让站点重启一次; + 上次中途被打断留下的备份,开跑时先还原 + oauth_client 有 30 秒缓存,每次改表都要轮询到生效,加上一次站点重启,整个场景约 2~3 分钟。 + + 直连 iXserver 用 127.0.0.1 / [::1]:IIS Express 以 /path 模式只绑了 localhost,Host 头必须是 localhost:<端口>。 + 前提:.\loop.ps1 up 各角色都健康,chatbot 以 --auto-reply echo 运行。 + 通过退出 0,失败退出 1;两种情况都在 run\reports\ 生成场景报告(观察台在跑时)。 + 用法:.\scenarios\s2-auth-ip-whitelist.ps1 [-TimeoutSec 60] [-NoReport] +#> +param([int]$TimeoutSec = 60, [switch]$NoReport) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$LoopNoReport = [bool]$NoReport +. (Join-Path $PSScriptRoot '_lib.ps1') +Add-Type -AssemblyName System.Net.Http +$Cfg = $LoopCfg +$port = $Cfg.Ports.Ixserver +$base4 = "http://127.0.0.1:$port" +$base6 = "http://[::1]:$port" +$ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)" +$cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)" +$clientId = $Cfg.Secrets.SetsailClientId +$webConfig = Join-Path $PSScriptRoot '..\run\ixserver\Web.config' +$webConfigBackup = "$webConfig.s2-bak" +$http = New-Object System.Net.Http.HttpClient +$http.Timeout = [TimeSpan]::FromSeconds(30) +$script:failures = New-Object System.Collections.ArrayList + +function Check([string]$name, [bool]$ok, [string]$detail = '') { + Add-LoopStep $name $ok $detail + if (-not $ok) { [void]$script:failures.Add($name) } + Write-Host ("[{0}] {1} {2}" -f $(if ($ok) { 'PASS' } else { 'FAIL' }), $name, $detail) -ForegroundColor $(if ($ok) { 'Green' } else { 'Red' }) +} + +# ============================================================================ 改环境 + +function Invoke-LoopSql([string]$sql, [switch]$Scalar) { + $s = $Cfg.Sql + $conn = New-Object System.Data.SqlClient.SqlConnection "Server=$($s.Server);Database=$($s.IxserverDb);User Id=$($s.User);Password=$($s.Password)" + $conn.Open() + try { + $cmd = $conn.CreateCommand(); $cmd.CommandText = $sql + if ($Scalar) { $v = $cmd.ExecuteScalar(); if ($v -is [DBNull]) { return $null }; return $v } + [void]$cmd.ExecuteNonQuery() + } + finally { $conn.Close() } +} + +function Get-Whitelist { Invoke-LoopSql "SELECT ip_whitelist FROM dbo.oauth_client WHERE client_id = N'$clientId'" -Scalar } + +function Set-Whitelist($value) { + $v = if ($null -eq $value) { 'NULL' } else { "N'" + $value.Replace("'", "''") + "'" } + Invoke-LoopSql "UPDATE dbo.oauth_client SET ip_whitelist = $v, update_datetime = GETDATE() WHERE client_id = N'$clientId'" + Write-Host "--- ip_whitelist = $(if ($null -eq $value) { 'NULL' } else { "'$value'" })" -ForegroundColor Cyan +} + +# Web.config 改动让站点重启:首个请求才触发 Application_Start,内嵌的 WebSocket 也是那时起的,所以两样都等 +function Wait-IxserverAlive([int]$timeoutSec = 180) { + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + try { + if ((Send-Http 'GET' "$base4/" $null $null).Status -lt 500) { + $tcp = New-Object System.Net.Sockets.TcpClient + try { $tcp.Connect('127.0.0.1', $Cfg.Ports.WebSocket); return $true } catch { } finally { $tcp.Close() } + } + } + catch { } + Start-Sleep -Seconds 2 + } + throw "iXserver 在 $timeoutSec 秒内没恢复(HTTP :$port / WebSocket :$($Cfg.Ports.WebSocket))" +} + +function Set-TrustedProxies([string]$value) { + [xml]$x = Get-Content $webConfig -Raw -Encoding UTF8 + $node = $x.configuration.appSettings.add | Where-Object { $_.key -eq 'Http_TrustedProxyIps' } + if (-not $node) { throw 'run\ixserver\Web.config 里没有 Http_TrustedProxyIps 键' } + $node.value = $value + $x.Save((Resolve-Path $webConfig).Path) + Write-Host "--- Http_TrustedProxyIps = '$value'(站点重启)" -ForegroundColor Cyan + Start-Sleep -Seconds 3 + [void](Wait-IxserverAlive) +} + +function Restore-WebConfig { + if (-not (Test-Path $webConfigBackup)) { return } + $same = (Get-FileHash $webConfigBackup).Hash -eq (Get-FileHash $webConfig).Hash + if (-not $same) { Copy-Item $webConfigBackup $webConfig -Force } + Remove-Item $webConfigBackup -Force + if ($same) { return } # 没改过就不必让站点白重启一次 + Write-Host '--- Web.config 已还原(站点重启)' -ForegroundColor Cyan + Start-Sleep -Seconds 3 + [void](Wait-IxserverAlive) +} + +# ============================================================================ 直连 iXserver + +function Send-Http([string]$method, [string]$url, $content, [hashtable]$headers) { + $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($method), $url) + $req.Headers.Host = "localhost:$port" + if ($headers) { foreach ($k in $headers.Keys) { [void]$req.Headers.TryAddWithoutValidation($k, $headers[$k]) } } + if ($content) { $req.Content = $content } + $resp = $http.SendAsync($req).GetAwaiter().GetResult() + [pscustomobject]@{ + Status = [int]$resp.StatusCode + Body = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() + WwwAuth = ($resp.Headers.WwwAuthenticate | ForEach-Object { $_.Scheme }) -join ',' + ContentType = if ($resp.Content.Headers.ContentType) { $resp.Content.Headers.ContentType.MediaType } else { '' } + } +} + +function Get-Token([string]$base, [string]$xff = '') { + $form = New-Object 'System.Collections.Generic.Dictionary[string,string]' + $form['grant_type'] = 'client_credentials'; $form['client_id'] = $clientId; $form['client_secret'] = $Cfg.Secrets.SetsailClientSecret + $h = @{}; if ($xff) { $h['X-Forwarded-For'] = $xff } + $r = Send-Http 'POST' "$base/api/oauth/token" ([System.Net.Http.FormUrlEncodedContent]::new($form)) $h + $token = if ($r.Status -eq 200) { ($r.Body | ConvertFrom-Json).access_token } else { '' } + $r | Add-Member -NotePropertyName Token -NotePropertyValue $token -PassThru +} + +function Send-Txt([string]$base, [string]$token, [string]$ticketId, [string]$message, [string]$xff = '') { + $json = @{ ticket_id = $ticketId; message = $message } | ConvertTo-Json -Compress + $h = @{ Authorization = "Bearer $token" }; if ($xff) { $h['X-Forwarded-For'] = $xff } + Send-Http 'POST' "$base/api/chatbot/ticket/send-txt" ([System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json')) $h +} + +# oauth_client 有 30 秒缓存:改表后轮询到预期状态,返回用了几秒,超时返回 -1 +function Wait-TokenStatus([string]$base, [int]$expected, [int]$timeoutSec = 60) { + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt $timeoutSec) { + if ((Get-Token $base).Status -eq $expected) { return [int]$sw.Elapsed.TotalSeconds } + Start-Sleep -Seconds 2 + } + return -1 +} + +# ============================================================================ 真链路 + +# 同一位 IG 用户发一句,等第三方客户端的 echo:它的 send-txt 结果,以及 IG 用户有没有真收到 +function Invoke-EchoRoundTrip([string]$Igsid, [string]$Tag) { + $text = "loop-s2 $Tag " + [guid]::NewGuid().ToString('N').Substring(0, 6) + $e = Send-LoopIg $Igsid @{ kind = 'text'; text = $text } + if (-not $e) { throw "「$text」没送进 iXserver" } + $expected = "echo: $text" + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $reply = $null; $out = $null + while ((Get-Date) -lt $deadline) { + $reply = @((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'reply' -and $_.message -eq $expected }) | Select-Object -First 1 + $out = @(Get-LoopOutbound $Igsid $e.id | Where-Object { $_.summary -eq $expected }) | Select-Object -First 1 + if ($reply -and $out) { break } + # 被拒的回复:再多等几秒,确认它确实没有漏发到 IG + if ($reply -and $reply.response.status -ne 201) { + $out = Wait-LoopOutbound $Igsid $e.id { $_.summary -eq $expected } 3 + break + } + Start-Sleep -Milliseconds 500 + } + $status = if ($reply) { [string]$reply.response.status } else { '(客户端没有回复记录)' } + @{ Delivered = [bool]$out; Reply = $reply; Status = $status; Text = $text } +} + +# ============================================================================ 场景 + +Start-LoopRun 'S2' 'IP 白名单:token 端点 + send-txt(真 IIS)' +Restore-WebConfig # 上次中途被打断留下的备份 +$originalWhitelist = Get-Whitelist +$crash = '' +try { + Copy-Item $webConfig $webConfigBackup -Force + + # ---------------------------------------------------------------- 0. 白名单为空 + Set-Whitelist $null + [void](Wait-TokenStatus $base4 200) + Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null + $igsid = 'loop-s2-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + $err = Complete-LoopTnC $igsid $TimeoutSec + if ($err) { throw $err } + $rt = Invoke-EchoRoundTrip $igsid '0' + $ticketId = if ($rt.Reply) { [string]$rt.Reply.ticketId } else { '' } + Set-LoopTicket $ticketId + Check '0 白名单为空:IG → 第三方 echo → IG' ($rt.Delivered -and $rt.Status -eq '201') "ticket $ticketId,send-txt $($rt.Status)" + if (-not $ticketId) { throw '拿不到工单号,后面的 send-txt 用例没法跑' } + $earlyToken = (Get-Token $base4).Token + Check '0 白名单为空:127.0.0.1 与 ::1 都能换 token' ([bool]$earlyToken -and (Get-Token $base6).Status -eq 200) + + # ---------------------------------------------------------------- A. 白名单不含本机 + Set-Whitelist '203.0.113.0/24' + $sec = Wait-TokenStatus $base4 401 + Check 'A0 改表后生效(oauth_client 缓存 30 秒)' ($sec -ge 0) "$sec 秒后开始拒" + $r = Get-Token $base4 + $error_ = try { ($r.Body | ConvertFrom-Json).error } catch { '' } + Check 'A1 token 端点 127.0.0.1 → 401 invalid_client' ($r.Status -eq 401 -and $error_ -eq 'invalid_client') "HTTP $($r.Status) $($r.Body)" + $r = Get-Token $base6 + Check 'A2 token 端点 ::1 → 401' ($r.Status -eq 401) "HTTP $($r.Status)" + $r = Send-Txt $base4 $earlyToken $ticketId 'loop-s2 A3 must be rejected' + Check 'A3 未过期 token 调 send-txt → 401 + WWW-Authenticate: Bearer + problem+json' ($r.Status -eq 401 -and $r.WwwAuth -eq 'Bearer' -and $r.ContentType -eq 'application/problem+json') "HTTP $($r.Status) $($r.ContentType) WWW-Authenticate=$($r.WwwAuth)" + $t = Get-Token $base4 '203.0.113.10' + $s = Send-Txt $base4 $earlyToken $ticketId 'loop-s2 A4 must be rejected' '203.0.113.10' + Check 'A4 没有可信代理时伪造 X-Forwarded-For: 203.0.113.10 → 401' ($t.Status -eq 401 -and $s.Status -eq 401) "token $($t.Status) / send-txt $($s.Status)" + $rt = Invoke-EchoRoundTrip $igsid 'A5' + Check 'A5 真链路:第三方的回复被拒,IG 用户收不到' ((-not $rt.Delivered) -and $rt.Status -ne '201') "send-txt $($rt.Status),IG 收到=$($rt.Delivered)" + $leaked = @(Get-LoopOutbound $igsid 0 | Where-Object { $_.summary -like '*must be rejected*' }) + Check 'A6 被拒的 send-txt 一条都没发到 IG' ($leaked.Count -eq 0) "漏发 $($leaked.Count) 条" + + # ---------------------------------------------------------------- B. 只有 127.0.0.1 + Set-Whitelist '127.0.0.1' + $sec = Wait-TokenStatus $base4 200 + $t = Get-Token $base4 + $s = Send-Txt $base4 $t.Token $ticketId 'loop-s2 B1 from 127.0.0.1' + Check 'B1 白名单 127.0.0.1:从 127.0.0.1 换 token 200、send-txt 201' ($sec -ge 0 -and $t.Status -eq 200 -and $s.Status -eq 201) "生效 $sec 秒;token $($t.Status) / send-txt $($s.Status)" + $t6 = Get-Token $base6 + $s6 = Send-Txt $base6 $t.Token $ticketId 'loop-s2 B2 must be rejected' + Check 'B2 白名单 127.0.0.1:从 ::1 来 → 401(IPv4 / IPv6 本机地址不互通)' ($t6.Status -eq 401 -and $s6.Status -eq 401) "token $($t6.Status) / send-txt $($s6.Status)" + $rt = Invoke-EchoRoundTrip $igsid 'B3' + Check 'B3 真链路:第三方客户端经 localhost 进来是 ::1 → 被拒' ((-not $rt.Delivered) -and $rt.Status -ne '201') "send-txt $($rt.Status),IG 收到=$($rt.Delivered)" + + # ---------------------------------------------------------------- C. 混写 + Set-Whitelist '203.0.113.10; ::1 127.0.0.0/8' + $sec = Wait-TokenStatus $base6 200 + $t = Get-Token $base4 + Check 'C1 混写「203.0.113.10; ::1 127.0.0.0/8」:::1 命中单个地址、127.0.0.1 命中 /8' ($sec -ge 0 -and $t.Status -eq 200) "生效 $sec 秒;127.0.0.1 token $($t.Status)" + $rt = Invoke-EchoRoundTrip $igsid 'C2' + Check 'C2 真链路:白名单命中,IG → 第三方 echo → IG 恢复' ($rt.Delivered -and $rt.Status -eq '201') "send-txt $($rt.Status)" + + # ---------------------------------------------------------------- D. 可信反向代理 + Set-Whitelist '203.0.113.10' + Set-TrustedProxies '127.0.0.1,::1' + $t = Get-Token $base4 + Check 'D1 本机是可信代理、白名单 203.0.113.10:不带 XFF → 401' ($t.Status -eq 401) "HTTP $($t.Status)" + $t = Get-Token $base4 '203.0.113.10' + $s = Send-Txt $base4 $t.Token $ticketId 'loop-s2 D2 via trusted proxy' '203.0.113.10' + Check 'D2 经可信代理带 XFF: 203.0.113.10 → token 200、send-txt 201' ($t.Status -eq 200 -and $s.Status -eq 201) "token $($t.Status) / send-txt $($s.Status)" + $t = Get-Token $base4 '203.0.113.10, 198.51.100.7' + Check 'D3 XFF「203.0.113.10, 198.51.100.7」取最右的非代理段 198.51.100.7 → 401' ($t.Status -eq 401) "HTTP $($t.Status)" + $t = Get-Token $base4 '198.51.100.7, 203.0.113.10, ::1' + Check 'D4 XFF 末尾的可信代理 ::1 跳过,取 203.0.113.10 → 200' ($t.Status -eq 200) "HTTP $($t.Status)" + $t = Get-Token $base4 '203.0.113.10, garbage' + Check 'D5 XFF 夹着解析不了的段 → 退回直连地址 → 401' ($t.Status -eq 401) "HTTP $($t.Status)" +} +catch { + $crash = "场景中断:$($_.Exception.Message)" + Check $crash $false +} +finally { + Restore-WebConfig + Set-Whitelist $originalWhitelist +} + +if ($null -eq $originalWhitelist) { + $sec = Wait-TokenStatus $base6 200 + Check 'E 还原后 token 端点恢复' ($sec -ge 0) "$sec 秒" +} + +if ($script:failures.Count) { + Write-Host "S2 失败:$($script:failures.Count) 项 —— $($script:failures -join ';')" -ForegroundColor Red + Write-Host '--- chatbot 记录(最近 6 条)' + (Invoke-RestMethod "$cb/_api/state").events | Select-Object -First 6 | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host + Save-LoopReport -Passed $false -Reason $script:failures[0] + exit 1 +} +Write-Host 'S2 通过:IP 白名单在 token 端点与 send-txt 上按预期放行 / 拒绝,可信代理的 XFF 规则生效' -ForegroundColor Green +Save-LoopReport -Passed $true +exit 0