diff --git a/adminui/assets/app.js b/adminui/assets/app.js index 5b4012b..710a438 100644 --- a/adminui/assets/app.js +++ b/adminui/assets/app.js @@ -5,30 +5,30 @@ var basePath = adminConfig.basePath || ""; var sourceOptions = [ - { value: "", label: "Select source" }, - { value: "header", label: "Header" }, - { value: "url", label: "URL Query" }, - { value: "query", label: "Query Alias" }, - { value: "payload", label: "Payload" }, - { value: "raw-request-body", label: "Raw Request Body" }, - { value: "request", label: "Request" }, - { value: "string", label: "Static String" }, - { value: "entire-payload", label: "Entire Payload" }, - { value: "entire-query", label: "Entire Query" }, - { value: "entire-headers", label: "Entire Headers" } + { value: "", label: "Select source / 选择来源" }, + { value: "header", label: "Header / 请求头" }, + { value: "url", label: "URL Query / URL查询参数" }, + { value: "query", label: "Query Alias / 查询别名" }, + { value: "payload", label: "Payload / 请求体" }, + { value: "raw-request-body", label: "Raw Request Body / 原始请求体" }, + { value: "request", label: "Request / 请求信息" }, + { value: "string", label: "Static String / 静态字符串" }, + { value: "entire-payload", label: "Entire Payload / 完整请求体" }, + { value: "entire-query", label: "Entire Query / 完整查询参数" }, + { value: "entire-headers", label: "Entire Headers / 完整请求头" } ]; var matchTypeOptions = [ - { value: "value", label: "Value Equals" }, - { value: "regex", label: "Regex Match" }, - { value: "payload-hmac-sha1", label: "Payload HMAC SHA1" }, - { value: "payload-hmac-sha256", label: "Payload HMAC SHA256" }, - { value: "payload-hmac-sha512", label: "Payload HMAC SHA512" }, - { value: "payload-hash-sha1", label: "Payload Hash SHA1 (Deprecated)" }, - { value: "payload-hash-sha256", label: "Payload Hash SHA256 (Deprecated)" }, - { value: "payload-hash-sha512", label: "Payload Hash SHA512 (Deprecated)" }, - { value: "ip-whitelist", label: "IP Whitelist" }, - { value: "scalr-signature", label: "Scalr Signature" } + { value: "value", label: "Value Equals / 值匹配" }, + { value: "regex", label: "Regex Match / 正则匹配" }, + { value: "payload-hmac-sha1", label: "Payload HMAC SHA1 / 签名验证" }, + { value: "payload-hmac-sha256", label: "Payload HMAC SHA256 / 签名验证" }, + { value: "payload-hmac-sha512", label: "Payload HMAC SHA512 / 签名验证" }, + { value: "payload-hash-sha1", label: "Payload Hash SHA1 (已弃用)" }, + { value: "payload-hash-sha256", label: "Payload Hash SHA256 (已弃用)" }, + { value: "payload-hash-sha512", label: "Payload Hash SHA512 (已弃用)" }, + { value: "ip-whitelist", label: "IP Whitelist / IP白名单" }, + { value: "scalr-signature", label: "Scalr Signature / Scalr签名" } ]; var state = { @@ -581,9 +581,13 @@ } if (match.type === "ip-whitelist") { - fields.appendChild(createLabeledField("IP Range", createTextInput(match["ip-range"] || match.ipRange, "192.168.1.0/24 10.0.0.1", function (nextRange) { + fields.appendChild(createLabeledField("IP Range (IP白名单)", createTextInput(match["ip-range"] || match.ipRange, "192.168.1.0/24 10.0.0.1", function (nextRange) { ensureMatch(rule)["ip-range"] = nextRange; }))); + var hint = document.createElement("div"); + hint.className = "field-hint"; + hint.textContent = "支持 CIDR 和单 IP,空格分隔多个。若经过反向代理,需配置 --trusted-proxies 和 --real-ip-header 才能获取真实客户端 IP。"; + fields.appendChild(hint); } card.appendChild(fields); diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 110e2bd..f87dce4 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -995,7 +995,11 @@ const ( // Evaluate MatchRule will return based on the type func (r MatchRule) Evaluate(req *Request) (bool, error) { if r.Type == IPWhitelist { - return CheckIPWhitelist(req.RawRequest.RemoteAddr, r.IPRange) + addr := req.RealIP + if addr == "" { + addr = req.RawRequest.RemoteAddr + } + return CheckIPWhitelist(addr, r.IPRange) } if r.Type == ScalrSignature { return CheckScalrSignature(req, r.Secret, true) diff --git a/internal/hook/request.go b/internal/hook/request.go index 6e0bd0c..df1f00e 100644 --- a/internal/hook/request.go +++ b/internal/hook/request.go @@ -36,6 +36,11 @@ type Request struct { // Treat signature errors as simple validate failures. AllowSignatureErrors bool + + // RealIP is the resolved client IP address. When the request comes through + // a trusted reverse proxy, this is extracted from the configured header + // (e.g. X-Real-Ip). Otherwise it equals RawRequest.RemoteAddr. + RealIP string } func (r *Request) ParseJSONPayload() error { diff --git a/realip.go b/realip.go new file mode 100644 index 0000000..bca60e6 --- /dev/null +++ b/realip.go @@ -0,0 +1,90 @@ +package main + +import ( + "net" + "net/http" + "strings" +) + +// parsedTrustedProxies holds the parsed CIDR networks from the --trusted-proxies flag. +var parsedTrustedProxies []*net.IPNet + +// initTrustedProxies parses the --trusted-proxies flag value into CIDR networks. +// Must be called after flag.Parse(). +func initTrustedProxies() { + if *trustedProxies == "" { + return + } + + for _, entry := range strings.Split(*trustedProxies, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + // If it's a plain IP without CIDR notation, add /32 (IPv4) or /128 (IPv6). + if !strings.Contains(entry, "/") { + if strings.Contains(entry, ":") { + entry += "/128" + } else { + entry += "/32" + } + } + + _, cidr, err := net.ParseCIDR(entry) + if err != nil { + continue + } + parsedTrustedProxies = append(parsedTrustedProxies, cidr) + } +} + +// isTrustedProxy checks whether the given remote address (IP:port) belongs to +// a trusted proxy network. +func isTrustedProxy(remoteAddr string) bool { + if len(parsedTrustedProxies) == 0 { + return false + } + + ip := extractIP(remoteAddr) + if ip == nil { + return false + } + + for _, cidr := range parsedTrustedProxies { + if cidr.Contains(ip) { + return true + } + } + return false +} + +// resolveRealIP determines the real client IP address. If the request comes +// from a trusted proxy and --real-ip-header is configured, the IP is read from +// that header. Otherwise the TCP remote address is used. +func resolveRealIP(r *http.Request) string { + if *realIPHeader != "" && isTrustedProxy(r.RemoteAddr) { + headerVal := strings.TrimSpace(r.Header.Get(*realIPHeader)) + if headerVal != "" { + // X-Forwarded-For may contain multiple IPs; take the first one. + if idx := strings.IndexByte(headerVal, ','); idx != -1 { + headerVal = strings.TrimSpace(headerVal[:idx]) + } + return headerVal + } + } + return r.RemoteAddr +} + +// extractIP parses an IP from an address string that may include a port. +func extractIP(addr string) net.IP { + addr = strings.Trim(addr, " []") + + // Try host:port split first. + host, _, err := net.SplitHostPort(addr) + if err == nil { + return net.ParseIP(host) + } + + return net.ParseIP(addr) +} diff --git a/webhook.go b/webhook.go index 7a3787c..1c57733 100644 --- a/webhook.go +++ b/webhook.go @@ -51,6 +51,8 @@ var ( maxMultipartMem = flag.Int64("max-multipart-mem", 1<<20, "maximum memory in bytes for parsing multipart form data before disk caching") httpMethods = flag.String("http-methods", "", `set default allowed HTTP methods (ie. "POST"); separate methods with comma`) pidPath = flag.String("pidfile", "", "create PID file at the given path") + realIPHeader = flag.String("real-ip-header", "", "header to extract real client IP from when behind a reverse proxy (e.g. X-Real-Ip)") + trustedProxies = flag.String("trusted-proxies", "", "comma-separated list of trusted proxy IPs or CIDRs; required for real-ip-header to take effect") responseHeaders hook.ResponseHeaders hooksFiles hook.HooksFiles @@ -110,6 +112,8 @@ func main() { flag.Parse() + initTrustedProxies() + if err := initExecutionSettings(); err != nil { fmt.Println("error:", err) os.Exit(1) @@ -358,9 +362,10 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { req := &hook.Request{ ID: middleware.GetReqID(r.Context()), RawRequest: r, + RealIP: resolveRealIP(r), } - log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, r.RemoteAddr) + log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, req.RealIP) // TODO: rename this to avoid confusion with Request.ID id := mux.Vars(r)["id"] diff --git a/webhook.yaml.example b/webhook.yaml.example index 8d0ed9b..960dac8 100644 --- a/webhook.yaml.example +++ b/webhook.yaml.example @@ -24,6 +24,25 @@ urlprefix: wh # 留空表示允许所有方法 # http-methods: "POST" +# ============================================================ +# 反向代理 / 真实 IP +# ============================================================ + +# 当 webhook 部署在 Nginx 等反向代理后面时,RemoteAddr 是代理的内网 IP, +# 需要通过以下两个参数配合,让 ip-whitelist 规则能正确识别真实客户端 IP。 + +# 从哪个请求头获取真实客户端 IP +# 常见值: "X-Real-Ip"(Nginx proxy_set_header X-Real-IP) +# "X-Forwarded-For"(取第一个 IP) +# 留空表示不启用,直接使用 RemoteAddr +# real-ip-header: "X-Real-Ip" + +# 可信代理 IP 或 CIDR 列表,逗号分隔 +# 只有当请求来自这些地址时,才会从 real-ip-header 中提取真实 IP +# 防止客户端伪造 header 绕过 IP 白名单 +# 示例: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +# trusted-proxies: "127.0.0.1,10.0.0.0/8" + # ============================================================ # Hook 定义文件 # ============================================================