feat: add staged website access diagnostics

This commit is contained in:
samwaf
2026-10-27 13:40:50 +08:00
parent 3d8ce3935b
commit eeec24bf3a
24 changed files with 2635 additions and 78 deletions
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"SamWaf/globalobj"
"SamWaf/model/common/response"
"SamWaf/model/request"
"SamWaf/service/waf_service"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"unicode"
"github.com/gin-gonic/gin"
"golang.org/x/net/http/httpguts"
)
func validateWebsiteDiagnostic(req *request.WafHostDiagnosticReq) error {
if req.HostCode == "" || len(req.HostCode) > 64 || strings.IndexFunc(req.HostCode, unicode.IsControl) >= 0 {
return errors.New("请选择已保存的网站")
}
switch req.Phase {
case "inspect", "local_ingress", "origin", "access_url":
default:
return errors.New("诊断阶段不合法")
}
if req.Method != http.MethodGet && req.Method != http.MethodHead {
return errors.New("诊断仅支持GET或HEAD")
}
if req.OriginCursor < 0 || req.OriginCursor > 1000000 || len(req.ConfigFingerprint) > 64 {
return errors.New("诊断分页或配置标识不合法")
}
if req.Phase != "inspect" && len(req.ConfigFingerprint) != 64 {
return errors.New("请先检查配置,再执行网络诊断")
}
if req.VisitorURL == "" {
if req.Phase != "inspect" {
return errors.New("请填写实际访问网址")
}
} else {
if len(req.VisitorURL) > 4096 || strings.IndexFunc(req.VisitorURL, unicode.IsControl) >= 0 {
return errors.New("访问网址过长或包含无效字符")
}
u, err := url.Parse(req.VisitorURL)
if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || strings.Contains(req.VisitorURL, "#") || u.Opaque != "" {
return errors.New("请填写不含账号密码及片段的HTTP/HTTPS网址")
}
if strings.ContainsAny(u.Hostname(), "* \\") || strings.HasSuffix(u.Host, ":") {
return errors.New("请填写实际访问域名或IP")
}
if p := u.Port(); p != "" {
port, err := strconv.Atoi(p)
if err != nil || port < 1 || port > 65535 {
return errors.New("访问端口需在1–65535之间")
}
}
}
if len(req.Headers) > 20 {
return errors.New("自定义请求头最多20项")
}
total := 0
for _, h := range req.Headers {
total += len(h.Name) + len(h.Value)
if len(h.Name) > 128 || len(h.Value) > 2048 || !httpguts.ValidHeaderFieldName(h.Name) || !httpguts.ValidHeaderFieldValue(h.Value) || strings.IndexFunc(h.Value, unicode.IsControl) >= 0 {
return errors.New("自定义请求头名称或值不合法")
}
switch strings.ToLower(h.Name) {
case "host", "content-length", "transfer-encoding", "connection", "upgrade", "trailer", "te", "keep-alive", "expect":
return errors.New("不能覆盖连接或传输控制请求头")
}
if strings.HasPrefix(strings.ToLower(h.Name), "proxy-") || strings.HasPrefix(strings.ToLower(h.Name), "x-waf-") {
return errors.New("不能设置代理或内部控制请求头")
}
}
if total > 8192 {
return errors.New("自定义请求头总长度不能超过8KiB")
}
return nil
}
func (w *WafHostAPi) DiagnosticApi(c *gin.Context) {
// 诊断会发起真实网络探测,仅面向登录用户;OpenAPI Key 走独立授权体系,统一不受理
if isOpenApi, ok := c.Get("is_openapi"); ok {
if b, _ := isOpenApi.(bool); b {
response.FailWithMessage("诊断功能仅支持登录用户调用", c)
return
}
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var req request.WafHostDiagnosticReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("网站诊断参数解析失败", c)
return
}
if err := validateWebsiteDiagnostic(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
env := waf_service.WebsiteDiagnosticEnvironment{}
if engine := globalobj.GWAF_RUNTIME_OBJ_WAF_ENGINE; engine != nil {
env.Snapshot = engine.DiagnosticSnapshot
env.Listeners = engine.ServerOnline
}
result, err := waf_service.WafHostDiagnosticServiceApp.Diagnose(c.Request.Context(), req, env)
if err != nil {
response.FailWithDetailed(result, err.Error(), c)
return
}
response.OkWithDetailed(result, "诊断完成", c)
}
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/request"
"SamWaf/model/wafenginmodel"
"SamWaf/service/waf_service"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
)
func TestWebsiteDiagnosticWildcardInspection(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{}, &model.LoadBalance{}, &model.HostPathRule{})
host := model.Hosts{BaseOrm: baseorm.BaseOrm{Id: "wildcard-diagnostic", Tenant_ID: global.GWAF_TENANT_ID, USER_CODE: global.GWAF_USER_CODE}, Code: "wildcard-diagnostic", Host: "*", Port: 7822}
if err := global.GWAF_LOCAL_DB.Create(&host).Error; err != nil {
t.Fatal(err)
}
snap := wafenginmodel.WebsiteDiagnosticSnapshot{Host: host, Loaded: true, MatchCode: host.Code, MatchKind: "catch_all", Mode: "static"}
env := waf_service.WebsiteDiagnosticEnvironment{Snapshot: func(string, string, string, http.Header) (wafenginmodel.WebsiteDiagnosticSnapshot, error) {
return snap, nil
}}
req := request.WafHostDiagnosticReq{HostCode: host.Code, Phase: "inspect", Method: "GET"}
result, err := waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || len(result.Checks) != 1 || result.Checks[0].Reason != "URL_REQUIRED" || result.ConfigFingerprint != "" {
t.Fatalf("empty wildcard URL: %+v %v", result, err)
}
for _, address := range []string{"http://site.example:7822/?token=hidden-query", "http://192.0.2.10:7822/"} {
req.VisitorURL = address
result, err = waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || result.Checks[0].Status != "passed" || !strings.Contains(result.Checks[0].Message, "命中所选通配网站") {
t.Fatalf("concrete wildcard URL: %+v %v", result, err)
}
encoded, _ := json.Marshal(result)
if strings.Contains(string(encoded), "hidden-query") {
t.Fatal("wildcard inspection echoed query value")
}
}
snap.MatchCode = "another-site"
result, err = waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || result.Checks[0].Reason != "HOST_MISMATCH" {
t.Fatalf("different site must not pass: %+v %v", result, err)
}
}
func TestWebsiteDiagnosticValidation(t *testing.T) {
base := request.WafHostDiagnosticReq{HostCode: "host-1", Phase: "inspect", VisitorURL: "http://site.example:9112/", Method: "GET"}
if err := validateWebsiteDiagnostic(&base); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
edit func(*request.WafHostDiagnosticReq)
}{
{"method", func(r *request.WafHostDiagnosticReq) { r.Method = "POST" }},
{"phase", func(r *request.WafHostDiagnosticReq) { r.Phase = "other" }},
{"fingerprint", func(r *request.WafHostDiagnosticReq) { r.Phase = "origin" }},
{"credentials", func(r *request.WafHostDiagnosticReq) { r.VisitorURL = "http://user:pass@site.example/" }},
{"fragment", func(r *request.WafHostDiagnosticReq) { r.VisitorURL += "#part" }},
{"wildcard", func(r *request.WafHostDiagnosticReq) { r.VisitorURL = "http://*.example/" }},
{"port", func(r *request.WafHostDiagnosticReq) { r.VisitorURL = "http://site.example:65536/" }},
{"negative cursor", func(r *request.WafHostDiagnosticReq) { r.OriginCursor = -1 }},
{"header host", func(r *request.WafHostDiagnosticReq) {
r.Headers = []request.WebsiteDiagnosticHeader{{Name: "Host", Value: "other"}}
}},
{"header control", func(r *request.WafHostDiagnosticReq) {
r.Headers = []request.WebsiteDiagnosticHeader{{Name: "X-Test", Value: "a\r\nb"}}
}},
{"header transport", func(r *request.WafHostDiagnosticReq) {
r.Headers = []request.WebsiteDiagnosticHeader{{Name: "Connection", Value: "X-Test"}}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := base
tc.edit(&req)
if validateWebsiteDiagnostic(&req) == nil {
t.Fatal("invalid diagnostic request accepted")
}
})
}
}
func TestWebsiteDiagnosticSavedConfigAndProbe(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{}, &model.LoadBalance{}, &model.HostPathRule{})
var hits atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.Host != "site.example:9112" {
t.Errorf("unexpected Host %q", r.Host)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", "session=cookie-secret")
_, _ = w.Write([]byte(`{"status":"ok","token":"body-secret"}`))
}))
defer origin.Close()
hostIP, portText, _ := net.SplitHostPort(origin.Listener.Addr().String())
port, _ := strconv.Atoi(portText)
host := model.Hosts{BaseOrm: baseorm.BaseOrm{Id: "diagnostic-host", Tenant_ID: global.GWAF_TENANT_ID, USER_CODE: global.GWAF_USER_CODE}, Code: "diagnostic-host", Host: "site.example", Port: 9112, Remote_host: "http://origin.example", Remote_ip: hostIP, Remote_port: port}
if err := global.GWAF_LOCAL_DB.Create(&host).Error; err != nil {
t.Fatal(err)
}
snap := wafenginmodel.WebsiteDiagnosticSnapshot{Loaded: true, Host: host, MatchCode: host.Code, MatchKind: "exact", Mode: "proxy", OriginPlans: []wafenginmodel.WebsiteDiagnosticOriginPlan{{URL: "http://origin.example:" + portText + "/", Host: "site.example:9112", FixedAddress: origin.Listener.Addr().String(), FallbackAddress: "origin.example:" + portText}}}
env := waf_service.WebsiteDiagnosticEnvironment{Snapshot: func(string, string, string, http.Header) (wafenginmodel.WebsiteDiagnosticSnapshot, error) {
return snap, nil
}}
req := request.WafHostDiagnosticReq{HostCode: host.Code, Phase: "inspect", VisitorURL: "http://site.example:9112/?token=input-secret", Method: "GET"}
res, err := waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || len(res.Checks) != 1 || res.Checks[0].Status != "passed" || hits.Load() != 0 {
t.Fatalf("inspect: %+v %v hits=%d", res, err, hits.Load())
}
body, _ := json.Marshal(res)
if strings.Contains(string(body), "input-secret") {
t.Fatal("inspection echoed sensitive query")
}
req.ConfigFingerprint, req.Phase = res.ConfigFingerprint, "origin"
res, err = waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || len(res.Checks) != 1 || res.Checks[0].StatusCode != 200 || hits.Load() != 1 {
t.Fatalf("origin: %+v %v hits=%d", res, err, hits.Load())
}
body, _ = json.Marshal(res)
for _, secret := range []string{"cookie-secret", "body-secret", "input-secret"} {
if strings.Contains(string(body), secret) {
t.Fatalf("response contains %s", secret)
}
}
if err := global.GWAF_LOCAL_DB.Model(&host).Update("remote_port", port+1).Error; err != nil {
t.Fatal(err)
}
res, err = waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err == nil || res.ErrorCode != "CONFIG_CHANGED" || hits.Load() != 1 {
t.Fatalf("changed config should stop: %+v %v", res, err)
}
if err := global.GWAF_LOCAL_DB.Model(&host).Update("tenant_id", "other-tenant").Error; err != nil {
t.Fatal(err)
}
if _, err := waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env); err == nil {
t.Fatal("foreign website accepted")
}
}
func TestWebsiteDiagnosticRejectsOpenAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/wafhost/host/diagnostic", strings.NewReader(`{"host_code":"host-1","phase":"inspect","method":"GET"}`))
c.Set("is_openapi", true)
new(WafHostAPi).DiagnosticApi(c)
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Msg != "诊断功能仅支持登录用户调用" {
t.Fatalf("openapi key caller must be rejected: %s", w.Body.String())
}
}
func TestWebsiteDiagnosticLocalPortAndRuntimeBoundaries(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{}, &model.LoadBalance{}, &model.HostPathRule{})
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.WriteHeader(http.StatusNoContent) }))
defer server.Close()
_, portText, _ := net.SplitHostPort(server.Listener.Addr().String())
port, _ := strconv.Atoi(portText)
host := model.Hosts{BaseOrm: baseorm.BaseOrm{Id: "local-diagnostic", Tenant_ID: global.GWAF_TENANT_ID, USER_CODE: global.GWAF_USER_CODE}, Code: "local-diagnostic", Host: "site.example", Port: port, Remote_host: "http://origin.example", Remote_port: 8080}
if err := global.GWAF_LOCAL_DB.Create(&host).Error; err != nil {
t.Fatal(err)
}
listeners := wafenginmodel.NewSafeServerMap()
listeners.Set(port, innerbean.ServerRunTime{Port: port, ServerType: "http", Status: 0, Svr: server.Config})
snap := wafenginmodel.WebsiteDiagnosticSnapshot{Host: host, Loaded: true, MatchCode: host.Code, MatchKind: "exact", Mode: "static"}
env := waf_service.WebsiteDiagnosticEnvironment{Snapshot: func(string, string, string, http.Header) (wafenginmodel.WebsiteDiagnosticSnapshot, error) {
return snap, nil
}, Listeners: listeners}
req := request.WafHostDiagnosticReq{HostCode: host.Code, Phase: "inspect", VisitorURL: "http://site.example:" + portText + "/", Method: "HEAD"}
inspected, err := waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil {
t.Fatal(err)
}
req.Phase, req.ConfigFingerprint = "local_ingress", inspected.ConfigFingerprint
result, err := waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || result.Checks[0].StatusCode != 204 || hits.Load() != 1 {
t.Fatalf("local result %+v %v", result, err)
}
oldPort := global.GWAF_LOCAL_SERVER_PORT
global.GWAF_LOCAL_SERVER_PORT = port
t.Cleanup(func() { global.GWAF_LOCAL_SERVER_PORT = oldPort })
result, err = waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err != nil || result.Checks[0].Status != "skipped" || hits.Load() != 1 {
t.Fatalf("management listener must not be called: %+v %v", result, err)
}
global.GWAF_LOCAL_SERVER_PORT = oldPort
snap.Host.Remote_port++
result, err = waf_service.WafHostDiagnosticServiceApp.Diagnose(context.Background(), req, env)
if err == nil || result.ErrorCode != "RUNTIME_CHANGED" || hits.Load() != 1 {
t.Fatalf("runtime changes must stop: %+v %v", result, err)
}
}
+2 -1
View File
@@ -167,7 +167,8 @@ func OpenApiLogMiddleware() gin.HandlerFunc {
contentType := c.GetHeader("Content-Type")
isFileRequest := strings.Contains(contentType, "multipart") ||
strings.Contains(path, "/download") ||
strings.Contains(path, "/upload")
strings.Contains(path, "/upload") ||
strings.HasSuffix(path, "/api/v1/wafhost/host/diagnostic")
if !isFileRequest && c.Request.Body != nil {
bodyBytes, err := io.ReadAll(c.Request.Body)
+3
View File
@@ -15,6 +15,9 @@ import (
// SecApi 鉴权中间件
func SecApi() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasSuffix(c.Request.URL.Path, "/api/v1/wafhost/host/diagnostic") {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read request body"})
+17
View File
@@ -0,0 +1,17 @@
package request
type WebsiteDiagnosticHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
type WafHostDiagnosticReq struct {
HostCode string `json:"host_code"`
Phase string `json:"phase"`
VisitorURL string `json:"visitor_url"`
Method string `json:"method"`
Headers []WebsiteDiagnosticHeader `json:"headers"`
UseSystemProxy bool `json:"use_system_proxy"`
ConfigFingerprint string `json:"config_fingerprint"`
OriginCursor int `json:"origin_cursor"`
}
+45
View File
@@ -0,0 +1,45 @@
package response
type WebsiteDiagnosticField struct {
Label string `json:"label"`
Value string `json:"value"`
}
type WebsiteDiagnosticAdvice struct {
Code string `json:"code"`
Message string `json:"message"`
Tab string `json:"tab"`
Section string `json:"section"`
}
type WebsiteDiagnosticCheck struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Reason string `json:"reason"`
Message string `json:"message"`
Overview []WebsiteDiagnosticField `json:"overview"`
Request string `json:"request"`
Response string `json:"response"`
Connection []WebsiteDiagnosticField `json:"connection"`
Advice []WebsiteDiagnosticAdvice `json:"advice"`
StatusCode int `json:"status_code"`
BodyTruncated bool `json:"body_truncated"`
}
type WebsiteDiagnosticSite struct {
HostCode string `json:"host_code"`
Title string `json:"title"`
VisitorURL string `json:"visitor_url"`
}
type WafHostDiagnosticResponse struct {
Phase string `json:"phase"`
ConfigFingerprint string `json:"config_fingerprint"`
Site WebsiteDiagnosticSite `json:"site"`
Checks []WebsiteDiagnosticCheck `json:"checks"`
NextCursor int `json:"next_cursor"`
HasMore bool `json:"has_more"`
Total int `json:"total"`
ErrorCode string `json:"error_code,omitempty"`
}
+36
View File
@@ -0,0 +1,36 @@
package wafenginmodel
import (
"SamWaf/model"
"net/http"
)
// WebsiteDiagnosticSnapshot 是管理端诊断使用的运行配置副本。
type WebsiteDiagnosticSnapshot struct {
Host model.Hosts `json:"-"`
TargetHost string `json:"-"`
Loaded bool `json:"-"`
MatchCode string `json:"-"`
MatchKind string `json:"-"`
Steps []string `json:"-"`
Mode string `json:"-"`
PathRule *model.HostPathRule `json:"-"`
PathRules []model.HostPathRule `json:"-"`
Backends []model.LoadBalance `json:"-"`
OriginPlans []WebsiteDiagnosticOriginPlan `json:"-"`
Limitations []string `json:"-"`
}
// WebsiteDiagnosticOriginPlan 只描述请求构造,不执行连接或选择负载后端。
type WebsiteDiagnosticOriginPlan struct {
Kind string `json:"-"`
Error string `json:"-"`
BackendID string `json:"-"`
URL string `json:"-"`
Host string `json:"-"`
Headers http.Header `json:"-"`
FixedAddress string `json:"-"`
FallbackAddress string `json:"-"`
ServerName string `json:"-"`
InsecureSkipVerify bool `json:"-"`
}
+1
View File
@@ -27,6 +27,7 @@ func (receiver *HostRouter) InitHostRouter(group *gin.RouterGroup) {
hostRouter.GET("/api/v1/wafhost/host/ipsource/probe", hostApi.IPSourceProbeApi) //查看最近到达的真实请求头(排查真实IP来源)
hostRouter.GET("/api/v1/wafhost/host/ipsource/probe/clear", hostApi.IPSourceProbeClearApi) //清空采样
hostRouter.POST("/api/v1/wafhost/host/health/test", middleware.RequireRole(enums.ROLE_SECURITY_ADMIN), hostApi.TestHealthApi)
hostRouter.POST("/api/v1/wafhost/host/diagnostic", middleware.RequireRole(enums.ROLE_SECURITY_ADMIN), hostApi.DiagnosticApi)
hostRouter.POST("/api/v1/wafhost/host/checkports", hostApi.CheckPortsApi) //保存前预检端口监听表(issue #955)
hostRouter.GET("/api/v1/wafhost/port/overview", hostApi.GetPortOverviewApi) //端口占用总览
+57
View File
@@ -0,0 +1,57 @@
package router
import (
"SamWaf/enums"
"SamWaf/middleware"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestWebsiteDiagnosticRouteAuthenticationAndRoles(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name, role string
auth, openAPI bool
code int
message string
}{
{name: "unauthenticated", auth: true, code: -999},
{name: "super admin", role: enums.ROLE_SUPER_ADMIN, code: -1, message: "网站诊断参数解析失败"},
{name: "security admin", role: enums.ROLE_SECURITY_ADMIN, code: -1, message: "网站诊断参数解析失败"},
{name: "system admin", role: enums.ROLE_SYSTEM_ADMIN, code: -403, message: "当前账号角色无权访问该功能"},
{name: "audit admin", role: enums.ROLE_AUDIT_ADMIN, code: -403, message: "当前账号角色无权访问该功能"},
{name: "openapi", openAPI: true, code: -1, message: "诊断功能仅支持登录用户调用"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := gin.New()
group := r.Group("")
if tc.auth {
group.Use(middleware.Auth())
} else {
group.Use(func(c *gin.Context) {
c.Set("userRole", tc.role)
if tc.openAPI {
c.Set("is_openapi", true)
}
})
}
new(HostRouter).InitHostRouter(group)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/wafhost/host/diagnostic", nil))
var reply struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &reply); err != nil {
t.Fatal(err)
}
if reply.Code != tc.code || (tc.message != "" && reply.Msg != tc.message) {
t.Fatalf("response=%s, want code=%d message=%q", rec.Body.String(), tc.code, tc.message)
}
})
}
}
+389
View File
@@ -0,0 +1,389 @@
package waf_service
import (
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/request"
resp "SamWaf/model/response"
"SamWaf/model/wafenginmodel"
"SamWaf/utils"
"SamWaf/wafdiagnose"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type WafHostDiagnosticService struct{}
var WafHostDiagnosticServiceApp = new(WafHostDiagnosticService)
var websiteDiagnosticSlots = make(chan struct{}, 2)
var diagnosticFingerprintKey = func() []byte {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
panic("diagnostic fingerprint initialization failed")
}
return key
}()
type WebsiteDiagnosticEnvironment struct {
Snapshot func(string, string, string, http.Header) (wafenginmodel.WebsiteDiagnosticSnapshot, error)
Listeners *wafenginmodel.SafeServerMap
}
type diagnosticConfiguration struct {
Host model.Hosts
Backends []model.LoadBalance
Rules []model.HostPathRule
}
func diagnosticHostConfiguration(h model.Hosts) model.Hosts {
h.BaseOrm = baseorm.BaseOrm{}
h.Nickname, h.REMARKS, h.GroupCode = "", "", ""
return h
}
func diagnosticConfigBytes(cfg diagnosticConfiguration) []byte {
backends := append([]model.LoadBalance(nil), cfg.Backends...)
rules := append([]model.HostPathRule(nil), cfg.Rules...)
for i := range backends {
id := backends[i].Id
backends[i].BaseOrm = baseorm.BaseOrm{Id: id}
}
for i := range rules {
id := rules[i].Id
rules[i].BaseOrm = baseorm.BaseOrm{Id: id}
}
sort.Slice(backends, func(i, j int) bool { return backends[i].Id < backends[j].Id })
sort.Slice(rules, func(i, j int) bool { return rules[i].Id < rules[j].Id })
data, _ := json.Marshal([]interface{}{diagnosticHostConfiguration(cfg.Host), backends, rules})
return data
}
func diagnosticFingerprint(cfg diagnosticConfiguration, snap wafenginmodel.WebsiteDiagnosticSnapshot, req request.WafHostDiagnosticReq) string {
mac := hmac.New(sha256.New, diagnosticFingerprintKey)
mac.Write(diagnosticConfigBytes(cfg))
data, _ := json.Marshal([]interface{}{snap.Loaded, snap.MatchCode, snap.MatchKind, req.VisitorURL, req.Method, req.Headers, req.UseSystemProxy})
mac.Write(data)
return hex.EncodeToString(mac.Sum(nil))
}
func loadDiagnosticConfiguration(ctx context.Context, code string) (diagnosticConfiguration, error) {
var cfg diagnosticConfiguration
if global.GWAF_LOCAL_DB == nil {
return cfg, errors.New("网站配置存储不可用")
}
db := global.GWAF_LOCAL_DB.WithContext(ctx)
if err := db.Where("code = ? AND tenant_id = ? AND user_code = ?", code, global.GWAF_TENANT_ID, global.GWAF_USER_CODE).First(&cfg.Host).Error; err != nil {
return cfg, errors.New("网站不存在或不可访问")
}
if cfg.Host.GLOBAL_HOST == 1 {
return cfg, errors.New("请选择具体网站进行诊断")
}
if err := db.Where("host_code = ? AND tenant_id = ? AND user_code = ?", code, global.GWAF_TENANT_ID, global.GWAF_USER_CODE).Order("create_time asc").Order("id asc").Find(&cfg.Backends).Error; err != nil {
return cfg, errors.New("读取网站后端失败")
}
if err := db.Where("host_code = ?", code).Find(&cfg.Rules).Error; err != nil {
return cfg, errors.New("读取网站路径配置失败")
}
return cfg, nil
}
func defaultDiagnosticURL(h model.Hosts) string {
if h.Host == "" || strings.Contains(h.Host, "*") {
return ""
}
for _, listen := range utils.ResolveHostListens(h) {
if listen.IsMain {
return (&url.URL{Scheme: listen.Protocol, Host: net.JoinHostPort(h.Host, strconv.Itoa(listen.Port)), Path: "/"}).String()
}
}
return ""
}
func diagnosticField(label, value string) resp.WebsiteDiagnosticField {
return resp.WebsiteDiagnosticField{Label: label, Value: value}
}
func diagnosticAdvice(code, message, section string) resp.WebsiteDiagnosticAdvice {
tab := "basic"
if section == "tls" {
tab = "other"
}
return resp.WebsiteDiagnosticAdvice{Code: code, Message: message, Tab: tab, Section: section}
}
func diagnosticCheck(id, title, status, reason, message string) resp.WebsiteDiagnosticCheck {
return resp.WebsiteDiagnosticCheck{ID: id, Title: title, Status: status, Reason: reason, Message: message,
Overview: []resp.WebsiteDiagnosticField{}, Connection: []resp.WebsiteDiagnosticField{}, Advice: []resp.WebsiteDiagnosticAdvice{}}
}
func (s *WafHostDiagnosticService) Diagnose(ctx context.Context, req request.WafHostDiagnosticReq, env WebsiteDiagnosticEnvironment) (resp.WafHostDiagnosticResponse, error) {
result := resp.WafHostDiagnosticResponse{Phase: req.Phase, Checks: []resp.WebsiteDiagnosticCheck{}, NextCursor: -1}
select {
case websiteDiagnosticSlots <- struct{}{}:
defer func() { <-websiteDiagnosticSlots }()
default:
return result, errors.New("网站诊断繁忙,请稍后重试")
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cfg, err := loadDiagnosticConfiguration(ctx, req.HostCode)
if err != nil {
return result, err
}
defaultURL := ""
if req.VisitorURL == "" {
req.VisitorURL = defaultDiagnosticURL(cfg.Host)
defaultURL = req.VisitorURL
}
result.Site = resp.WebsiteDiagnosticSite{HostCode: cfg.Host.Code, Title: fmt.Sprintf("%s:%d", cfg.Host.Host, cfg.Host.Port), VisitorURL: defaultURL}
if req.VisitorURL == "" {
result.Checks = append(result.Checks, diagnosticCheck("inspect", "配置与匹配", "warning", "URL_REQUIRED", "通配域名需要填写用户实际访问的网址"))
return result, nil
}
visitor, err := url.Parse(req.VisitorURL)
if err != nil || visitor.Hostname() == "" || (visitor.Scheme != "http" && visitor.Scheme != "https") {
return result, errors.New("访问网址格式不正确")
}
headers := make(http.Header)
for _, h := range req.Headers {
headers.Add(h.Name, h.Value)
}
var snap wafenginmodel.WebsiteDiagnosticSnapshot
if env.Snapshot != nil {
snap, err = env.Snapshot(req.HostCode, req.VisitorURL, req.Method, headers)
if err != nil {
return result, errors.New("读取网站运行视图失败")
}
}
result.ConfigFingerprint = diagnosticFingerprint(cfg, snap, req)
if req.Phase != "inspect" && !hmac.Equal([]byte(req.ConfigFingerprint), []byte(result.ConfigFingerprint)) {
result.ErrorCode = "CONFIG_CHANGED"
return result, errors.New("配置或访问参数已变化,请重新诊断")
}
consistent := snap.Loaded && hmac.Equal(diagnosticConfigBytes(cfg), diagnosticConfigBytes(diagnosticConfiguration{snap.Host, snap.Backends, snap.PathRules}))
if req.Phase == "inspect" {
check := diagnosticCheck("inspect", "配置与匹配", "passed", "CONFIG_MATCHED", "当前URL按运行路由匹配到所选网站;CDN/NAT改写后的请求需另行确认")
if !snap.Loaded {
check.Status, check.Reason, check.Message = "unknown", "NOT_LOADED", "当前运行视图中尚未加载所选网站"
} else if !consistent {
check.Status, check.Reason, check.Message = "warning", "RUNTIME_CHANGED", "保存配置与运行配置不同,网络阶段将停止,请确认配置加载状态后重测"
} else if snap.MatchCode != cfg.Host.Code {
check.Status, check.Reason, check.Message = "warning", "HOST_MISMATCH", "按访问URL推演未匹配所选网站;请核对域名、端口或前置转发改写"
}
matchType := snap.MatchKind
switch snap.MatchKind {
case "exact":
matchType = "精确匹配(域名 + 端口)"
case "no_port":
matchType = "来源不限端口匹配"
case "alias":
matchType = "别名精确匹配"
case "wildcard", "alias_wildcard":
matchType = "子域名通配匹配"
case "catch_all":
matchType = "通用网站匹配(* + 端口)"
case "catch_all_no_port":
matchType = "通用网站匹配(*,不限制来源端口)"
case "unmatched":
matchType = "未匹配到任何网站"
}
if check.Status == "passed" && strings.Contains(cfg.Host.Host, "*") {
check.Message = fmt.Sprintf("本次访问 %s 命中所选通配网站 %s(%s);结论仅适用于本次具体访问地址,CDN/NAT改写后的请求需另行确认", visitor.Host, cfg.Host.Host, matchType)
}
port := diagnosticURLPort(visitor)
matchedListener := false
var listens []string
for _, listen := range utils.ResolveHostListens(cfg.Host) {
listens = append(listens, fmt.Sprintf("%d / %s / %s", listen.Port, listen.Protocol, listen.IPVersion))
if strconv.Itoa(listen.Port) == port && listen.Protocol == visitor.Scheme {
matchedListener = true
}
}
if check.Status == "passed" && !matchedListener {
check.Status, check.Reason, check.Message = "warning", "LISTENER_MISMATCH", "访问协议或端口与本站监听不一致;公网映射场景需核对映射后的入口"
}
check.Overview = []resp.WebsiteDiagnosticField{diagnosticField("实际访问域名", visitor.Hostname()), diagnosticField("访问协议与端口", visitor.Scheme+" / "+port), diagnosticField("保存监听", strings.Join(listens, ";")), diagnosticField("所选网站", cfg.Host.Host), diagnosticField("是否命中所选网站", strconv.FormatBool(snap.MatchCode == cfg.Host.Code)), diagnosticField("路由匹配类型", matchType), diagnosticField("保存与运行时一致", strconv.FormatBool(consistent)), diagnosticField("运行处理方式", snap.Mode), diagnosticField("来源严格端口", strconv.FormatBool(cfg.Host.UnrestrictedPort == 0))}
check.Advice = append(check.Advice, diagnosticAdvice(check.Reason, "核对访问域名、协议、端口与网站配置;经过CDN/NAT时核对转发后的Host及目标端口", "listen"))
result.Checks = append(result.Checks, check)
return result, nil
}
if !consistent {
result.ErrorCode = "RUNTIME_CHANGED"
return result, errors.New("保存配置与运行时不一致,请确认加载状态后重新诊断")
}
guard, err := newHealthDialGuard(env.Listeners)
if err != nil {
return result, err
}
switch req.Phase {
case "local_ingress":
result.Checks = []resp.WebsiteDiagnosticCheck{s.localIngress(ctx, req, cfg, snap, visitor, headers, env.Listeners, guard)}
case "origin":
result.Total = len(snap.OriginPlans)
if len(snap.OriginPlans) == 0 {
result.Checks = append(result.Checks, diagnosticCheck("origin", "源站检测", "skipped", "NO_ORIGIN", "当前路径处理方式为"+snap.Mode+",没有需要独立检测的回源目标"))
break
}
if req.OriginCursor >= len(snap.OriginPlans) {
return result, errors.New("源站分页已变化,请重新诊断")
}
end := req.OriginCursor + 5
if end > len(snap.OriginPlans) {
end = len(snap.OriginPlans)
}
plans := snap.OriginPlans[req.OriginCursor:end]
result.Checks = make([]resp.WebsiteDiagnosticCheck, len(plans))
var wg sync.WaitGroup
workers := make(chan struct{}, 2)
for i, plan := range plans {
wg.Add(1)
go func(i int, plan wafenginmodel.WebsiteDiagnosticOriginPlan) {
defer wg.Done()
select {
case workers <- struct{}{}:
defer func() { <-workers }()
case <-ctx.Done():
result.Checks[i] = diagnosticCheck(fmt.Sprintf("origin-%d", req.OriginCursor+i), "源站检测", "skipped", "CANCELLED", "诊断已取消或超时")
return
}
id := fmt.Sprintf("origin-%d", req.OriginCursor+i)
if plan.Error != "" {
result.Checks[i] = diagnosticCheck(id, "源站检测", "failed", "ORIGIN_CONFIG", "源站地址无法构造,请检查已保存的回源配置")
return
}
r := wafdiagnose.Run(ctx, wafdiagnose.Target{URL: plan.URL, Method: req.Method, Headers: plan.Headers, Host: plan.Host, FixedAddress: plan.FixedAddress, Fallback: plan.FallbackAddress != "", InsecureSkipVerify: plan.InsecureSkipVerify, DialGuard: guard})
result.Checks[i] = diagnosticProbeCheck(id, "源站检测 "+strconv.Itoa(req.OriginCursor+i+1), "独立源站请求,不是WAF实际转发报文", r)
}(i, plan)
}
wg.Wait()
if end < result.Total {
result.NextCursor, result.HasMore = end, true
}
case "access_url":
r := wafdiagnose.Run(ctx, wafdiagnose.Target{URL: req.VisitorURL, Method: req.Method, Headers: headers, Public: true, AllowProxy: req.UseSystemProxy, DialGuard: guard})
source := "服务器视角直连且不使用环境代理,不能代表用户浏览器网络"
if req.UseSystemProxy {
source = "服务器视角经系统代理访问,不能代表用户浏览器网络"
}
check := diagnosticProbeCheck("access_url", "实际访问地址", source, r)
if !req.UseSystemProxy && utils.HasSystemProxy() && check.Status == "failed" {
check.Advice = append(check.Advice, diagnosticAdvice("USE_SYSTEM_PROXY",
"检测到运行环境已配置系统代理;如本站经代理出网,可开启「跟随系统代理」后重试", "listen"))
}
result.Checks = []resp.WebsiteDiagnosticCheck{check}
default:
return result, errors.New("诊断阶段不合法")
}
return result, nil
}
func diagnosticURLPort(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
func (s *WafHostDiagnosticService) localIngress(ctx context.Context, req request.WafHostDiagnosticReq, cfg diagnosticConfiguration, snap wafenginmodel.WebsiteDiagnosticSnapshot, visitor *url.URL, headers http.Header, listeners *wafenginmodel.SafeServerMap, originGuard func(string) error) resp.WebsiteDiagnosticCheck {
skipped := func(code, message string) resp.WebsiteDiagnosticCheck {
c := diagnosticCheck("local_ingress", "本机WAF入口", "skipped", code, message)
c.Advice = append(c.Advice, diagnosticAdvice(code, "检查基础内容中的监听协议、端口与来源域名;本机检测仅连接本站已登记业务端口", "listen"))
return c
}
if cfg.Host.START_STATUS != 0 || snap.MatchCode != cfg.Host.Code {
return skipped("HOST_MISMATCH", "站点已关闭或当前URL未匹配所选网站,未向其他本机入口发送请求")
}
port, _ := strconv.Atoi(diagnosticURLPort(visitor))
if port == global.GWAF_LOCAL_SERVER_PORT || listeners == nil {
return skipped("LISTENER_UNAVAILABLE", "缺少可使用的本站业务监听")
}
var selected *utils.HostListen
for _, l := range utils.ResolveHostListens(cfg.Host) {
if l.Port == port && l.Protocol == visitor.Scheme {
copy := l
selected = &copy
break
}
}
if selected == nil {
return skipped("LISTENER_MISMATCH", "访问协议或端口不属于所选网站监听;未猜测或扫描其他本机端口")
}
runtime, ok := listeners.Get(port)
if !ok || runtime.Status != 0 || runtime.ServerType != selected.Protocol || runtime.Svr == nil {
return skipped("LISTENER_UNAVAILABLE", "本站监听尚无可检测的运行记录;登记状态本身不证明绑定成功")
}
unverified, err := diagnosticOriginLoopCheck(ctx, snap.OriginPlans, originGuard)
if err != nil {
return skipped("ORIGIN_LOOP_OR_UNKNOWN", "回源目标指向本机业务监听;本次不触发入口回源,请先检查独立源站结果")
}
ip := "127.0.0.1"
if selected.IPVersion == "ipv6" {
ip = "::1"
}
address := net.JoinHostPort(ip, strconv.Itoa(port))
localGuard := func(actual string) error {
if actual != address || port == global.GWAF_LOCAL_SERVER_PORT {
return errors.New("本机入口仅允许所选业务监听")
}
return nil
}
r := wafdiagnose.Run(ctx, wafdiagnose.Target{URL: req.VisitorURL, Method: req.Method, Headers: headers, FixedAddress: address, DialGuard: localGuard})
check := diagnosticProbeCheck("local_ingress", "本机WAF入口", "本站业务监听真实请求;正常经过认证、防护、缓存和访问日志", r)
if len(unverified) > 0 && check.Status != "failed" {
check.Status = "warning"
check.Message += ";部分回源地址未完成解析核验:" + strings.Join(unverified, "、")
}
return check
}
// 解析全部回源计划的实际地址;确认命中受保护本机监听时返回错误,
// 解析失败的地址归入 unverified 由调用方标注,不因单个后端 DNS 抖动阻断整个入口检测
func diagnosticOriginLoopCheck(ctx context.Context, plans []wafenginmodel.WebsiteDiagnosticOriginPlan, guard func(string) error) ([]string, error) {
if len(plans) > 100 {
return nil, errors.New("回源目标过多")
}
var unverified []string
for _, plan := range plans {
if plan.Error != "" {
return nil, errors.New("回源配置无效")
}
for _, address := range []string{plan.FixedAddress, plan.FallbackAddress} {
if address == "" {
continue
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
unverified = append(unverified, address)
continue
}
for _, ip := range ips {
if err := guard(net.JoinHostPort(ip.IP.String(), port)); err != nil {
return nil, err
}
}
}
}
return unverified, nil
}
@@ -0,0 +1,45 @@
package waf_service
import (
"SamWaf/model/wafenginmodel"
"context"
"errors"
"net"
"strconv"
"testing"
)
func TestDiagnosticOriginLoopCheckGuards(t *testing.T) {
guard := func(actual string) error {
host, _, err := net.SplitHostPort(actual)
if err != nil {
return err
}
if host == "127.0.0.1" || host == "::1" {
return errors.New("目标为本机受保护监听")
}
return nil
}
// 解析失败的地址只记录不阻断,避免单个后端 DNS 抖动挡住整个入口检测
unverified, err := diagnosticOriginLoopCheck(context.Background(), []wafenginmodel.WebsiteDiagnosticOriginPlan{
{FallbackAddress: "diagnostic-unresolvable.invalid:80"},
}, guard)
if err != nil || len(unverified) != 1 {
t.Fatalf("dns failure should be recorded not blocked: unverified=%v err=%v", unverified, err)
}
// 确认解析到本机受保护监听时必须阻断
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
_, portText, _ := net.SplitHostPort(listener.Addr().String())
if _, err := strconv.Atoi(portText); err != nil {
t.Fatal(err)
}
if _, err := diagnosticOriginLoopCheck(context.Background(), []wafenginmodel.WebsiteDiagnosticOriginPlan{
{FallbackAddress: "localhost:" + portText},
}, guard); err == nil {
t.Fatal("loopback origin must be blocked")
}
}
@@ -0,0 +1,71 @@
package waf_service
import (
resp "SamWaf/model/response"
"SamWaf/wafdiagnose"
"fmt"
"strconv"
"strings"
)
func diagnosticProbeCheck(id, title, source string, r wafdiagnose.Result) resp.WebsiteDiagnosticCheck {
check := diagnosticCheck(id, title, "passed", "HTTP_RESPONSE", "已收到HTTP响应;请结合状态码与报文判断业务结果")
check.Request, check.Response = r.Request, r.Response
check.StatusCode, check.BodyTruncated = r.StatusCode, r.BodyTruncated
check.Overview = []resp.WebsiteDiagnosticField{
diagnosticField("证据来源", source), diagnosticField("逻辑请求URL", r.URL), diagnosticField("实际连接地址", r.ActualAddress),
diagnosticField("HTTP Host", r.Host), diagnosticField("TLS SNI", r.SNI), diagnosticField("HTTP请求已发送", strconv.FormatBool(r.HTTPWritten)),
diagnosticField("总耗时", fmt.Sprintf("%.1f ms", r.Timings.TotalMS)),
}
if r.StatusCode != 0 {
check.Overview = append(check.Overview, diagnosticField("HTTP状态", strconv.Itoa(r.StatusCode)))
}
for _, attempt := range r.Attempts {
check.Connection = append(check.Connection, diagnosticField("连接尝试", fmt.Sprintf("%s / %s / %.1f ms %s", attempt.Address, attempt.Status, attempt.MS, attempt.Reason)))
}
check.Connection = append(check.Connection,
diagnosticField("DNS耗时", fmt.Sprintf("%.1f ms", r.Timings.DNSMS)), diagnosticField("TCP耗时", fmt.Sprintf("%.1f ms", r.Timings.TCPMS)),
diagnosticField("TLS耗时", fmt.Sprintf("%.1f ms", r.Timings.TLSMS)), diagnosticField("首字节耗时", fmt.Sprintf("%.1f ms", r.Timings.FirstByteMS)))
if r.TLS != nil {
check.Connection = append(check.Connection, diagnosticField("TLS版本", r.TLS.Version), diagnosticField("密码套件", r.TLS.CipherSuite), diagnosticField("证书已验证", strconv.FormatBool(r.TLS.Verified)))
for _, cert := range r.TLS.Certificates {
check.Connection = append(check.Connection, diagnosticField("证书主体", cert.Subject), diagnosticField("证书颁发者", cert.Issuer), diagnosticField("证书SAN", strings.Join(cert.DNSNames, ", ")),
diagnosticField("证书有效期", cert.NotBefore+" → "+cert.NotAfter), diagnosticField("证书SHA256", cert.SHA256))
}
if !r.TLS.Verified && r.Status == "success" {
check.Status, check.Reason, check.Message = "warning", "TLS_NOT_VERIFIED", "按保存配置未校验证书可信性;握手完成不代表证书有效"
}
}
for _, evidence := range r.Evidence {
check.Connection = append(check.Connection, diagnosticField("检测说明", evidence))
}
if r.Status != "success" {
check.Status, check.Reason = "failed", strings.ToUpper(r.Stage)
check.Message = "检测停在" + r.Stage + "阶段:" + r.Reason
if strings.Contains(strings.ToLower(r.Reason), "policy") || r.Stage == "validation" {
check.Status, check.Reason = "skipped", "TARGET_POLICY"
}
} else if r.StatusCode >= 300 {
check.Status = "warning"
if r.StatusCode < 400 {
check.Reason, check.Message = "HTTP_REDIRECT", "已收到重定向,本次不跟随;请核对Location并按需重新诊断"
} else {
check.Reason, check.Message = "HTTP_STATUS", "已收到HTTP "+strconv.Itoa(r.StatusCode)+",连接已完成,请结合响应内容和访问日志检查业务或入口策略"
}
}
if r.BodyTruncated {
check.Status = "warning"
check.Connection = append(check.Connection, diagnosticField("正文范围", "正文已截断或未完整读取,报文仅为受限预览"))
}
section := "backend"
message := "检查源站进程、监听IP/端口和网络路径;HTTP Host、路径与实际连接地址应分别核对"
if r.Stage == "tls" || check.Reason == "TLS_NOT_VERIFIED" {
section, message = "tls", "核对源站证书有效期、SAN域名、完整证书链和运行环境信任库;此项控制回源HTTPS,不会自动忽略校验或修改配置"
} else if id == "local_ingress" {
section, message = "listen", "核对本站入口协议和监听;认证、防护、缓存、重定向可能提前响应,请结合访问日志确认"
} else if id == "access_url" {
section, message = "listen", "本结果仅代表服务器直连视角;结合用户网络核对DNS、CDN、NAT、端口映射与防火墙,不能仅凭服务器超时判断用户不可达"
}
check.Advice = append(check.Advice, diagnosticAdvice(check.Reason, message, section))
return check
}
+46 -7
View File
@@ -253,28 +253,40 @@ func outboundProxyAddrs() map[string]bool {
// DNS 再改指内网"的重绑定/TOCTOU:先解析、逐个判定,再直连已判定过的那个 IP,
// 保证"校验的 IP"与"真正连的 IP"是同一个。
func safeOutboundDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
return dialOutbound(ctx, network, addr, true, nil)
}
func dialOutbound(ctx context.Context, network, addr string, allowProxy bool, guard func(string) error) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
dialer := &net.Dialer{Timeout: outboundDialTimeout, KeepAlive: outboundKeepAlive}
dial := func(address string) (net.Conn, error) {
if guard != nil {
if err := guard(address); err != nil {
return nil, err
}
}
return dialer.DialContext(ctx, network, address)
}
// 连到代理本身的那一跳:真正的目标主机由 Transport 交给代理,这里判不了也不该判。
// 被 NO_PROXY 排除的主机不会走到这个分支(Transport 会直连),仍受完整判定约束。
if outboundProxyAddrs()[strings.ToLower(addr)] {
if allowProxy && outboundProxyAddrs()[strings.ToLower(addr)] {
return dialer.DialContext(ctx, network, addr)
}
// 主机名命中带外清单:运营方明确授权的内网镜像,按原样连接
if IsOutboundHostAllowlisted(host) {
allowHost := IsOutboundHostAllowlisted(host)
if allowProxy && allowHost {
return dialer.DialContext(ctx, network, addr)
}
if ip := net.ParseIP(host); ip != nil {
if !isPublicIP(ip) && !isOutboundIPAllowlisted(ip) {
if !allowHost && !isPublicIP(ip) && !isOutboundIPAllowlisted(ip) {
return nil, fmt.Errorf("目标IP %s 属于内网/环回/保留地址,已拒绝", host)
}
return dialer.DialContext(ctx, network, addr)
return dial(addr)
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
@@ -287,13 +299,13 @@ func safeOutboundDialContext(ctx context.Context, network, addr string) (net.Con
// 只要应答里混进一个内网地址就整体拒绝:允许"挑公网的连"等于给
// 攻击者一个稳定的重试窗口,最终仍会连上内网。
for _, ia := range ips {
if !isPublicIP(ia.IP) && !isOutboundIPAllowlisted(ia.IP) {
if !allowHost && !isPublicIP(ia.IP) && !isOutboundIPAllowlisted(ia.IP) {
return nil, fmt.Errorf("主机名 %s 解析到内网/保留地址 %s,已拒绝", host, ia.IP.String())
}
}
var lastErr error
for _, ia := range ips {
conn, e := dialer.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port))
conn, e := dial(net.JoinHostPort(ia.IP.String(), port))
if e == nil {
return conn, nil
}
@@ -316,6 +328,33 @@ var safeOutboundTransport = &http.Transport{
ExpectContinueTimeout: 1 * time.Second,
}
// SafeOutboundDirectTransport creates an isolated direct transport with numeric connection guards.
func SafeOutboundDirectTransport(guard func(string) error) *http.Transport {
transport := safeOutboundTransport.Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialOutbound(ctx, network, addr, false, guard)
}
return transport
}
// SafeOutboundProxyTransport creates an isolated transport that follows system proxy
// settings (HTTP_PROXY/HTTPS_PROXY/NO_PROXY, absent proxy = direct dial), keeping the
// numeric connection guard on every non-proxy dial. The proxy hop itself is deployment
// configuration and is exempt from the guard, same as dialOutbound's proxy branch.
func SafeOutboundProxyTransport(guard func(string) error) *http.Transport {
transport := safeOutboundTransport.Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialOutbound(ctx, network, addr, true, guard)
}
return transport
}
// HasSystemProxy reports whether any system proxy environment variable is configured.
func HasSystemProxy() bool {
return len(outboundProxyAddrs()) > 0
}
// SafeOutboundHTTPClient 返回用于"用户可配对外地址"的 HTTP 客户端,三层防护:
// 1. 调用方先过 IsAllowedOutboundURL(初始 URL);
// 2. 每一跳 30x 目标重新判定(含带外清单);
+164
View File
@@ -0,0 +1,164 @@
package wafdiagnose
import (
"encoding/json"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
const redacted = "[redacted]"
var privateInputPattern = regexp.MustCompile(`(?is)<input\b[^>]*(?:type\s*=\s*["']?password|name\s*=\s*["']?(?:password|passwd|pwd|secret|token|csrf|api[_-]?key|authorization|credential|access[_-]?key))`)
var fieldPattern = regexp.MustCompile(`(?i)(["']?(?:password|passwd|pwd|secret|token|api[_-]?key|authorization|credential|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s&;,<>]+)`)
func sensitive(key string) bool {
key = strings.ToLower(key)
for _, part := range []string{"authorization", "cookie", "password", "passwd", "secret", "token", "credential", "apikey", "api-key", "api_key", "private-key", "private_key", "access-key", "access_key"} {
if strings.Contains(key, part) {
return true
}
}
return key == "pwd"
}
type sanitizer struct{ secrets []string }
func newSanitizer(t Target) sanitizer {
s := sanitizer{}
add := func(v string) {
if v != "" && len(v) <= MaxHeaderBytes {
s.secrets = append(s.secrets, v, url.QueryEscape(v), url.PathEscape(v))
}
}
for key, values := range t.Headers {
for _, value := range values {
if len(value) >= 4 {
add(value)
}
if sensitive(key) {
for _, item := range strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ';' || r == '=' || r == ',' }) {
if len(item) >= 4 {
add(item)
}
}
}
}
}
if u, err := url.Parse(t.URL); err == nil {
if u.User != nil {
add(u.User.Username())
p, _ := u.User.Password()
add(p)
}
for _, values := range u.Query() {
for _, v := range values {
if len(v) >= 4 {
add(v)
}
}
}
}
sort.Slice(s.secrets, func(i, j int) bool { return len(s.secrets[i]) > len(s.secrets[j]) })
return s
}
func bounded(s string, limit int) string {
if len(s) > limit {
s = s[:limit]
}
return strings.ToValidUTF8(s, "�")
}
func (s sanitizer) plain(v string, limit int) string {
v = bounded(v, limit)
// One pass prevents replacement markers from being rewritten by short values.
if len(s.secrets) > 0 {
pairs := make([]string, 0, len(s.secrets)*2)
for _, secret := range s.secrets {
pairs = append(pairs, secret, redacted)
}
v = strings.NewReplacer(pairs...).Replace(v)
}
v = fieldPattern.ReplaceAllString(v, "${1}"+redacted)
if len(v) > limit {
v = v[:limit]
}
return v
}
func (s sanitizer) text(v string) string { return s.plain(v, MaxHeaderBytes) }
func (s sanitizer) headers(h http.Header, request bool) http.Header {
out := make(http.Header, len(h))
for key, values := range h {
for _, value := range values {
if request || sensitive(key) {
value = redacted
} else {
value = s.text(value)
}
out[s.text(key)] = append(out[s.text(key)], value)
}
}
return out
}
func (s sanitizer) url(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "[invalid URL]"
}
u.User = nil
q := u.Query()
for key := range q {
q[key] = []string{redacted}
}
u.RawQuery = q.Encode()
u.Fragment = ""
return s.plain(u.String(), 4096)
}
func redactJSON(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
for k, value := range x {
if sensitive(k) {
x[k] = redacted
} else {
x[k] = redactJSON(value)
}
}
case []interface{}:
for i := range x {
x[i] = redactJSON(x[i])
}
}
return v
}
func (s sanitizer) body(raw []byte, contentType string, truncated bool) string {
if !utf8.Valid(raw) || strings.IndexByte(string(raw), 0) >= 0 {
return "[binary body omitted]"
}
if strings.Contains(strings.ToLower(contentType), "json") {
var value interface{}
if truncated || json.Unmarshal(raw, &value) != nil {
return "[incomplete JSON body omitted]"
}
if b, err := json.MarshalIndent(redactJSON(value), "", " "); err == nil {
return s.plain(string(b), MaxBodyBytes)
}
}
if contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "text/") && !strings.Contains(contentType, "xml") && !strings.Contains(contentType, "javascript") && !strings.Contains(contentType, "x-www-form-urlencoded") {
return "[body omitted]"
}
if privateInputPattern.Match(raw) {
return "[body with credential form fields omitted]"
}
return s.plain(string(raw), MaxBodyBytes)
}
+429
View File
@@ -0,0 +1,429 @@
package wafdiagnose
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"SamWaf/utils"
)
const maxDuration = 10 * time.Second
type recorder struct {
sync.Mutex
result Result
start, dnsStart, tlsStart time.Time
connectStart map[string]time.Time
}
func millis(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }
// Run performs one bounded request without following redirects or retaining connections.
func Run(parent context.Context, target Target) (result Result) {
started := time.Now()
if len(target.URL) > 8192 || len(target.Host) > 255 || len(target.FixedAddress) > 128 || !boundedHeaders(target.Headers) {
return Result{StartedAt: started.UTC().Format(time.RFC3339Nano), FinishedAt: time.Now().UTC().Format(time.RFC3339Nano), Stage: "validation", Status: "error", Reason: "target input exceeds diagnostic limits", Attempts: []Attempt{}}
}
s := newSanitizer(target)
r := &recorder{start: started, connectStart: make(map[string]time.Time), result: Result{StartedAt: started.UTC().Format(time.RFC3339Nano), Stage: "validation", Status: "error", URL: s.url(target.URL), Attempts: []Attempt{}}}
defer func() {
r.Lock()
r.result.FinishedAt = time.Now().UTC().Format(time.RFC3339Nano)
r.result.Timings.TotalMS = millis(time.Since(started))
result = r.result
r.Unlock()
result = sanitizeResult(result, s)
}()
ctx, cancel := context.WithTimeout(parent, maxDuration)
defer cancel()
fail := func(stage string, err error) {
r.Lock()
defer r.Unlock()
if stage != "" {
r.result.Stage = stage
}
r.result.Reason = err.Error()
}
u, err := url.Parse(target.URL)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Opaque != "" || len(target.URL) > 8192 {
fail("validation", errors.New("invalid HTTP target URL"))
return
}
method := target.Method
if method == "" {
method = http.MethodGet
}
if method != http.MethodGet && method != http.MethodHead {
fail("validation", errors.New("only GET and HEAD are supported"))
return
}
port := u.Port()
if port == "" {
if u.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
n, portErr := strconv.Atoi(port)
if portErr != nil || n < 1 || n > 65535 {
fail("validation", errors.New("invalid target port"))
return
}
if target.Public && target.FixedAddress != "" {
fail("validation", errors.New("public targets cannot override the connection address"))
return
}
if target.FixedAddress != "" {
host, fixedPort, e := net.SplitHostPort(target.FixedAddress)
fixedNumber, fixedError := strconv.Atoi(fixedPort)
if e != nil || net.ParseIP(host) == nil || fixedError != nil || fixedNumber < 1 || fixedNumber > 65535 {
fail("validation", errors.New("fixed address must contain a numeric IP and valid port"))
return
}
}
host := target.Host
if host == "" {
host = u.Host
}
if strings.ContainsAny(host, "\r\n/\\@?# \t") || len(host) > 255 {
fail("validation", errors.New("invalid Host override"))
return
}
sni := u.Hostname()
r.result.Host = host
if u.Scheme == "https" {
r.result.SNI = sni
}
if target.Public {
check := make(chan error, 1)
go func() {
if ok, reason := utils.IsAllowedOutboundURL(target.URL); !ok {
check <- errors.New(reason)
} else {
check <- nil
}
}()
select {
case e := <-check:
if e != nil {
fail("validation", e)
return
}
case <-ctx.Done():
fail("dns", ctx.Err())
return
}
}
request, err := http.NewRequestWithContext(ctx, method, target.URL, nil)
if err != nil {
fail("validation", err)
return
}
request.Host = host
request.Header = make(http.Header)
headerBytes := len(host)
for key, values := range target.Headers {
if !validHeaderName(key) || forbiddenHeader(key) {
fail("validation", errors.New("unsupported request header"))
return
}
for _, value := range values {
headerBytes += len(key) + len(value) + 4
if strings.ContainsAny(value, "\r\n\x00") || headerBytes > MaxHeaderBytes {
fail("validation", errors.New("invalid or oversized request headers"))
return
}
request.Header.Add(key, value)
}
}
if request.Header.Get("User-Agent") == "" {
request.Header.Set("User-Agent", "SamWaf-Diagnostic")
}
trace := &httptrace.ClientTrace{
DNSStart: func(httptrace.DNSStartInfo) { r.Lock(); r.dnsStart = time.Now(); r.result.Stage = "dns"; r.Unlock() },
DNSDone: func(info httptrace.DNSDoneInfo) {
r.Lock()
r.result.Timings.DNSMS += millis(time.Since(r.dnsStart))
r.Unlock()
},
ConnectStart: func(_, addr string) { r.Lock(); r.result.Stage = "tcp"; r.connectStart[addr] = time.Now(); r.Unlock() },
ConnectDone: func(_, addr string, e error) {
r.Lock()
defer r.Unlock()
d := millis(time.Since(r.connectStart[addr]))
r.result.Timings.TCPMS += d
a := Attempt{Address: addr, Status: "success", MS: d}
if e != nil {
a.Status = "error"
a.Reason = e.Error()
}
if len(r.result.Attempts) < 32 {
r.result.Attempts = append(r.result.Attempts, a)
}
},
GotConn: func(info httptrace.GotConnInfo) {
r.Lock()
r.result.ActualAddress = info.Conn.RemoteAddr().String()
r.Unlock()
},
TLSHandshakeStart: func() { r.Lock(); r.tlsStart = time.Now(); r.result.Stage = "tls"; r.Unlock() },
TLSHandshakeDone: func(state tls.ConnectionState, e error) {
r.Lock()
defer r.Unlock()
r.result.Timings.TLSMS = millis(time.Since(r.tlsStart))
r.result.TLS = tlsInfo(state, e, target.Public || !target.InsecureSkipVerify)
},
WroteRequest: func(info httptrace.WroteRequestInfo) {
if info.Err != nil {
return
}
r.Lock()
defer r.Unlock()
r.result.Stage = "response"
r.result.HTTPWritten = true
r.result.RequestHeaders = s.headers(request.Header, true)
r.result.Request = normalizedRequest(request, s)
},
GotFirstResponseByte: func() { r.Lock(); r.result.Timings.FirstByteMS = millis(time.Since(started)); r.Unlock() },
}
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
var transport *http.Transport
if target.Public {
if target.AllowProxy {
transport = utils.SafeOutboundProxyTransport(target.DialGuard)
} else {
transport = utils.SafeOutboundDirectTransport(target.DialGuard)
}
} else {
transport = &http.Transport{Proxy: nil, DialContext: localDial(target, u.Hostname(), port)}
}
transport.TLSClientConfig = &tls.Config{ServerName: sni, InsecureSkipVerify: target.InsecureSkipVerify && !target.Public} // Saved source setting.
transport.DisableKeepAlives = true
transport.DisableCompression = true
transport.ForceAttemptHTTP2 = false
transport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper)
transport.MaxResponseHeaderBytes = MaxHeaderBytes
transport.TLSHandshakeTimeout = maxDuration
transport.ResponseHeaderTimeout = maxDuration
baseDial := transport.DialContext
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := baseDial(ctx, network, address)
if err == nil {
r.Lock()
r.result.ActualAddress = conn.RemoteAddr().String()
r.Unlock()
}
return conn, err
}
defer transport.CloseIdleConnections()
r.result.Stage = "tcp"
client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
response, err := client.Do(request)
if err != nil {
fail("", err)
return
}
defer response.Body.Close()
r.Lock()
r.result.Stage = "body"
r.result.StatusCode = response.StatusCode
r.result.ResponseHeaders = s.headers(response.Header, false)
r.result.Response = normalizedResponse(response, s)
r.Unlock()
body, err := io.ReadAll(io.LimitReader(response.Body, MaxBodyBytes+1))
truncated := len(body) > MaxBodyBytes
if truncated {
body = body[:MaxBodyBytes]
}
r.Lock()
r.result.BodyTruncated = truncated
r.result.ResponseBody = s.body(body, response.Header.Get("Content-Type"), truncated)
r.result.Response += r.result.ResponseBody
r.result.Response = s.plain(r.result.Response, MaxBodyBytes+MaxHeaderBytes)
r.result.Request = s.plain(r.result.Request, MaxHeaderBytes+4096)
if err == nil {
r.result.Stage = "complete"
r.result.Status = "success"
}
r.Unlock()
if err != nil {
fail("body", err)
}
return
}
func localDial(t Target, hostname, port string) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, _ string) (net.Conn, error) {
dialer := &net.Dialer{}
dial := func(address string) (net.Conn, error) {
if t.DialGuard != nil {
if err := t.DialGuard(address); err != nil {
return nil, err
}
}
return dialer.DialContext(ctx, network, address)
}
if t.FixedAddress != "" {
conn, err := dial(t.FixedAddress)
if err == nil || !t.Fallback || ctx.Err() != nil {
return conn, err
}
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, errors.New("no DNS addresses")
}
var last error
for i, ip := range ips {
if i == 16 {
break
}
conn, err := dial(net.JoinHostPort(ip.IP.String(), port))
if err == nil {
return conn, nil
}
last = err
if ctx.Err() != nil {
break
}
}
return nil, last
}
}
func tlsInfo(state tls.ConnectionState, err error, strict bool) *TLSInfo {
info := &TLSInfo{Verified: strict && err == nil && len(state.VerifiedChains) > 0, Verification: "failed"}
if !strict {
info.Verification = "skipped"
} else if info.Verified {
info.Verification = "verified"
}
if state.Version != 0 {
info.Version = tls.VersionName(state.Version)
info.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
}
certs := state.PeerCertificates
var verification *tls.CertificateVerificationError
if errors.As(err, &verification) {
certs = verification.UnverifiedCertificates
}
for i, cert := range certs {
if i == 5 {
break
}
hash := sha256.Sum256(cert.Raw)
names := cert.DNSNames
if len(names) > 32 {
names = names[:32]
}
info.Certificates = append(info.Certificates, Certificate{Subject: cert.Subject.String(), Issuer: cert.Issuer.String(), DNSNames: append([]string(nil), names...), NotBefore: cert.NotBefore.UTC().Format(time.RFC3339), NotAfter: cert.NotAfter.UTC().Format(time.RFC3339), SHA256: hex.EncodeToString(hash[:])})
}
// Some TLS implementations return the certificate in the underlying verification error.
if len(info.Certificates) == 0 {
var unknown x509.UnknownAuthorityError
if errors.As(err, &unknown) && unknown.Cert != nil {
state.PeerCertificates = []*x509.Certificate{unknown.Cert}
return tlsInfo(state, nil, false)
}
}
return info
}
func boundedHeaders(headers http.Header) bool {
if len(headers) > 128 {
return false
}
total, count := 0, 0
for key, values := range headers {
for _, value := range values {
total += len(key) + len(value) + 4
count++
if total > MaxHeaderBytes || count > 128 {
return false
}
}
}
return true
}
func validHeaderName(key string) bool {
if key == "" {
return false
}
for _, c := range key {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", c)) {
return false
}
}
return true
}
func forbiddenHeader(key string) bool {
switch strings.ToLower(key) {
case "host", "content-length", "transfer-encoding", "connection", "proxy-authorization", "proxy-connection", "upgrade", "trailer", "te":
return true
}
return false
}
func headerText(h http.Header) string {
keys := make([]string, 0, len(h))
for key := range h {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
for _, value := range h[key] {
fmt.Fprintf(&b, "%s: %s\r\n", key, value)
}
}
return b.String()
}
func normalizedRequest(req *http.Request, s sanitizer) string {
u := *req.URL
u.Scheme = ""
u.Host = ""
return req.Method + " " + s.url(u.String()) + " HTTP/1.1\r\nHost: " + s.text(req.Host) + "\r\n" + headerText(s.headers(req.Header, true)) + "\r\n"
}
func normalizedResponse(resp *http.Response, s sanitizer) string {
return s.text(resp.Proto+" "+resp.Status) + "\r\n" + headerText(s.headers(resp.Header, false)) + "\r\n"
}
func sanitizeResult(r Result, s sanitizer) Result {
r.Reason = s.text(r.Reason)
r.Host = s.text(r.Host)
r.SNI = s.text(r.SNI)
r.ActualAddress = s.text(r.ActualAddress)
for i := range r.Attempts {
r.Attempts[i].Address = s.text(r.Attempts[i].Address)
r.Attempts[i].Reason = s.text(r.Attempts[i].Reason)
}
if r.TLS != nil {
for i := range r.TLS.Certificates {
c := &r.TLS.Certificates[i]
c.Subject = s.text(c.Subject)
c.Issuer = s.text(c.Issuer)
for j := range c.DNSNames {
c.DNSNames[j] = s.text(c.DNSNames[j])
}
}
}
return r
}
+273
View File
@@ -0,0 +1,273 @@
package wafdiagnose
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"SamWaf/global"
"SamWaf/utils"
)
// 进程内单例代理服务器,见 TestPublicAllowProxyFollowsSystemProxy 注释。
var (
proxyOnce sync.Once
proxyServer struct {
srv *httptest.Server
hits *atomic.Int32
}
)
func TestPublicAllowProxyFollowsSystemProxy(t *testing.T) {
// http.ProxyFromEnvironment 用进程级 sync.Once 缓存首次读取的环境变量,
// -count>1 或别的用例先触发缓存都会让本用例失真。因此代理服务器进程内
// 单例(端口跨轮稳定,缓存指向始终有效),断言用命中数增量而非绝对值。
proxyOnce.Do(func() {
proxyServer.hits = new(atomic.Int32)
proxyServer.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyServer.hits.Add(1)
io.WriteString(w, "proxied")
}))
})
t.Setenv("HTTP_PROXY", proxyServer.srv.URL)
t.Setenv("HTTPS_PROXY", proxyServer.srv.URL)
t.Setenv("ALL_PROXY", "")
t.Setenv("NO_PROXY", "")
t.Setenv("no_proxy", "")
old := global.GCONFIG_OUTBOUND_ALLOWED_HOSTS
global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = ""
defer func() { global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = old }()
before := proxyServer.hits.Load()
r := Run(context.Background(), Target{URL: "http://proxy-target.invalid/", Method: "GET", Public: true, AllowProxy: true})
if r.Status != "success" || r.StatusCode != 200 || proxyServer.hits.Load() != before+1 {
t.Fatalf("allow-proxy result: %+v hits %d->%d", r, before, proxyServer.hits.Load())
}
before = proxyServer.hits.Load()
direct := Run(context.Background(), Target{URL: "http://proxy-target.invalid/", Method: "GET", Public: true})
if direct.Status == "success" || proxyServer.hits.Load() != before {
t.Fatalf("direct probe must not use system proxy: %+v hits %d->%d", direct, before, proxyServer.hits.Load())
}
}
func TestNonPublicTargetNeverUsesSystemProxy(t *testing.T) {
// 本机入口/源站阶段结构上不走代理:即便 AllowProxy 被误置,
// 非 public 目标也必须直连 FixedAddress,不读环境代理。
proxyOnce.Do(func() {
proxyServer.hits = new(atomic.Int32)
proxyServer.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyServer.hits.Add(1)
io.WriteString(w, "proxied")
}))
})
t.Setenv("HTTP_PROXY", proxyServer.srv.URL)
t.Setenv("HTTPS_PROXY", proxyServer.srv.URL)
t.Setenv("ALL_PROXY", "")
t.Setenv("NO_PROXY", "")
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "origin") }))
defer origin.Close()
u, _ := url.Parse(origin.URL)
before := proxyServer.hits.Load()
r := Run(context.Background(), Target{URL: "http://logical.invalid/x", Method: "GET", FixedAddress: u.Host, AllowProxy: true})
if r.Status != "success" || proxyServer.hits.Load() != before || r.ActualAddress != u.Host {
t.Fatalf("non-public target used system proxy: %+v hits %d->%d", r, before, proxyServer.hits.Load())
}
}
func TestFixedAddressHostAndHEAD(t *testing.T) {
var method, host string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method, host = r.Method, r.Host
w.Header().Set("X-Test", "received")
io.WriteString(w, "hello")
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
r := Run(context.Background(), Target{URL: "http://logical.invalid:" + u.Port() + "/a?token=private-query", Method: "HEAD", Host: "virtual.example", FixedAddress: u.Host})
if r.Status != "success" || !r.HTTPWritten || r.ActualAddress != u.Host || method != "HEAD" || host != "virtual.example" {
t.Fatalf("unexpected diagnostic: %+v method=%s host=%s", r, method, host)
}
if r.ResponseBody != "" || !strings.Contains(r.Request, "HEAD /a?") || strings.Contains(r.Request, "private-query") {
t.Fatalf("unexpected normalized messages: %+v", r)
}
}
func TestTLSFailureAndSavedSkip(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != "http-host.example" || r.TLS.ServerName != "virtual.example" {
t.Errorf("Host=%s SNI=%s", r.Host, r.TLS.ServerName)
}
requests.Add(1)
io.WriteString(w, "ok")
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
target := Target{URL: "https://virtual.example:443", Host: "http-host.example", FixedAddress: u.Host}
r := Run(context.Background(), target)
if r.Stage != "tls" || r.Status != "error" || r.HTTPWritten || r.Request != "" || r.Response != "" || requests.Load() != 0 || r.ActualAddress != u.Host {
t.Fatalf("TLS failure report: %+v", r)
}
if r.TLS == nil || r.TLS.Verified || r.TLS.Verification != "failed" || len(r.TLS.Certificates) == 0 {
t.Fatalf("missing certificate metadata: %+v", r.TLS)
}
target.InsecureSkipVerify = true
r = Run(context.Background(), target)
if r.Status != "success" || !r.HTTPWritten || r.TLS == nil || r.TLS.Verified || r.TLS.Verification != "skipped" || r.SNI != "virtual.example" {
t.Fatalf("saved skip report: %+v", r)
}
}
func TestRedirectNotFollowed(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Redirect(w, r, "/destination", http.StatusFound)
}))
defer srv.Close()
r := Run(context.Background(), Target{URL: srv.URL})
if r.Status != "success" || r.StatusCode != 302 || calls.Load() != 1 || !strings.Contains(r.Response, "302") {
t.Fatalf("redirect report: %+v calls=%d", r, calls.Load())
}
}
func TestBodyAndHeaderLimits(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
io.WriteString(w, strings.Repeat("z", MaxBodyBytes+10000))
}))
defer srv.Close()
r := Run(context.Background(), Target{URL: srv.URL})
if r.Status != "success" || !r.BodyTruncated || len(r.ResponseBody) != MaxBodyBytes {
t.Fatalf("body limit result: status=%s length=%d truncated=%v", r.Status, len(r.ResponseBody), r.BodyTruncated)
}
headers := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Large", strings.Repeat("h", MaxHeaderBytes+1))
io.WriteString(w, "ignored")
}))
defer headers.Close()
r = Run(context.Background(), Target{URL: headers.URL})
if r.Status != "error" || r.Response != "" || !r.HTTPWritten {
t.Fatalf("header limit result: %+v", r)
}
}
func TestCancellationAndGuard(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
r := Run(ctx, Target{URL: srv.URL})
if r.Status != "error" || r.Timings.TotalMS > 1000 {
t.Fatalf("cancel result: %+v", r)
}
var seen string
r = Run(context.Background(), Target{URL: srv.URL, DialGuard: func(address string) error { seen = address; return errors.New("controlled guard denied") }})
host, _, err := net.SplitHostPort(seen)
if err != nil || net.ParseIP(host) == nil || r.HTTPWritten || r.Status != "error" || !strings.Contains(r.Reason, "controlled guard denied") {
t.Fatalf("guard result: %+v seen=%s", r, seen)
}
}
func TestRedactionAcrossResult(t *testing.T) {
const custom = "custom-private-value"
const token = "auth-private-value"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "session=cookie-private-value")
w.Header().Set("X-Echo", r.Header.Get("X-Custom"))
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"password":"json-private-value","nested":[{"api_key":"json-key-value"}],"echo":"`+custom+`","query":"query-private-value","authorization":"`+token+`"}`)
}))
defer srv.Close()
r := Run(context.Background(), Target{URL: srv.URL + "/?q=query-private-value", Headers: http.Header{"X-Custom": {custom}, "Authorization": {"Bearer " + token}}})
b, _ := json.Marshal(r)
for _, secret := range []string{custom, token, "json-private-value", "json-key-value", "query-private-value", "cookie-private-value"} {
if strings.Contains(string(b), secret) {
t.Fatalf("result contains %s: %s", secret, b)
}
}
if !strings.Contains(r.ResponseBody, "redacted") || !r.HTTPWritten {
t.Fatalf("unexpected redaction report: %+v", r)
}
}
func TestRedactionExpansionBudget(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
io.WriteString(w, strings.Repeat(strings.Repeat("a", 32), 4096))
}))
defer srv.Close()
r := Run(context.Background(), Target{URL: srv.URL, Headers: http.Header{"X-Short": {"a"}, "Authorization": {"Bearer a"}}})
if len(r.ResponseBody) > MaxBodyBytes || !strings.Contains(r.ResponseBody, strings.Repeat("a", 32)) {
t.Fatalf("unexpected expansion budget: %d", len(r.ResponseBody))
}
}
func TestFixedFallbackAndNumericGuard(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
defer srv.Close()
u, _ := url.Parse(srv.URL)
var seen []string
r := Run(context.Background(), Target{URL: "http://localhost:" + u.Port(), FixedAddress: net.JoinHostPort("127.0.0.2", u.Port()), Fallback: true, DialGuard: func(address string) error {
seen = append(seen, address)
host, _, _ := net.SplitHostPort(address)
if net.ParseIP(host) == nil {
t.Errorf("non-numeric guard input %s", address)
}
if host == "127.0.0.2" {
return errors.New("controlled first attempt failure")
}
return nil
}})
if r.Status != "success" || len(seen) < 2 {
t.Fatalf("fallback result: %+v seen=%v", r, seen)
}
}
func TestPublicDirectTransportHasNoProxyExemption(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("private target must not receive request") }))
defer srv.Close()
t.Setenv("HTTP_PROXY", srv.URL)
t.Setenv("HTTPS_PROXY", srv.URL)
old := global.GCONFIG_OUTBOUND_ALLOWED_HOSTS
global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = ""
defer func() { global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = old }()
transport := utils.SafeOutboundDirectTransport(nil)
defer transport.CloseIdleConnections()
if transport.Proxy != nil {
t.Fatal("direct transport has proxy")
}
client := &http.Client{Transport: transport}
resp, err := client.Get(srv.URL)
if resp != nil {
resp.Body.Close()
}
if err == nil {
t.Fatal("direct transport allowed private proxy endpoint as target")
}
}
func TestPublicTLSAlwaysStrict(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("strict public TLS request reached HTTP") }))
defer srv.Close()
u, _ := url.Parse(srv.URL)
old := global.GCONFIG_OUTBOUND_ALLOWED_HOSTS
global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = u.Hostname()
defer func() { global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = old }()
r := Run(context.Background(), Target{URL: srv.URL, Public: true, InsecureSkipVerify: true})
if r.Status != "error" || r.Stage != "tls" || r.HTTPWritten || r.TLS == nil || r.TLS.Verification != "failed" {
t.Fatalf("public TLS report: %+v", r)
}
}
+85
View File
@@ -0,0 +1,85 @@
package wafdiagnose
import (
"context"
"net/http"
)
const (
MaxHeaderBytes = 32 << 10
MaxBodyBytes = 64 << 10
)
type Target struct {
URL string
Method string
Headers http.Header
Host string
FixedAddress string
Fallback bool
InsecureSkipVerify bool
Public bool
// AllowProxy lets a public target follow system proxy settings (HTTP_PROXY etc.).
AllowProxy bool
// DialGuard receives each resolved numeric host:port before connecting.
DialGuard func(string) error
}
type Attempt struct {
Address string `json:"address"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
MS float64 `json:"ms"`
}
type Timings struct {
DNSMS float64 `json:"dns_ms"`
TCPMS float64 `json:"tcp_ms"`
TLSMS float64 `json:"tls_ms"`
FirstByteMS float64 `json:"first_byte_ms"`
TotalMS float64 `json:"total_ms"`
}
type Certificate struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
DNSNames []string `json:"dns_names,omitempty"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
SHA256 string `json:"sha256"`
}
type TLSInfo struct {
Version string `json:"version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
Verified bool `json:"verified"`
Verification string `json:"verification"`
Certificates []Certificate `json:"certificates,omitempty"`
}
type Result struct {
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Stage string `json:"stage"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Evidence []string `json:"evidence,omitempty"`
URL string `json:"url"`
Host string `json:"host"`
SNI string `json:"sni,omitempty"`
ActualAddress string `json:"actual_address,omitempty"`
Attempts []Attempt `json:"attempts"`
Timings Timings `json:"timings"`
TLS *TLSInfo `json:"tls,omitempty"`
Request string `json:"request,omitempty"`
Response string `json:"response,omitempty"`
RequestHeaders http.Header `json:"request_headers,omitempty"`
ResponseHeaders http.Header `json:"response_headers,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
BodyTruncated bool `json:"body_truncated"`
StatusCode int `json:"status_code,omitempty"`
HTTPWritten bool `json:"http_written"`
}
// Runner is useful for adapters that inject a diagnostic implementation.
type Runner func(context.Context, Target) Result
+4 -70
View File
@@ -182,25 +182,6 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-SamWaf-Worker", global.GWAF_WORKER_TAG)
}
port := ""
host := r.Host
if !strings.Contains(host, ":") {
// 检查请求是否使用了HTTPS
if r.TLS != nil {
// 请求使用了HTTPS
host = host + ":443"
} else {
// 请求使用了HTTP
host = host + ":80"
}
}
// 从 host 字符串中提取端口
if strings.Contains(host, ":") {
parts := strings.Split(host, ":")
if len(parts) == 2 {
port = parts[1]
}
}
defer func() {
e := recover()
if e != nil {
@@ -214,57 +195,10 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
debug.PrintStack()
}
}()
targetCode := ""
//检测是否是不检测端口的情况
if targetHost, ok := waf.rt().HostTargetNoPort[utils.GetPureDomain(host)]; ok {
host = targetHost
}
findHost := false
target, ok := waf.rt().HostTarget[host]
if !ok {
// 看看是不是泛域名情况
target, ok = waf.rt().HostTarget[domaintool.MaskSubdomain(host)]
if ok {
findHost = true
targetCode = target.Host.Code
}
} else {
targetCode = target.Host.Code
findHost = true
}
if !findHost {
// 绑定更多域名的信息
targetCode, ok = waf.rt().HostTargetMoreDomain[host]
if ok {
findHost = true
} else {
// 看看是不是泛域名情况
targetCode, ok = waf.rt().HostTargetMoreDomain[domaintool.MaskSubdomain(host)]
if ok {
findHost = true
}
}
}
//检测是否是宽松端口且是不指定域名的情况
if targetHost, ok := waf.rt().HostTargetNoPort["*"]; ok {
host = targetHost
//检测是否存在不指定域名的情况
target, ok = waf.rt().HostTarget[host]
if ok {
findHost = true
targetCode = target.Host.Code
zlog.Debug(fmt.Sprintf("%s %s", innerLogName, "触发*逻辑和来源port"))
}
} else {
//检测是否存在不指定域名的情况
target, ok = waf.rt().HostTarget["*:"+port]
if ok {
findHost = true
targetCode = target.Host.Code
zlog.Debug(fmt.Sprintf("%s %s", innerLogName, "触发*逻辑"))
}
route := resolveRequestHost(waf.rt(), r.Host, r.TLS != nil, false)
host, targetCode, findHost := route.Host, route.Code, route.Found
if route.CatchAllLog != "" {
zlog.Debug(fmt.Sprintf("%s %s", innerLogName, route.CatchAllLog))
}
// 检查域名是否已经注册
+175
View File
@@ -0,0 +1,175 @@
package wafenginecore
import (
"SamWaf/model"
"SamWaf/model/wafenginmodel"
"SamWaf/wafproxy"
"crypto/tls"
"encoding/json"
"errors"
"net"
"net/http"
"net/url"
"strconv"
"strings"
)
// DiagnosticSnapshot 读取单次发布的路由快照,不选择后端或建立连接。
func (waf *WafEngine) DiagnosticSnapshot(hostCode, visitorURL, method string, headers http.Header) (wafenginmodel.WebsiteDiagnosticSnapshot, error) {
var snapshot wafenginmodel.WebsiteDiagnosticSnapshot
u, err := url.Parse(visitorURL)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
return snapshot, errors.New("invalid diagnostic visitor URL")
}
req, err := http.NewRequest(method, visitorURL, nil)
if err != nil {
return snapshot, errors.New("invalid diagnostic request")
}
req.Header = headers.Clone()
if req.Header == nil {
req.Header = make(http.Header)
}
if req.URL.Path == "" {
req.URL.Path = "/"
}
req.URL.Fragment, req.URL.RawFragment = "", ""
req.RequestURI = req.URL.RequestURI()
if u.Scheme == "https" {
req.TLS = &tls.ConnectionState{}
}
rt := waf.rt()
match := resolveRequestHost(rt, req.Host, req.TLS != nil, true)
snapshot.MatchCode, snapshot.MatchKind, snapshot.Steps = match.Code, match.Kind, match.Steps
host := rt.HostTarget[rt.HostCode[hostCode]]
if host == nil {
return snapshot, nil
}
snapshot.Loaded = true
snapshot.Host, snapshot.TargetHost = host.Host, host.TargetHost
snapshot.Backends = append([]model.LoadBalance(nil), host.LoadBalanceLists...)
snapshot.PathRules = append([]model.HostPathRule(nil), host.PathRules...)
snapshot.Limitations = []string{
"Request planning does not execute WAF rules, authentication, cache lookup, or local file reads.",
"Client address variables and automatic X-Forwarded-For appending cannot be reproduced without a live visitor connection.",
}
if host.Host.START_STATUS == 1 {
snapshot.Mode = "disabled"
return snapshot, nil
}
if req.TLS == nil {
if jump, _ := shouldAutoJumpHTTPS(match.Host, host.Host.Host, req.URL.RequestURI(), host.Host.AutoJumpHTTPS, host.Host.Ssl); jump {
snapshot.Mode = "https_redirect"
return snapshot, nil
}
}
staticConfig := InitDefaultStaticSiteConfig()
_ = json.Unmarshal([]byte(host.Host.StaticSiteJSON), &staticConfig)
if staticConfig.IsEnableStaticSite == 1 {
snapshot.Mode = "static"
return snapshot, nil
}
if _, err := url.Parse(host.TargetHost); err != nil {
snapshot.Mode = "proxy"
snapshot.OriginPlans = []wafenginmodel.WebsiteDiagnosticOriginPlan{{Kind: "proxy", Error: "invalid runtime origin URL"}}
return snapshot, nil
}
if rule := MatchPathRule(host.PathRules, req.URL.Path); rule != nil {
snapshot.PathRule = rule
switch rule.TargetType {
case 2:
snapshot.Mode = "path_static"
return snapshot, nil
case 3:
snapshot.Mode = "redirect"
return snapshot, nil
default:
snapshot.Mode = "path_proxy"
snapshot.OriginPlans = []wafenginmodel.WebsiteDiagnosticOriginPlan{waf.diagnosticPathPlan(req, host, rule)}
return snapshot, nil
}
}
snapshot.Mode = "proxy"
if host.Host.IsEnableLoadBalance > 0 {
snapshot.Mode = "load_balance"
snapshot.Limitations = append(snapshot.Limitations, "Load balance plans enumerate configured backends without advancing selection state; existing proxy headers may originate from the first live request.")
for _, backend := range snapshot.Backends {
snapshot.OriginPlans = append(snapshot.OriginPlans, waf.diagnosticOriginPlan(req, host, &backend))
}
} else {
snapshot.OriginPlans = []wafenginmodel.WebsiteDiagnosticOriginPlan{waf.diagnosticOriginPlan(req, host, nil)}
}
return snapshot, nil
}
func (waf *WafEngine) diagnosticOriginPlan(req *http.Request, host *wafenginmodel.HostSafe, backend *model.LoadBalance) wafenginmodel.WebsiteDiagnosticOriginPlan {
plan := wafenginmodel.WebsiteDiagnosticOriginPlan{Kind: "proxy"}
var lb model.LoadBalance
if backend != nil {
lb = *backend
plan.Kind, plan.BackendID = "load_balance", lb.Id
if lb.Remote_port > 0 {
plan.FixedAddress = net.JoinHostPort(lb.Remote_ip, strconv.Itoa(lb.Remote_port))
}
} else if host.Host.Remote_ip != "" && host.Host.Remote_port > 0 {
plan.FixedAddress = net.JoinHostPort(host.Host.Remote_ip, strconv.Itoa(host.Host.Remote_port))
}
target, err := url.Parse(host.TargetHost)
if err != nil || target == nil || target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https") {
plan.Error = "invalid runtime origin URL"
return plan
}
customHeaders := waf.getCustomHeaders(req, req.Host, host.Host.IsEnableLoadBalance, lb, host)
return finishDiagnosticPlan(plan, req, target, customHeaders, host)
}
func (waf *WafEngine) diagnosticPathPlan(req *http.Request, host *wafenginmodel.HostSafe, rule *model.HostPathRule) wafenginmodel.WebsiteDiagnosticOriginPlan {
plan := wafenginmodel.WebsiteDiagnosticOriginPlan{Kind: "path_proxy"}
if rule.RemoteIP != "" && rule.RemotePort > 0 {
plan.FixedAddress = net.JoinHostPort(rule.RemoteIP, strconv.Itoa(rule.RemotePort))
}
targetString := resolvePathRuleScheme(req, rule) + "://" + rule.RemoteHost
if rule.RemotePort > 0 {
targetString += ":" + strconv.Itoa(rule.RemotePort)
}
target, err := url.Parse(targetString)
if err != nil || target.Hostname() == "" {
plan.Error = "invalid runtime path origin URL"
return plan
}
req = req.Clone(req.Context())
if rule.StripPrefix == 1 && strings.HasPrefix(req.URL.Path, rule.Path) {
req.URL.Path = diagnosticStripPrefix(req.URL.Path, rule.Path)
if req.URL.RawPath != "" {
req.URL.RawPath = diagnosticStripPrefix(req.URL.RawPath, rule.Path)
}
}
customHeaders := map[string]string{}
if req.TLS != nil {
customHeaders["X-FORWARDED-PROTO"] = "https"
}
return finishDiagnosticPlan(plan, req, target, customHeaders, host)
}
func diagnosticStripPrefix(path, prefix string) string {
path = strings.TrimPrefix(path, prefix)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func finishDiagnosticPlan(plan wafenginmodel.WebsiteDiagnosticOriginPlan, req *http.Request, target *url.URL, customHeaders map[string]string, host *wafenginmodel.HostSafe) wafenginmodel.WebsiteDiagnosticOriginPlan {
out := wafproxy.BuildDiagnosticRequest(req, target, customHeaders, map[string]string{"IsTransBackDomain": strconv.Itoa(host.Host.IsTransBackDomain)})
plan.URL, plan.Host, plan.Headers = out.URL.String(), out.Host, out.Header
plan.ServerName = target.Hostname()
plan.InsecureSkipVerify = host.Host.InsecureSkipVerify == 1
port := target.Port()
if port == "" {
port = "80"
if target.Scheme == "https" {
port = "443"
}
}
plan.FallbackAddress = net.JoinHostPort(target.Hostname(), port)
return plan
}
+93
View File
@@ -0,0 +1,93 @@
package wafenginecore
import (
"SamWaf/common/domaintool"
"SamWaf/utils"
"strings"
)
type requestHostMatch struct {
Host, Port, Code, Kind string
Found bool
CatchAllLog string
Steps []string
}
// resolveRequestHost 与请求入口共用同一份路由顺序。
func resolveRequestHost(rt *routingTable, authority string, secure, trace bool) requestHostMatch {
m := requestHostMatch{Host: authority, Kind: "unmatched"}
if !strings.Contains(m.Host, ":") {
if secure {
m.Host += ":443"
} else {
m.Host += ":80"
}
}
if strings.Contains(m.Host, ":") {
parts := strings.Split(m.Host, ":")
if len(parts) == 2 {
m.Port = parts[1]
}
}
if trace {
m.Steps = append(m.Steps, "authority="+m.Host)
}
loose := false
if targetHost, ok := rt.HostTargetNoPort[utils.GetPureDomain(m.Host)]; ok {
m.Host = targetHost
loose = true
if trace {
m.Steps = append(m.Steps, "no_port="+targetHost)
}
}
target, ok := rt.HostTarget[m.Host]
if !ok {
target, ok = rt.HostTarget[domaintool.MaskSubdomain(m.Host)]
if ok {
m.Found, m.Code, m.Kind = true, target.Host.Code, "wildcard"
}
} else {
m.Found, m.Code, m.Kind = true, target.Host.Code, "exact"
if loose {
m.Kind = "no_port"
}
}
if !m.Found {
m.Code, ok = rt.HostTargetMoreDomain[m.Host]
if ok {
m.Found, m.Kind = true, "alias"
} else {
m.Code, ok = rt.HostTargetMoreDomain[domaintool.MaskSubdomain(m.Host)]
if ok {
m.Found, m.Kind = true, "alias_wildcard"
}
}
}
if trace && m.Found {
m.Steps = append(m.Steps, m.Kind+"="+m.Code)
}
if targetHost, ok := rt.HostTargetNoPort["*"]; ok {
m.Host = targetHost
target, ok = rt.HostTarget[m.Host]
if ok {
m.Found, m.Code, m.Kind = true, target.Host.Code, "catch_all_no_port"
m.CatchAllLog = "触发*逻辑和来源port"
if trace {
m.Steps = append(m.Steps, m.Kind+"="+m.Code)
}
}
} else {
target, ok = rt.HostTarget["*:"+m.Port]
if ok {
m.Found, m.Code, m.Kind = true, target.Host.Code, "catch_all"
m.CatchAllLog = "触发*逻辑"
if trace {
m.Steps = append(m.Steps, m.Kind+"="+m.Code)
}
}
}
if trace && !m.Found {
m.Steps = append(m.Steps, "unmatched")
}
return m
}
+271
View File
@@ -0,0 +1,271 @@
package wafenginecore
import (
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/wafenginmodel"
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
)
func TestWebsiteDiagnosticRoute(t *testing.T) {
tests := []struct {
name, authority string
secure bool
hosts map[string]string
aliases, loose map[string]string
code, kind string
}{
{name: "http default", authority: "example.com", hosts: map[string]string{"example.com:80": "a"}, code: "a", kind: "exact"},
{name: "https default", authority: "example.com", secure: true, hosts: map[string]string{"example.com:443": "a"}, code: "a", kind: "exact"},
{name: "explicit port", authority: "example.com:8443", hosts: map[string]string{"example.com:8443": "a"}, code: "a", kind: "exact"},
{name: "alias", authority: "alias.com:8080", aliases: map[string]string{"alias.com:8080": "a"}, code: "a", kind: "alias"},
{name: "wildcard", authority: "one.example.com", hosts: map[string]string{"*.example.com:80": "a"}, code: "a", kind: "wildcard"},
{name: "wildcard alias", authority: "one.example.com", aliases: map[string]string{"*.example.com:80": "a"}, code: "a", kind: "alias_wildcard"},
{name: "only first label masked", authority: "one.two.example.com", hosts: map[string]string{"*.example.com:80": "a"}, kind: "unmatched"},
{name: "exact before wildcard alias", authority: "one.example.com", hosts: map[string]string{"one.example.com:80": "a"}, aliases: map[string]string{"*.example.com:80": "b"}, code: "a", kind: "exact"},
{name: "loose port", authority: "example.com:9900", hosts: map[string]string{"example.com:80": "a"}, loose: map[string]string{"example.com": "example.com:80"}, code: "a", kind: "no_port"},
{name: "loose alias", authority: "alias.com:9900", hosts: map[string]string{"example.com:80": "a"}, loose: map[string]string{"alias.com": "example.com:80"}, code: "a", kind: "no_port"},
{name: "catch all", authority: "other.com:8080", hosts: map[string]string{"*:8080": "b"}, code: "b", kind: "catch_all"},
{name: "catch all replaces exact", authority: "example.com", hosts: map[string]string{"example.com:80": "a", "*:80": "b"}, code: "b", kind: "catch_all"},
{name: "catch all uses visitor port after loose", authority: "example.com:9900", hosts: map[string]string{"example.com:80": "a", "*:9900": "b"}, loose: map[string]string{"example.com": "example.com:80"}, code: "b", kind: "catch_all"},
{name: "loose catch all replaces exact", authority: "example.com", hosts: map[string]string{"example.com:80": "a", "*:8080": "b"}, loose: map[string]string{"*": "*:8080"}, code: "b", kind: "catch_all_no_port"},
{name: "missing loose catch all skips port fallback", authority: "example.com", hosts: map[string]string{"example.com:80": "a", "*:80": "b"}, loose: map[string]string{"*": "*:8080"}, code: "a", kind: "exact"},
{name: "case preserved", authority: "EXAMPLE.com", hosts: map[string]string{"example.com:80": "a"}, kind: "unmatched"},
{name: "ipv6 authority preserved", authority: "[::1]:8080", hosts: map[string]string{"[::1]:8080": "a", "*:8080": "b"}, code: "a", kind: "exact"},
{name: "unmatched", authority: "missing.com", kind: "unmatched"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rt := newRoutingTable()
for key, code := range tc.hosts {
rt.HostTarget[key] = &wafenginmodel.HostSafe{Host: model.Hosts{Code: code}}
}
for k, v := range tc.aliases {
rt.HostTargetMoreDomain[k] = v
}
for k, v := range tc.loose {
rt.HostTargetNoPort[k] = v
}
result := resolveRequestHost(rt, tc.authority, tc.secure, true)
if result.Code != tc.code || result.Kind != tc.kind || result.Found != (tc.code != "") {
t.Fatalf("route = %+v, want %s/%s", result, tc.code, tc.kind)
}
if len(result.Steps) == 0 {
t.Fatal("route trace is empty")
}
quiet := resolveRequestHost(rt, tc.authority, tc.secure, false)
if quiet.Steps != nil || quiet.Code != result.Code || quiet.Host != result.Host || quiet.Port != result.Port {
t.Fatalf("production route differs from traced route: %+v / %+v", quiet, result)
}
})
}
}
func diagnosticTestEngine(host *wafenginmodel.HostSafe) *WafEngine {
waf := &WafEngine{}
rt := newRoutingTable()
rt.HostTarget["site.test:443"] = host
rt.HostCode[host.Host.Code] = "site.test:443"
waf.routing.Store(rt)
return waf
}
func TestWebsiteDiagnosticOriginPlan(t *testing.T) {
host := &wafenginmodel.HostSafe{
Host: model.Hosts{
Code: "site", Remote_ip: "192.0.2.1", Remote_port: 9443, IsTransBackDomain: 1,
CustomHeadersJSON: `{"is_enable_custom_headers":1,"headers":[{"header_name":"X-Config","header_value":"saved"},{"header_name":"X-Visitor","header_value":"${host}|${scheme}|${request_uri}|${request_method}"},{"header_name":"X-Remove","header_value":""},{"header_name":"X-Forwarded-For","header_value":"192.0.2.8"}]}`,
},
TargetHost: "https://origin.test:9443/base?source=1",
}
waf := diagnosticTestEngine(host)
headers := http.Header{"X-Config": {"caller"}, "X-Remove": {"remove"}, "X-Test": {"input"}, "Connection": {"X-Hop"}, "X-Hop": {"remove"}}
before := headers.Clone()
snapshot, err := waf.DiagnosticSnapshot("site", "https://site.test/a%2Fb?q=2", "HEAD", headers)
if err != nil {
t.Fatal(err)
}
if !snapshot.Loaded || snapshot.MatchCode != "site" || snapshot.Mode != "proxy" || len(snapshot.OriginPlans) != 1 {
t.Fatalf("snapshot = %+v", snapshot)
}
plan := snapshot.OriginPlans[0]
if plan.URL != "https://origin.test:9443/base/a%2Fb?source=1&q=2" || plan.Host != "origin.test" || plan.ServerName != "origin.test" || plan.FixedAddress != "192.0.2.1:9443" || plan.FallbackAddress != "origin.test:9443" {
t.Fatalf("plan = %+v", plan)
}
if plan.Headers.Get("X-Config") != "saved" || plan.Headers.Get("X-Test") != "input" || plan.Headers.Get("X-Visitor") != "site.test|https|/a%2Fb?q=2|HEAD" || plan.Headers.Get("X-Forwarded-Proto") != "https" || plan.Headers.Get("X-Forwarded-For") != "192.0.2.8" {
t.Fatalf("headers = %v", plan.Headers)
}
for _, name := range []string{"X-Remove", "Connection", "X-Hop", "X-Waf-Custom-XFF-Control"} {
if plan.Headers.Get(name) != "" {
t.Fatalf("header %s should be removed", name)
}
}
if !reflect.DeepEqual(headers, before) || waf.TransportPool != nil {
t.Fatal("planning changed input headers or transport cache")
}
encoded, err := json.Marshal(snapshot)
if err != nil || string(encoded) != "{}" {
t.Fatalf("internal snapshot serialized: %s / %v", encoded, err)
}
}
func TestWebsiteDiagnosticPathPlan(t *testing.T) {
host := &wafenginmodel.HostSafe{
Host: model.Hosts{Code: "site", CustomHeadersJSON: `{"is_enable_custom_headers":1,"headers":[{"header_name":"X-Saved","header_value":"value"}]}`},
PathRules: []model.HostPathRule{
{RuleName: "later", Priority: 10, MatchType: 1, Path: "/", TargetType: 1},
{RuleName: "chosen", Priority: 1, MatchType: 1, Path: "/api", StripPrefix: 1, RemoteHost: "path.test", RemotePort: 8080, RemoteIP: "2001:db8::1", RemoteScheme: "http"},
},
}
waf := diagnosticTestEngine(host)
before := append([]model.HostPathRule(nil), host.PathRules...)
snapshot, err := waf.DiagnosticSnapshot("site", "https://site.test/api/a%2Fb?x=1", "GET", http.Header{"X-Input": {"value"}})
if err != nil {
t.Fatal(err)
}
if snapshot.Mode != "path_proxy" || snapshot.PathRule.RuleName != "chosen" {
t.Fatalf("snapshot = %+v", snapshot)
}
plan := snapshot.OriginPlans[0]
if plan.URL != "http://path.test:8080/a%2Fb?x=1" || plan.Host != "site.test" || plan.FixedAddress != "[2001:db8::1]:8080" || plan.Headers.Get("X-Saved") != "" || plan.Headers.Get("X-Input") != "value" || plan.Headers.Get("X-Forwarded-Proto") != "https" {
t.Fatalf("path plan = %+v", plan)
}
snapshot.PathRules[0].Path = "changed"
snapshot.PathRule.Path = "changed"
if !reflect.DeepEqual(host.PathRules, before) {
t.Fatal("planning or snapshot mutation changed runtime path rules")
}
}
func TestWebsiteDiagnosticZeroPortKeepsDomainFallback(t *testing.T) {
// Remote_port/RemotePort 为 0 时不能生成 ip:0 固定地址,必须回落域名地址
zeroPortHost := &wafenginmodel.HostSafe{
Host: model.Hosts{Code: "site", Remote_ip: "192.0.2.1"},
TargetHost: "https://origin.test/base",
}
snapshot, err := diagnosticTestEngine(zeroPortHost).DiagnosticSnapshot("site", "https://site.test/a", "GET", nil)
if err != nil {
t.Fatal(err)
}
if plan := snapshot.OriginPlans[0]; plan.FixedAddress != "" || plan.FallbackAddress != "origin.test:443" {
t.Fatalf("single-origin zero port plan = %+v", plan)
}
lbHost := &wafenginmodel.HostSafe{
Host: model.Hosts{Code: "site", IsEnableLoadBalance: 1},
TargetHost: "http://origin.test",
LoadBalanceLists: []model.LoadBalance{
{BaseOrm: baseorm.BaseOrm{Id: "one"}, Remote_ip: "192.0.2.1"},
},
}
snapshot, err = diagnosticTestEngine(lbHost).DiagnosticSnapshot("site", "http://site.test/", "GET", nil)
if err != nil {
t.Fatal(err)
}
if plan := snapshot.OriginPlans[0]; plan.FixedAddress != "" || plan.FallbackAddress != "origin.test:80" {
t.Fatalf("load-balance zero port plan = %+v", plan)
}
pathHost := &wafenginmodel.HostSafe{
Host: model.Hosts{Code: "site"},
PathRules: []model.HostPathRule{
{RuleName: "zero", Priority: 1, MatchType: 1, Path: "/", TargetType: 1, RemoteHost: "path.test", RemoteIP: "192.0.2.1"},
},
}
snapshot, err = diagnosticTestEngine(pathHost).DiagnosticSnapshot("site", "http://site.test/x", "GET", nil)
if err != nil {
t.Fatal(err)
}
if plan := snapshot.OriginPlans[0]; plan.FixedAddress != "" || plan.FallbackAddress != "path.test:80" || plan.URL != "http://path.test/x" {
t.Fatalf("path-rule zero port plan = %+v", plan)
}
}
func TestWebsiteDiagnosticLoadBalanceReadOnly(t *testing.T) {
host := &wafenginmodel.HostSafe{
Host: model.Hosts{Code: "site", IsEnableLoadBalance: 1}, TargetHost: "http://origin.test:80",
LoadBalanceLists: []model.LoadBalance{
{BaseOrm: baseorm.BaseOrm{Id: "one"}, Remote_ip: "192.0.2.1", Remote_port: 8080},
{BaseOrm: baseorm.BaseOrm{Id: "two"}, Remote_ip: "192.0.2.2", Remote_port: 8081},
},
LoadBalanceRuntime: &wafenginmodel.LoadBalanceRuntime{CurrentProxyIndex: 17},
}
waf := diagnosticTestEngine(host)
snapshot, err := waf.DiagnosticSnapshot("site", "https://site.test/", "GET", nil)
if err != nil || len(snapshot.OriginPlans) != 2 {
t.Fatalf("snapshot = %+v / %v", snapshot, err)
}
if snapshot.OriginPlans[1].FixedAddress != "192.0.2.2:8081" || snapshot.OriginPlans[1].BackendID != "two" || snapshot.OriginPlans[1].URL != "http://origin.test:80/" {
t.Fatalf("LB plan = %+v", snapshot.OriginPlans[1])
}
snapshot.Backends[0].Remote_ip = "changed"
if host.LoadBalanceLists[0].Remote_ip != "192.0.2.1" || host.LoadBalanceRuntime.CurrentProxyIndex != 17 || len(host.LoadBalanceRuntime.RevProxies) != 0 || waf.TransportPool != nil {
t.Fatal("diagnostic changed runtime load balance state")
}
}
func TestWebsiteDiagnosticNonOriginModes(t *testing.T) {
for _, tc := range []struct {
name string
edit func(*wafenginmodel.HostSafe)
mode string
}{
{"disabled", func(h *wafenginmodel.HostSafe) { h.Host.START_STATUS = 1 }, "disabled"},
{"static", func(h *wafenginmodel.HostSafe) { h.Host.StaticSiteJSON = `{"is_enable_static_site":1}` }, "static"},
{"path static", func(h *wafenginmodel.HostSafe) { h.PathRules[0].TargetType = 2 }, "path_static"},
{"path redirect", func(h *wafenginmodel.HostSafe) { h.PathRules[0].TargetType = 3 }, "redirect"},
} {
t.Run(tc.name, func(t *testing.T) {
host := &wafenginmodel.HostSafe{Host: model.Hosts{Code: "site"}, PathRules: []model.HostPathRule{{MatchType: 1, Path: "/"}}}
tc.edit(host)
snapshot, err := diagnosticTestEngine(host).DiagnosticSnapshot("site", "https://site.test/", "GET", nil)
if err != nil || snapshot.Mode != tc.mode || len(snapshot.OriginPlans) != 0 {
t.Fatalf("snapshot = %+v / %v", snapshot, err)
}
})
}
}
func TestWebsiteDiagnosticRootAndHTTPSRedirect(t *testing.T) {
host := &wafenginmodel.HostSafe{Host: model.Hosts{Code: "site", Host: "site.test", Ssl: 1, AutoJumpHTTPS: 1}, TargetHost: "http://origin.test:80", PathRules: []model.HostPathRule{{MatchType: 2, Path: "/", TargetType: 3}}}
waf := diagnosticTestEngine(host)
waf.rt().HostTarget["site.test:80"] = host
snapshot, err := waf.DiagnosticSnapshot("site", "http://site.test#fragment", "GET", nil)
if err != nil || snapshot.Mode != "https_redirect" || len(snapshot.OriginPlans) != 0 {
t.Fatalf("HTTPS redirect = %+v / %v", snapshot, err)
}
snapshot, err = waf.DiagnosticSnapshot("site", "https://site.test#fragment", "GET", nil)
if err != nil || snapshot.Mode != "redirect" {
t.Fatalf("root path match = %+v / %v", snapshot, err)
}
host.PathRules = nil
snapshot, err = waf.DiagnosticSnapshot("site", "https://site.test#fragment", "GET", nil)
if err != nil || snapshot.OriginPlans[0].URL != "http://origin.test:80/" {
t.Fatalf("root origin = %+v / %v", snapshot, err)
}
}
func TestWebsiteDiagnosticMissingAndInvalid(t *testing.T) {
waf := &WafEngine{}
snapshot, err := waf.DiagnosticSnapshot("missing", "https://site.test/", "GET", nil)
if err != nil || snapshot.Loaded || snapshot.MatchKind != "unmatched" {
t.Fatalf("snapshot = %+v / %v", snapshot, err)
}
for _, input := range []string{"file:///tmp/a", "https://user:pass@site.test/", "/relative", "http://%/"} {
if _, err := waf.DiagnosticSnapshot("missing", input, "GET", nil); err == nil {
t.Errorf("accepted invalid URL %q", input)
}
}
if _, err := waf.DiagnosticSnapshot("missing", "https://site.test/", "BAD\nMETHOD", nil); err == nil {
t.Fatal("accepted invalid method")
}
host := &wafenginmodel.HostSafe{Host: model.Hosts{Code: "site"}, TargetHost: "missing-scheme:80"}
snapshot, err = diagnosticTestEngine(host).DiagnosticSnapshot("site", "https://site.test/", "GET", nil)
if err != nil || !strings.Contains(snapshot.OriginPlans[0].Error, "origin URL") {
t.Fatalf("invalid runtime origin should preserve snapshot: %+v / %v", snapshot, err)
}
}
+32
View File
@@ -0,0 +1,32 @@
package wafproxy
import (
"net/http"
"net/url"
"golang.org/x/net/http/httpguts"
)
// BuildDiagnosticRequest 复用 Director 和逐跳头处理,仅构造独立请求副本。
func BuildDiagnosticRequest(req *http.Request, target *url.URL, customHeaders, customConfig map[string]string) *http.Request {
out := req.Clone(req.Context())
if out.Header == nil {
out.Header = make(http.Header)
}
NewSingleHostReverseProxyCustomHeader(target, customHeaders, customConfig).Director(out)
out.Close = false
upgrade := upgradeType(out.Header)
removeConnectionHeaders(out.Header)
for _, name := range hopHeaders {
out.Header.Del(name)
}
if httpguts.HeaderValuesContainsToken(req.Header["Te"], "trailers") {
out.Header.Set("Te", "trailers")
}
if upgrade != "" {
out.Header.Set("Connection", "Upgrade")
out.Header.Set("Upgrade", upgrade)
}
out.Header.Del("X-Waf-Custom-XFF-Control")
return out
}
+58
View File
@@ -0,0 +1,58 @@
package wafproxy
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
)
type diagnosticCaptureTransport func(*http.Request) (*http.Response, error)
func (f diagnosticCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func TestWebsiteDiagnosticBuilderMatchesProxy(t *testing.T) {
for _, tc := range []struct {
name, target string
backDomain string
custom map[string]string
}{
{"host and escaped path", "https://origin.test:9443/base?source=1", "1", map[string]string{"X-Config": "saved", "X-Remove": "", "X-Forwarded-For": "192.0.2.1"}},
{"host without port", "http://origin.test/base", "1", nil},
{"preserve host", "http://origin.test:8080/", "0", nil},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("HEAD", "https://visitor.test/api/a%2Fb?q=2", nil)
req.RemoteAddr = ""
req.Header = http.Header{"X-Config": {"input"}, "X-Remove": {"remove"}, "Connection": {"X-Hop"}, "X-Hop": {"remove"}, "Te": {"trailers"}}
before := req.Clone(req.Context())
target, err := url.Parse(tc.target)
if err != nil {
t.Fatal(err)
}
config := map[string]string{"IsTransBackDomain": tc.backDomain}
planned := BuildDiagnosticRequest(req, target, tc.custom, config)
proxy := NewSingleHostReverseProxyCustomHeader(target, tc.custom, config)
var actual *http.Request
proxy.Transport = diagnosticCaptureTransport(func(out *http.Request) (*http.Response, error) {
actual = out.Clone(out.Context())
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil
})
proxy.ServeHTTP(httptest.NewRecorder(), req)
if actual == nil {
t.Fatal("proxy did not construct an outgoing request")
}
if actual.URL.String() != planned.URL.String() || actual.Host != planned.Host || !reflect.DeepEqual(actual.Header, planned.Header) {
t.Fatalf("planned %s host=%s headers=%v; actual %s host=%s headers=%v", planned.URL, planned.Host, planned.Header, actual.URL, actual.Host, actual.Header)
}
if !reflect.DeepEqual(req.Header, before.Header) || req.URL.String() != before.URL.String() || req.Host != before.Host {
t.Fatal("request builder changed caller request")
}
})
}
}
+16
View File
@@ -12,6 +12,22 @@
# 5. fresh_install: true 的条目只在"全新安装"时生成,不参与版本区间计算
notes:
- id: v1_3_25_website_access_diagnostic
version: v1.3.25
kind: check
level: low
page: /wafhost/website-diagnostic
apply:
type: navigate
zh:
title: 网站列表新增网站访问诊断
detail: 网站列表顶部「网站访问诊断」可按已保存网站填写实际访问网址,从服务器分段检查配置匹配、本机入口、源站和访问地址,展示请求、响应、连接与证书证据及处理建议。GET/HEAD 检测会真实访问业务并正常经过认证、防护和访问日志;不会修改网站配置。
revert: 不使用该页面即可;诊断不会保存或改变网站配置。
en:
title: Website access diagnostics added to the site list
detail: The Website Access Diagnostic action in the site-list toolbar selects a saved website and visitor URL, then independently checks configuration matching, the local ingress, origins and the access URL from the server. It shows request, response, connection and certificate evidence with handling suggestions. GET/HEAD probes make real business requests that pass through authentication, protection and access logs; website settings are not changed.
revert: Simply do not use this page; diagnostics neither save nor change website settings.
- id: v1_3_25_app_manage_setup_guide
version: v1.3.25
kind: check