mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-08-30 17:20:58 +08:00
@@ -113,6 +113,37 @@ func (w *WafOneKeyModApi) DoOneKeyModifyApi(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseNginxApi 解析nginx配置为待添加主机候选
|
||||
// @Summary 解析nginx配置
|
||||
// @Description 解析粘贴文本或扫描宝塔vhost目录,提取待添加的被防护主机候选(只读,不修改文件)
|
||||
// @Tags 一键修改
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param data body request.WafParseNginxReq true "解析参数"
|
||||
// @Success 200 {object} response.Response "解析成功"
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /wafhost/onekeymod/parseNginx [post]
|
||||
func (w *WafOneKeyModApi) ParseNginxApi(c *gin.Context) {
|
||||
var req request.WafParseNginxReq
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
return
|
||||
}
|
||||
var candidates []wafonekey.NginxHostCandidate
|
||||
switch req.Source {
|
||||
case "scan":
|
||||
candidates, err = wafonekey.ScanNginxDir(req.FilePath)
|
||||
default: // text
|
||||
candidates, err = wafonekey.ParseNginxText(req.Content)
|
||||
}
|
||||
if err != nil {
|
||||
response.FailWithMessage("解析失败 "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(candidates, "解析成功", c)
|
||||
}
|
||||
|
||||
// RestoreApi 还原一键修改
|
||||
// @Summary 还原一键修改
|
||||
// @Description 根据ID还原指定的一键修改记录到原始状态
|
||||
|
||||
@@ -18,3 +18,10 @@ type WafOneKeyModSearchReq struct {
|
||||
type WafDoOneKeyModReq struct {
|
||||
FilePath string `json:"file_path"` //文件所在路径
|
||||
}
|
||||
|
||||
// WafParseNginxReq 解析 nginx 配置为待添加主机候选
|
||||
type WafParseNginxReq struct {
|
||||
Source string `json:"source"` //来源: "text" 粘贴文本 | "scan" 扫描目录
|
||||
Content string `json:"content"` //source=text 时的粘贴内容
|
||||
FilePath string `json:"file_path"` //source=scan 时的目录
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ func (receiver *OneKeyModRouter) InitOneKeyModRouter(group *gin.RouterGroup) {
|
||||
router.POST("/api/v1/wafhost/onekeymod/doModify", api.DoOneKeyModifyApi)
|
||||
router.GET("/api/v1/wafhost/onekeymod/del", api.DelApi)
|
||||
router.GET("/api/v1/wafhost/onekeymod/restore", api.RestoreApi)
|
||||
router.POST("/api/v1/wafhost/onekeymod/parseNginx", api.ParseNginxApi)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package wafonekey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 解析相关安全上限
|
||||
const (
|
||||
maxNginxConfigLen = 1 << 20 // 粘贴文本上限 1MB
|
||||
maxScanFileCount = 500 // 扫描目录最多读取的 .conf 文件数
|
||||
maxScanFileSize = 2 << 20 // 单个 .conf 文件读取上限 2MB
|
||||
scanRequiredSubDir = "server/panel/vhost"
|
||||
)
|
||||
|
||||
// NginxHostCandidate 从一个 nginx server{} 块解析出的待添加主机候选
|
||||
type NginxHostCandidate struct {
|
||||
Domains []string `json:"domains"` // server_name 全部域名(已过滤 _ / 空 / 非法FQDN)
|
||||
Port int `json:"port"` // listen 端口 → 拟作 remote_port
|
||||
Ssl bool `json:"ssl"` // listen ssl / 存在 ssl_certificate
|
||||
Root string `json:"root"` // nginx root,仅展示/备注用,不参与代理
|
||||
SourceFile string `json:"source_file"` // 扫描模式下来源 .conf 文件名(粘贴模式为空)
|
||||
}
|
||||
|
||||
var (
|
||||
reServerBlock = regexp.MustCompile(`(?:^|\s)server\s*\{`)
|
||||
reListen = regexp.MustCompile(`(?mi)^\s*listen\s+([^;]+);`)
|
||||
reServerName = regexp.MustCompile(`(?mi)^\s*server_name\s+([^;]+);`)
|
||||
reRoot = regexp.MustCompile(`(?mi)^\s*root\s+([^;]+);`)
|
||||
reSslCert = regexp.MustCompile(`(?mi)^\s*ssl_certificate\s+[^;]+;`)
|
||||
reFQDN = regexp.MustCompile(`^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
)
|
||||
|
||||
// ParseNginxText 解析粘贴的 nginx 配置文本
|
||||
func ParseNginxText(content string) ([]NginxHostCandidate, error) {
|
||||
if len(content) > maxNginxConfigLen {
|
||||
return nil, errors.New("配置内容过长")
|
||||
}
|
||||
return parseNginxContent(content, ""), nil
|
||||
}
|
||||
|
||||
// ScanNginxDir 安全地扫描目录下 .conf 并解析
|
||||
func ScanNginxDir(dirPath string) ([]NginxHostCandidate, error) {
|
||||
safeDir, err := resolveSafeNginxDir(dirPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(safeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates := make([]NginxHostCandidate, 0)
|
||||
readCnt := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".conf") {
|
||||
continue
|
||||
}
|
||||
if readCnt >= maxScanFileCount {
|
||||
break
|
||||
}
|
||||
readCnt++
|
||||
filePath := filepath.Join(safeDir, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil || info.Size() > maxScanFileSize {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, parseNginxContent(string(data), entry.Name())...)
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// resolveSafeNginxDir 校验用户提供的目录必须是真实的 nginx vhost 目录,防止路径穿越/任意目录读取。
|
||||
// 采用「绝对化 + Clean + 路径分段判定」而非现有 OneKeyModifyBt 的弱子串判断:
|
||||
// 要求路径分段中连续出现 server/panel/vhost,避免 .../vhostEVIL 之类的旁路。
|
||||
func resolveSafeNginxDir(dirPath string) (string, error) {
|
||||
if strings.TrimSpace(dirPath) == "" {
|
||||
dirPath = "/www/server/panel/vhost/nginx"
|
||||
}
|
||||
absPath, err := filepath.Abs(filepath.Clean(dirPath))
|
||||
if err != nil {
|
||||
return "", errors.New("无法解析目录路径")
|
||||
}
|
||||
// 分段(统一为斜杠)判定
|
||||
normalized := filepath.ToSlash(absPath)
|
||||
segs := strings.Split(normalized, "/")
|
||||
cleaned := make([]string, 0, len(segs))
|
||||
for _, s := range segs {
|
||||
if s == "" || s == "." {
|
||||
continue
|
||||
}
|
||||
if s == ".." {
|
||||
return "", errors.New("目录不在允许范围内")
|
||||
}
|
||||
cleaned = append(cleaned, s)
|
||||
}
|
||||
required := strings.Split(scanRequiredSubDir, "/")
|
||||
if !containsContiguous(cleaned, required) {
|
||||
return "", errors.New("目录不在允许范围内(须为宝塔 nginx vhost 目录)")
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// containsContiguous 判断 required 是否作为连续子序列出现在 segs 中
|
||||
func containsContiguous(segs, required []string) bool {
|
||||
if len(required) == 0 || len(segs) < len(required) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i+len(required) <= len(segs); i++ {
|
||||
match := true
|
||||
for j := range required {
|
||||
if segs[i+j] != required[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseNginxContent 从配置文本中提取每个 server{} 块的候选
|
||||
func parseNginxContent(content, sourceFile string) []NginxHostCandidate {
|
||||
candidates := make([]NginxHostCandidate, 0)
|
||||
for _, block := range splitServerBlocks(content) {
|
||||
candidate, ok := parseServerBlock(block)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate.SourceFile = sourceFile
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// splitServerBlocks 用花括号配对切分出所有 server{} 块的内部内容
|
||||
func splitServerBlocks(content string) []string {
|
||||
blocks := make([]string, 0)
|
||||
locs := reServerBlock.FindAllStringIndex(content, -1)
|
||||
for _, loc := range locs {
|
||||
open := strings.IndexByte(content[loc[0]:loc[1]], '{')
|
||||
if open < 0 {
|
||||
continue
|
||||
}
|
||||
open += loc[0]
|
||||
depth := 0
|
||||
end := -1
|
||||
for i := open; i < len(content); i++ {
|
||||
switch content[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end > open {
|
||||
blocks = append(blocks, content[open+1:end])
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// parseServerBlock 解析单个 server 块内容
|
||||
func parseServerBlock(block string) (NginxHostCandidate, bool) {
|
||||
var candidate NginxHostCandidate
|
||||
|
||||
// listen:取第一条含合法端口的;ssl 为任意一条含 ssl 关键字或存在 ssl_certificate
|
||||
for _, m := range reListen.FindAllStringSubmatch(block, -1) {
|
||||
port, ssl := parseListen(m[1])
|
||||
if candidate.Port == 0 && port > 0 && port <= 65535 {
|
||||
candidate.Port = port
|
||||
}
|
||||
if ssl {
|
||||
candidate.Ssl = true
|
||||
}
|
||||
}
|
||||
if reSslCert.MatchString(block) {
|
||||
candidate.Ssl = true
|
||||
}
|
||||
if candidate.Port == 0 {
|
||||
return candidate, false
|
||||
}
|
||||
|
||||
// server_name:拆分并过滤非法域名
|
||||
if m := reServerName.FindStringSubmatch(block); m != nil {
|
||||
for _, name := range strings.Fields(m[1]) {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
if name == "" || name == "_" || name == "localhost" {
|
||||
continue
|
||||
}
|
||||
if reFQDN.MatchString(name) {
|
||||
candidate.Domains = append(candidate.Domains, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidate.Domains) == 0 {
|
||||
return candidate, false
|
||||
}
|
||||
|
||||
// root:仅展示
|
||||
if m := reRoot.FindStringSubmatch(block); m != nil {
|
||||
candidate.Root = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
// parseListen 解析 listen 指令值,返回端口与是否 ssl
|
||||
// 兼容: "81" / "127.0.0.1:81" / "[::]:81" / "443 ssl http2" / "0.0.0.0:443 ssl"
|
||||
func parseListen(value string) (int, bool) {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
ssl := false
|
||||
for _, f := range fields[1:] {
|
||||
if strings.EqualFold(f, "ssl") {
|
||||
ssl = true
|
||||
}
|
||||
}
|
||||
return extractPort(fields[0]), ssl
|
||||
}
|
||||
|
||||
// extractPort 从 listen 的地址部分取端口
|
||||
func extractPort(addr string) int {
|
||||
// IPv6: [::]:81
|
||||
if i := strings.LastIndex(addr, "]:"); i >= 0 {
|
||||
if n, err := strconv.Atoi(addr[i+2:]); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// host:port
|
||||
if i := strings.LastIndex(addr, ":"); i >= 0 {
|
||||
if n, err := strconv.Atoi(addr[i+1:]); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// 纯端口
|
||||
if n, err := strconv.Atoi(addr); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package wafonekey
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseNginxText_Example(t *testing.T) {
|
||||
content := `
|
||||
server
|
||||
{
|
||||
listen 81;
|
||||
server_name log.samwaf.net log2.samwaf.net;
|
||||
index index.php index.html index.htm default.php default.htm default.html;
|
||||
root /www/wwwroot/log.samwaf.net_81;
|
||||
location / {
|
||||
root /some/other;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 444 ssl http2;
|
||||
server_name shop.samwaf.net;
|
||||
ssl_certificate /x/a.pem;
|
||||
ssl_certificate_key /x/a.key;
|
||||
root /www/wwwroot/shop;
|
||||
}
|
||||
`
|
||||
got, err := ParseNginxText(content)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 candidates, got %d: %+v", len(got), got)
|
||||
}
|
||||
c0 := got[0]
|
||||
if c0.Port != 81 || c0.Ssl {
|
||||
t.Errorf("c0 port/ssl wrong: %+v", c0)
|
||||
}
|
||||
if len(c0.Domains) != 2 || c0.Domains[0] != "log.samwaf.net" || c0.Domains[1] != "log2.samwaf.net" {
|
||||
t.Errorf("c0 domains wrong: %+v", c0.Domains)
|
||||
}
|
||||
if c0.Root != "/www/wwwroot/log.samwaf.net_81" {
|
||||
t.Errorf("c0 root wrong: %q", c0.Root)
|
||||
}
|
||||
c1 := got[1]
|
||||
if c1.Port != 444 || !c1.Ssl {
|
||||
t.Errorf("c1 port/ssl wrong: %+v", c1)
|
||||
}
|
||||
if len(c1.Domains) != 1 || c1.Domains[0] != "shop.samwaf.net" {
|
||||
t.Errorf("c1 domains wrong: %+v", c1.Domains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListen(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
port int
|
||||
ssl bool
|
||||
}{
|
||||
{"81", 81, false},
|
||||
{"127.0.0.1:81", 81, false},
|
||||
{"[::]:81", 81, false},
|
||||
{"443 ssl http2", 443, true},
|
||||
{"0.0.0.0:443 ssl", 443, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, s := parseListen(c.in)
|
||||
if p != c.port || s != c.ssl {
|
||||
t.Errorf("parseListen(%q)=%d,%v want %d,%v", c.in, p, s, c.port, c.ssl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSafeNginxDir(t *testing.T) {
|
||||
if _, err := resolveSafeNginxDir("/www/server/panel/vhost/nginx"); err != nil {
|
||||
t.Errorf("valid dir rejected: %v", err)
|
||||
}
|
||||
bad := []string{"/etc", "/root", "/tmp/server/panel/vhostEVIL/x", "/www/server/panel/../../../etc"}
|
||||
for _, d := range bad {
|
||||
if _, err := resolveSafeNginxDir(d); err == nil {
|
||||
t.Errorf("bad dir %q accepted", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user