mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-11-03 11:41:55 +08:00
feat: add staged website access diagnostics
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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) //端口占用总览
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
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
@@ -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 目标重新判定(含带外清单);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 检查域名是否已经注册
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user