diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index cc7996c0..cbce92d1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -1,6 +1,5 @@ name: Bug description: Report a bug -# Create a report to help us improve title: "[Bug] " body: @@ -8,38 +7,46 @@ body: attributes: value: | Please search the [open issues](https://github.com/chaitin/SafeLine/issues) and [discussion](https://github.com/chaitin/SafeLine/discussions) for duplicate issue first. - The more information you share, the faster we can identify and fix the bug. - # Please check for duplicate issue first. + + - type: input + id: version + attributes: + label: SafeLine version + description: | + Please provide the exact SafeLine version. + For nightly or custom builds, provide the image tag or commit ID. + placeholder: "e.g. 9.4.0" + validations: + required: true + - type: textarea - id: Description + id: description attributes: - label: What happened? - # Describe the bug + label: Bug description + description: Please describe what happened and what you expected to happen. + value: | + What happened: + + What did you expect: validations: - required: false + required: true + - type: textarea - id: Reproduce + id: reproduce attributes: - label: How we reproduce? - description: | - Reports cannot be reproduced will Most likely be closed. - # To Reproduce + label: Steps to reproduce (if available) + description: If you can reproduce the issue, list the minimum steps. value: | 1. ... 2. ... 3. ... + - type: textarea - id: Expected - attributes: - label: Expected behavior - # placeholder: | - # Descript what you expected to happen. - # Expected behavior. Descript what you expected to happen. - - type: textarea - id: Errorlog + id: evidence attributes: - label: Error log + label: Logs or screenshots (optional) + description: | + Paste relevant logs or drag screenshots here. + Please remove sensitive information before submitting. placeholder: | - Paste the error logs if any. - # Expected behavior. Descript what you expected to happen. - + Paste logs or screenshots here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 371e2e15..85b57a38 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,5 +1,4 @@ name: Suggestion -# Feature request description: New feature or improvements. title: "[Suggestion] " @@ -8,21 +7,22 @@ body: attributes: value: | Please search the [open issues](https://github.com/chaitin/SafeLine/issues) and [discussion](https://github.com/chaitin/SafeLine/discussions) for duplicate issue first. - Please rise only one suggestion in an issue. + Please submit only one suggestion in an issue. + - type: textarea id: solution attributes: label: What would you like to be added or improved? - # Describe the solution you'd like - # placeholder: | - # + description: Please describe the feature or improvement you would like to see. + placeholder: Describe the desired feature or improvement. + validations: + required: true + - type: textarea id: problem attributes: label: Why is it needed? - # Background and the specific problem that frustrates you - placeholder: | - Background and the problem that frustrates you - + description: Please explain the background and the problem this suggestion would solve. + placeholder: Describe the background and the problem that frustrates you. validations: required: true diff --git a/README.md b/README.md index e29364fa..464cd3a8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,24 @@ 中文版

+

🏆 Achievements

+ +

+ + chaitin/SafeLine — #1 GitHub Trending Repository + + + chaitin/SafeLine — #1 Go Repository of the Day + +

+ + chaitin/SafeLine — #1 Go Repository of the Week + + + chaitin/SafeLine — #9 Go Repository of the Month + +

+ ## 👋 INTRODUCTION SafeLine is a self-hosted **`WAF(Web Application Firewall)`** to protect your web apps from attacks and exploits. @@ -53,7 +71,7 @@ List of the main features as follows: - **`Rate Limiting`** - Defend your web apps against `DoS attacks`, `bruteforce attempts`, `traffic surges`, and other types of abuse by throttling traffic that exceeds defined limits. - **`Anti-Bot Challenge`** - - Anti-Bot challenges to protect your website from `bot attacks`, humen users will be allowed, crawlers and bots will be blocked. + - Anti-Bot challenges to protect your website from `bot attacks`, human users will be allowed, crawlers and bots will be blocked. - **`Authentication Challenge`** - When authentication challenge turned on, visitors need to enter the password, otherwise they will be blocked. - **`Dynamic Protection`** @@ -73,7 +91,7 @@ List of the main features as follows: ## 🚀 Quickstart > [!WARNING] -> 中国大陆用户安装国际版可能会导致无法连接云服务,请查看 [中文版安装文档](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%AE%89%E8%A3%85%E9%9B%B7%E6%B1%A0) +> 中国大陆用户安装国际版可能会导致无法连接云服务,请查看 [中文版安装文档](https://help.waf-ce.chaitin.cn/node/01973fc6-df0f-7650-bafa-8ed8d2fc2bc1) #### 📦 Installing @@ -83,7 +101,7 @@ Information on how to install SafeLine can be found in the [Install Guide](https to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication) -## 📋 More Informations +## 📋 More Information #### Effect Evaluation @@ -99,9 +117,22 @@ to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication Yes, SafeLine is production-ready. -- Over 180,000 installations worldwide -- Protecting over 1,000,000 Websites -- Handling over 30,000,000,000 HTTP Requests Daily +- Over 700,000 installations worldwide +- Protecting over 2,000,000 Websites +- Handling over 50,000,000,000 HTTP Requests Daily + +#### 🌟 Community Recognition + +- Rated **10.0/10** by [HelloGitHub](https://hellogithub.com/en/repository/0d07cfe266af4c25ba3eadf2c3d06f50) +- Listed in [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted-data/blob/master/software/safeline.yml) +- Featured on [Product Hunt](https://www.producthunt.com/products/safeline-waf/launches/safeline-waf) +- Recognized by [Cyber Press](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) as the most popular open-source web firewall on GitHub + +#### 🔌 Ecosystem Integrations + +- [SafeLine MCP Server](./mcp_server/README.md) for AI-assisted management and control +- [Ingress-NGINX](./sdk/ingress-nginx/README.md) plugin for protecting Kubernetes ingress traffic +- [Kong Gateway](./sdk/kong/Readme.md) plugin for protecting APIs and services #### 🙋‍♂️ Community @@ -121,7 +152,7 @@ Several contact options exist for our community, the primary one being Discord. #### 💪 PRO Edition -Coming soon! +SafeLine PRO is now available! [Explore plans and pricing](https://cyberserval.tech/landing/safeline#safeline-price) or [start a 7-day trial](https://safeline.app.cyberserval.tech/). #### 📝 License diff --git a/README_CN.md b/README_CN.md index 4da0f5ac..ffd6bf94 100644 --- a/README_CN.md +++ b/README_CN.md @@ -8,12 +8,30 @@

🏠 官网   |   - 📖 文档   |   + 📖 文档   |   🔍 演示环境   |   🙋‍♂️ 社区微信群   |   国际版

+

🏆 项目荣誉

+ +

+ + chaitin/SafeLine — GitHub Trending 第一名 + + + chaitin/SafeLine — Go 项目日榜第一名 + +

+ + chaitin/SafeLine — Go 项目周榜第一名 + + + chaitin/SafeLine — Go 项目月榜第九名 + +

+ ## 👋 项目介绍 SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web 应用防火墙(WAF)`**,可以保护 Web 服务不受黑客攻击。 @@ -72,11 +90,11 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web #### 📦 安装 -查看 [安装雷池](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%AE%89%E8%A3%85%E9%9B%B7%E6%B1%A0) +查看 [安装雷池](https://help.waf-ce.chaitin.cn/node/01973fc6-df0f-7650-bafa-8ed8d2fc2bc1) #### ⚙️ 配置防护站点 -查看 [快速配置](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%BF%AB%E9%80%9F%E9%85%8D%E7%BD%AE) +查看 [快速配置](https://help.waf-ce.chaitin.cn/node/01973fc6-e14a-7234-8acd-bdba21c8b3f3) ## 📋 更多信息 @@ -94,10 +112,22 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web 是的,已经有不少用户将雷池投入生产使用,截至目前 -- 全球累计装机量已超过 18 万台 +- [全球累计装机量已超过 40 万台](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) - 防护的网站数量超过 100 万个 - 每天清洗 HTTP 请求超过 300 亿次 +#### 🌟 社区认可 + +- 获得 [HelloGitHub](https://hellogithub.com/en/repository/0d07cfe266af4c25ba3eadf2c3d06f50) **10.0/10** 评分 +- 被 [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted-data/blob/master/software/safeline.yml) 收录 +- 被 [Cyber Press](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) 评为 GitHub 上最受欢迎的开源 Web 应用防火墙 + +#### 🔌 生态集成 + +- [SafeLine MCP Server](./mcp_server/README.md):支持通过 AI 辅助管理和控制雷池 +- [Ingress-NGINX](./sdk/ingress-nginx/README.md):保护 Kubernetes Ingress 流量 +- [Kong Gateway](./sdk/kong/Readme.md):保护经过 Kong 的 API 和服务 + #### 🙋‍♂️ 用户社区 欢迎加入雷池 [社区微信群](/images/wechat.png) 进行技术交流。 @@ -112,4 +142,4 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web #### 💪 专业版 -查看 [社区版 vs 专业版](https://waf-ce.chaitin.cn/version) +雷池专业版现已发布!查看 [社区版 vs 专业版](https://waf-ce.chaitin.cn/version),选择适合你的版本。 diff --git a/compose.yaml b/compose.yaml index 5536977c..d4242361 100644 --- a/compose.yaml +++ b/compose.yaml @@ -43,6 +43,24 @@ services: test: curl -k -f https://localhost:1443/api/open/health environment: - MGT_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable + # Optional override for the control channel token that tcontrollerd has to + # present before it may subscribe. Leave it empty to use + # ${SAFELINE_DIR}/resources/sock/mgt_grpc_token, which this container + # creates and the tengine container reads. + - MGT_GRPC_TOKEN=${MGT_GRPC_TOKEN:-} + # Token required on GET /api/OTPUrl as X-Bootstrap-Token. Leave empty + # to let the process generate one into + # ${SAFELINE_DIR}/resources/sock/tfa_bootstrap_token (0600) and log the + # path. The console must send that value; until it does, bind with curl. + - MGT_BOOTSTRAP_TOKEN=${MGT_BOOTSTRAP_TOKEN:-} + # How long the account may be bound without a credential, measured from + # the moment the installation was set up (or from `mgt -reset_user + # admin`, which opens it again). Defaults to 30m; set it to 0 to leave + # the bootstrap open until the first login, which is what older releases + # did. A console that is bound after the window closed answers with an + # error that names the command to reopen it, and never rejects a pending + # secret that is already bound to the address that asks for it. + - MGT_BOOTSTRAP_WINDOW=${MGT_BOOTSTRAP_WINDOW:-} depends_on: - postgres - fvm @@ -83,6 +101,9 @@ services: environment: - TCD_MGT_API=https://${SUBNET_PREFIX}.4:1443/api/open/publish/server - TCD_SNSERVER=${SUBNET_PREFIX}.5:8000 + # Optional override for the control channel token; empty means the token + # file in ${SAFELINE_DIR}/resources/sock is used. + - TCD_MGT_TOKEN=${MGT_GRPC_TOKEN:-} # deprecated - SNSERVER_ADDR=${SUBNET_PREFIX}.5:8000 - CHAOS_ADDR=${SUBNET_PREFIX}.10 @@ -140,4 +161,4 @@ services: - ${SAFELINE_DIR}/resources/chaos:/app/chaos networks: safeline-ce: - ipv4_address: ${SUBNET_PREFIX}.10 \ No newline at end of file + ipv4_address: ${SUBNET_PREFIX}.10 diff --git a/images/wechat.png b/images/wechat.png index 436ed63b..c72cb85c 100644 Binary files a/images/wechat.png and b/images/wechat.png differ diff --git a/management/tcontrollerd/config.yml b/management/tcontrollerd/config.yml index 04cf50ce..5f2c49a9 100644 --- a/management/tcontrollerd/config.yml +++ b/management/tcontrollerd/config.yml @@ -2,4 +2,9 @@ log: output: stdout # "stdout", "stderr" or file path level: debug # "debug", "info", "warn" or "error" -mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver \ No newline at end of file +mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver +# Shared secret of the control channel. mgt-api creates it in a directory that +# both containers mount (see the safeLine compose file); set MGT_TOKEN_FILE to +# point somewhere else, or TCD_MGT_TOKEN to pass the value through the +# environment instead. +# mgt_token_file: /app/sock/mgt_grpc_token diff --git a/management/tcontrollerd/controller/controller.go b/management/tcontrollerd/controller/controller.go index 5eb56284..eeda201f 100644 --- a/management/tcontrollerd/controller/controller.go +++ b/management/tcontrollerd/controller/controller.go @@ -1,9 +1,14 @@ package controller import ( + "context" + "os" + "strings" + "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "chaitin.cn/dev/go/errors" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/log" pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website" @@ -13,11 +18,38 @@ var ( logger = log.GetLogger("controller") ) +const ( + // TokenEnv holds the control channel token itself. + TokenEnv = "TCD_MGT_TOKEN" + + // tokenMetadataKey is the metadata key that carries the token to the + // management server. gRPC metadata keys have to be lower case. + tokenMetadataKey = "mgt-token" +) + func Handle() error { logger.Infof("Connect mgt-webserver at %s", config.GlobalConfig.MgtWebserver) - gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, []grpc.DialOption{ - grpc.WithTransportCredentials(insecure.NewCredentials()), - }...) + + // The management server only accepts a subscription that proves it belongs + // to this installation. + token, err := controlToken() + if err != nil { + logger.Errorf("Fail to read the control channel token: %v", err) + return err + } + + dialOptions := []grpc.DialOption{} + + // The token below proves that this is tcontrollerd, and the certificate + // proves that the peer is the management server of this installation: + // reading the traffic of the deployment is not enough to take either side. + creds, err := transportCreds() + if err != nil { + return err + } + dialOptions = append(dialOptions, grpc.WithTransportCredentials(creds)) + + gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, dialOptions...) if err != nil { logger.Errorf("Fail to dial: %v", err) return err @@ -33,9 +65,34 @@ func Handle() error { } }(gRPCConn) - if err = websiteHandler(wsClient); err != nil { + ctx := metadata.AppendToOutgoingContext(context.Background(), tokenMetadataKey, token) + if err = websiteHandler(ctx, wsClient); err != nil { return err } return nil } + +// controlToken returns the shared secret of the control channel. +// +// The value is taken from the environment when it is set there, and from the +// token file otherwise. The management server creates that file in the +// directory both containers mount, so the standard deployment needs no +// configuration. +func controlToken() (string, error) { + if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" { + return token, nil + } + + content, err := os.ReadFile(config.GlobalConfig.MgtTokenFile) + if err != nil { + return "", errors.Wrapf(err, "read the control channel token from %s", config.GlobalConfig.MgtTokenFile) + } + + token := strings.TrimSpace(string(content)) + if token == "" { + return "", errors.New("the control channel token file " + config.GlobalConfig.MgtTokenFile + " is empty") + } + + return token, nil +} diff --git a/management/tcontrollerd/controller/creds.go b/management/tcontrollerd/controller/creds.go new file mode 100644 index 00000000..f506ccb2 --- /dev/null +++ b/management/tcontrollerd/controller/creds.go @@ -0,0 +1,76 @@ +package controller + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc/credentials" + + "chaitin.cn/dev/go/errors" + "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config" +) + +const ( + // TLSEnv names the environment variable that used to allow a plaintext + // control channel. Plaintext is no longer supported: a value of "0" / + // off / false / no is an error. + TLSEnv = "TCD_MGT_TLS" + + // ServerCertFile is the certificate the management server is expected to + // present, ClientCertFile and ClientKeyFile are the credentials issued for + // this side of the control channel, and CertAuthorityFile is the authority + // both of them are issued by. + ServerName = "waf-management-server" + ClientCertFile = "tcd_client.crt" + ClientKeyFile = "tcd_client.key" + CertAuthorityFile = "grpc_ca.crt" +) + +// transportCreds returns the credentials of the control channel. +// +// The channel is always reached over TLS. Missing credentials or an explicit +// request for plaintext are errors. +func transportCreds() (credentials.TransportCredentials, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { + case "0", "off", "false", "no": + return nil, errors.New("plaintext is not supported: unset " + TLSEnv + " and use credentials published by mgt -gen_certs") + } + + return loadTransportCreds() +} + +// loadTransportCreds reads the credentials the management server published for +// this side of the control channel. +func loadTransportCreds() (credentials.TransportCredentials, error) { + dir := config.CertDir() + + certPath := filepath.Join(dir, ClientCertFile) + certificate, err := tls.LoadX509KeyPair(certPath, filepath.Join(dir, ClientKeyFile)) + if err != nil { + return nil, errors.Wrapf(err, "load %s", certPath) + } + + caPath := filepath.Join(dir, CertAuthorityFile) + content, err := os.ReadFile(caPath) + if err != nil { + return nil, errors.Wrapf(err, "read %s", caPath) + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(content) { + return nil, errors.New("no certificate found in " + caPath) + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{certificate}, + RootCAs: roots, + // The server is reached at an address of the deployment, which no + // certificate is issued for: the name it is issued for is fixed on + // both sides instead, and it is checked against the certificate. + ServerName: ServerName, + MinVersion: tls.VersionTLS12, + }), nil +} diff --git a/management/tcontrollerd/controller/validation.go b/management/tcontrollerd/controller/validation.go new file mode 100644 index 00000000..83bdf1ce --- /dev/null +++ b/management/tcontrollerd/controller/validation.go @@ -0,0 +1,154 @@ +package controller + +import ( + "fmt" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +const ( + // maxPort is the largest TCP port that may be listened on. + maxPort = 65535 + + // maxServerNameLength and maxLabelLength follow the DNS limits. + maxServerNameLength = 253 + maxLabelLength = 63 +) + +var ( + // portPattern matches the decimal notation of a TCP port. + portPattern = regexp.MustCompile(`^[0-9]{1,5}$`) + + // serverNameLabelPattern matches one label of a literal host name. The + // accepted character set is deliberately narrow: the value ends up in an + // nginx "server_name" directive, so anything that could terminate the + // directive (or start another one) must not pass. + serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`) + + // certFilenamePattern matches the file name of a certificate or a key. The + // name is rendered into "ssl_certificate" and "ssl_certificate_key", and + // it is joined to the certificate directory, so only a plain file name + // without a path separator, a control character or any nginx syntax may + // pass. + certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + + // upstreamSchemePattern matches the two schemes an upstream may use. The + // scheme is rendered into "proxy_pass", so anything else is rejected. + upstreamSchemePattern = regexp.MustCompile(`^https?$`) + + // upstreamHostPattern matches the host of an upstream: a host name, an IPv4 + // address or a bracketed IPv6 address, with an optional port. The host is + // rendered into the "server" directive of the site, so a value carrying + // nginx syntax (a space, a semicolon, a "$", a quote) must not pass. + upstreamHostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\.?)(?::([0-9]{1,5}))?$`) +) + +// maxCertFilenameLength bounds a certificate file name. +const maxCertFilenameLength = 255 + +// validateCertFilename accepts the name of a file in the certificate directory. +// +// The management server validates the same value before it stores it, but this +// side cannot rely on that: the configuration is pushed over the network, and a +// name carrying nginx syntax would turn into directives that run commands. +func validateCertFilename(name string) error { + // An empty name means "this site is served over plain HTTP". + if name == "" { + return nil + } + + if len(name) > maxCertFilenameLength { + return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength) + } + + if name != filepath.Base(name) || name == "." || name == ".." { + return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name) + } + + if !certFilenamePattern.MatchString(name) { + return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name) + } + + return nil +} + +// validatePort accepts the decimal notation of a TCP port. It rejects any +// other value because the port is interpolated into a "listen" directive. +func validatePort(port string) error { + if !portPattern.MatchString(port) { + return fmt.Errorf("invalid port %q: only digits are allowed", port) + } + + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid port %q: must be between 1 and %d", port, maxPort) + } + + return nil +} + +// validateUpstream accepts the address of a site upstream. +// +// The management server validates the same value before it stores it, but this +// side cannot rely on that: the site configuration is pushed over the network, +// and the scheme and the host of an upstream are rendered into the "proxy_pass" +// and "server" directives of the site. +func validateUpstream(upstream string) error { + parsed, err := url.Parse(upstream) + if err != nil { + return fmt.Errorf("invalid upstream %q: %v", upstream, err) + } + + if !upstreamSchemePattern.MatchString(parsed.Scheme) { + return fmt.Errorf("invalid upstream %q: the scheme has to be http or https", upstream) + } + + host := parsed.Host + if parsed.Scheme == HttpsScheme && parsed.Port() == "" { + // generateNginxConfig appends the default HTTPS port to this value + // before it renders it, so validate the value it renders. + host = host + ":" + DefaultHttpsPort + } + + matches := upstreamHostPattern.FindStringSubmatch(host) + if matches == nil { + return fmt.Errorf("invalid upstream %q: %q is not a host name, an IP address or a host with a port", upstream, host) + } + + if port := matches[1]; port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid upstream %q: port has to be between 1 and %d", upstream, maxPort) + } + } + + return nil +} + +// validateServerName accepts a literal host name, optionally with a wildcard +// as its first label. It rejects any other value because the name is +// interpolated into a "server_name" directive. +func validateServerName(name string) error { + if len(name) > maxServerNameLength { + return fmt.Errorf("invalid server name %q: longer than %d characters", name, maxServerNameLength) + } + + for i, label := range strings.Split(name, ".") { + if label == "*" { + // nginx only accepts a wildcard as the left most label. + if i != 0 { + return fmt.Errorf("invalid server name %q: wildcard must be the first label", name) + } + continue + } + + if len(label) > maxLabelLength || !serverNameLabelPattern.MatchString(label) { + return fmt.Errorf("invalid server name %q: %q is not a valid host name label", name, label) + } + } + + return nil +} diff --git a/management/tcontrollerd/controller/website.go b/management/tcontrollerd/controller/website.go index 48bf716e..694aef5d 100644 --- a/management/tcontrollerd/controller/website.go +++ b/management/tcontrollerd/controller/website.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" + "chaitin.cn/dev/go/errors" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/model" "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/ngcmd" pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website" @@ -30,6 +31,10 @@ const ( nginxBackupFilePrefix = "BAK_IF_backend_" nginxFileMode = 0644 + // nginxCertsPath is the directory the ssl_certificate templates below point + // at; certificate file names have to stay inside it. + nginxCertsPath = "/etc/nginx/certs" + HttpsScheme = "https" DefaultHttpsPort = "443" ) @@ -48,6 +53,11 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { if urlInfo.Scheme == HttpsScheme && urlInfo.Port() == "" { urlInfo.Host = urlInfo.Host + ":" + DefaultHttpsPort } + // The host and the scheme are rendered into "server" and "proxy_pass", + // so a value carrying nginx syntax has to be rejected here. + if err := validateUpstream(upstream); err != nil { + return "", err + } upstreamAddr = fmt.Sprintf(upstreamAddrTpl, urlInfo.Host) if len(urlInfo.Scheme) > 0 { scheme = urlInfo.Scheme @@ -58,6 +68,19 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { sslCertFilename := "" sslCertKeyFilename := "" if website.KeyFilename != "" && website.CertFilename != "" { + for _, filename := range []string{website.CertFilename, website.KeyFilename} { + if err := validateCertFilename(filename); err != nil { + return "", err + } + + // The template renders the name into an absolute path, so an + // explicit check backs up the name validation above: the resolved + // path has to stay inside the certificate directory. + if filepath.Dir(filepath.Join(nginxCertsPath, filename)) != nginxCertsPath { + return "", fmt.Errorf("invalid certificate file name %q: it leaves %s", filename, nginxCertsPath) + } + } + sslFlag = " ssl" // with a blank ahead sslCertFilename = fmt.Sprintf(certTpl, website.CertFilename) sslCertKeyFilename = fmt.Sprintf(certKeyTpl, website.KeyFilename) @@ -70,6 +93,8 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { if sn == "*" || sn == "" { sn = "_" addrAnyProperties = addrAnyPropertiesTpl + } else if err := validateServerName(sn); err != nil { + return "", err } serverName = fmt.Sprintf(serverNameTpl, sn) } @@ -77,6 +102,9 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) { // only ONE port supported in v1.0 var serverListen string for _, port := range website.Ports { + if err := validatePort(port); err != nil { + return "", err + } serverListen = fmt.Sprintf(serverListenTpl, port, sslFlag, addrAnyProperties) } @@ -105,11 +133,38 @@ func generateFullConfigAndReload(msg []byte) error { return err } + // "null" is what a query that returned nothing looks like on the wire, and + // a full push that carries it would delete every configuration of the + // installation. The management server always sends an array, so a payload + // that is not a list is refused and the current configuration is kept: an + // installation that really has no site sends an empty array, which is + // applied. + if websites == nil { + return errors.New("the full push does not carry a list of sites, keeping the current configuration") + } + + if err := utils.EnsureDir(nginxConfigPath); err != nil { + return err + } + configFilename := make(map[string]struct{}) for _, website := range websites { configFilename[fmt.Sprintf("%s%d", nginxFilePrefix, website.Id)] = struct{}{} } - filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error { + + if err := filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error { + // filepath.Walk reports the entries it could not read through err and + // leaves info nil in that case: touching info before this check is a + // nil pointer dereference that takes the whole daemon down. + if err != nil { + logger.Warnf("Skip %s: %v", path, err) + return nil + } + + if info == nil || info.IsDir() { + return nil + } + _, ok := configFilename[info.Name()] if !ok && strings.HasPrefix(info.Name(), nginxFilePrefix) { if err := os.Remove(path); err != nil { @@ -119,7 +174,9 @@ func generateFullConfigAndReload(msg []byte) error { } return nil - }) + }); err != nil { + return err + } for _, website := range websites { if err := generateConfigAndReload(&website); err != nil { @@ -226,8 +283,8 @@ func sendResponse(stream pb.Website_SubscribeClient, eventType string, errMsg [] }) } -func websiteHandler(wc pb.WebsiteClient) error { - stream, err := wc.Subscribe(context.Background()) +func websiteHandler(ctx context.Context, wc pb.WebsiteClient) error { + stream, err := wc.Subscribe(ctx) if err != nil { logger.Errorf("Subscribe failed: %v", err) return err diff --git a/management/tcontrollerd/go.mod b/management/tcontrollerd/go.mod index aba1d943..cc1a1723 100644 --- a/management/tcontrollerd/go.mod +++ b/management/tcontrollerd/go.mod @@ -1,8 +1,8 @@ module chaitin.cn/patronus/safeline-2/management/tcontrollerd -go 1.21 +go 1.25.0 -toolchain go1.21.3 +toolchain go1.25.14 require ( chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 @@ -10,11 +10,12 @@ require ( chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.3 - google.golang.org/grpc v1.65.0 + google.golang.org/grpc v1.83.2 ) require ( - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/management/tcontrollerd/go.sum b/management/tcontrollerd/go.sum index fe02fe0e..d23388d5 100644 --- a/management/tcontrollerd/go.sum +++ b/management/tcontrollerd/go.sum @@ -1,3 +1,4 @@ +chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8 h1:qljuZkcItfSnd1OvteZKgxlqZzcr48DC9qm+OTnt/Xo= chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 h1:1Qa9ABk907/9ZrOLbbRcS8Fqq9VhjAF/mLjbSP1qAJY= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= @@ -21,8 +22,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= @@ -49,6 +50,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -66,9 +68,11 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -77,13 +81,17 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -96,6 +104,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -103,6 +113,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.39.0-dev h1:K4VkkiYp4LCvQiW6OiGglzm5nO4Zyryf7pHhzP15cmI= google.golang.org/grpc v1.39.0-dev/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -117,11 +129,15 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/management/tcontrollerd/pkg/config/config.go b/management/tcontrollerd/pkg/config/config.go index 3b93f747..3647e69b 100644 --- a/management/tcontrollerd/pkg/config/config.go +++ b/management/tcontrollerd/pkg/config/config.go @@ -2,6 +2,8 @@ package config import ( "os" + "path/filepath" + "strings" "chaitin.cn/dev/go/settings" ) @@ -10,6 +12,28 @@ var ( GlobalConfig = DefaultGlobalConfig() ) +// DefaultMgtTokenFile is where the control channel token is read from by +// default. The standard deployment mounts the same host directory at +// /app/sock inside the management container, which creates the file, and +// inside the tengine container, which is where tcontrollerd runs. +const DefaultMgtTokenFile = "/app/sock/mgt_grpc_token" + +// CertDirEnv names the environment variable that holds the directory of the +// credentials the management server published for this side of the control +// channel. The default is below the directory of the token file, which is the +// directory the standard deployment shares between the two containers. +const CertDirEnv = "TCD_MGT_CERT_DIR" + +// CertDir returns the directory the credentials of the control channel are +// read from. +func CertDir() string { + if v, ok := os.LookupEnv(CertDirEnv); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + + return filepath.Join(filepath.Dir(GlobalConfig.MgtTokenFile), "grpc") +} + func InitConfigs(configFilePath string) error { s, err := settings.New(configFilePath) if err != nil { @@ -28,5 +52,15 @@ func InitConfigs(configFilePath string) error { GlobalConfig.MgtWebserver = v } + // The token file is optional in the configuration file: the default is + // where the management server puts it. + if err := s.Unmarshal("mgt_token_file", &GlobalConfig.MgtTokenFile); err != nil { + return err + } + + if v, ok := os.LookupEnv("MGT_TOKEN_FILE"); ok { + GlobalConfig.MgtTokenFile = v + } + return nil } diff --git a/management/tcontrollerd/pkg/config/global.go b/management/tcontrollerd/pkg/config/global.go index e03c1cce..e0258ed2 100644 --- a/management/tcontrollerd/pkg/config/global.go +++ b/management/tcontrollerd/pkg/config/global.go @@ -3,11 +3,13 @@ package config type Config struct { Log LogConfig MgtWebserver string + MgtTokenFile string } func DefaultGlobalConfig() Config { return Config{ Log: DefaultLogConfig(), MgtWebserver: "169.254.0.4:9002", + MgtTokenFile: DefaultMgtTokenFile, } } diff --git a/management/tcontrollerd/pkg/cron/forbidden_page.go b/management/tcontrollerd/pkg/cron/forbidden_page.go index d5efe05b..2a8d5fe6 100644 --- a/management/tcontrollerd/pkg/cron/forbidden_page.go +++ b/management/tcontrollerd/pkg/cron/forbidden_page.go @@ -45,7 +45,11 @@ func checkAndUpdateForbiddenPage() { return } - err = ioutil.WriteFile(forbiddenPagePath, []byte(constants.DefaultForbiddenPage), 0644) + // utils.EnsureWriteFile replaces the file atomically instead of truncating + // it in place: the path lives in the nginx configuration tree, so a symlink + // planted there would otherwise make this root process overwrite its target + // (CWE-59). + err = utils.EnsureWriteFile(forbiddenPagePath, []byte(constants.DefaultForbiddenPage), 0644) if err != nil { logger.Error(err) return diff --git a/management/tcontrollerd/pkg/log/log.go b/management/tcontrollerd/pkg/log/log.go index 8b5639f7..ec2af6af 100644 --- a/management/tcontrollerd/pkg/log/log.go +++ b/management/tcontrollerd/pkg/log/log.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "strings" + "syscall" "github.com/sirupsen/logrus" @@ -43,7 +44,7 @@ func InitLogger() error { return err } - fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC + fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC | syscall.O_NOFOLLOW if !exist { if err := utils.EnsureFileDir(config.GlobalConfig.Log.Output); err != nil { return err @@ -51,11 +52,17 @@ func InitLogger() error { fileFlag = fileFlag | os.O_CREATE } - if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil { + // os.ModePerm (0777) let every local user rewrite the audit log. The + // file is created with an owner/group readable mode instead. + if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil { return fmt.Errorf("failed to open log file: %s", err.Error()) } else { log.SetOutput(log.AllLoggers, log.NewLockOutput(fp)) } + + // The mode above only applies when the file is created, so a log file + // that an older release left world writable is tightened here. + tightenLogFileMode(config.GlobalConfig.Log.Output) } // hook @@ -67,6 +74,28 @@ func InitLogger() error { return nil } +// logFileMode is the permission the log file is created with. Logs are audit +// material: they must not be writable by other local users. +const logFileMode os.FileMode = 0640 + +// looseLogFileMask matches the group and world write bits. +const looseLogFileMask os.FileMode = 0o022 + +// tightenLogFileMode removes the group and world write bits from an existing +// log file, so that a log file created by an older release cannot stay +// tamperable by other local users. +func tightenLogFileMode(path string) { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm()&looseLogFileMask == 0 { + return + } + + log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode) + if err := os.Chmod(path, logFileMode); err != nil { + log.Warnf("Cannot tighten the mode of log file %s: %s", path, err) + } +} + type RuntimeHook struct{} func (h *RuntimeHook) Levels() []logrus.Level { diff --git a/management/tcontrollerd/utils/file.go b/management/tcontrollerd/utils/file.go index e4b1121c..0aeec06d 100644 --- a/management/tcontrollerd/utils/file.go +++ b/management/tcontrollerd/utils/file.go @@ -1,9 +1,9 @@ package utils import ( + "bytes" "fmt" "io" - "io/ioutil" "os" "path/filepath" ) @@ -49,12 +49,62 @@ func FilesExist(paths ...string) (bool, error) { return true, nil } +// RenameWriteFile writes data to filename by writing a temporary file next to +// it and renaming that file into place. +// +// os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a file +// or symlink planted at the temporary path beforehand cannot be followed, and +// the rename replaces a symlink that was left at filename instead of writing +// through it. The previous implementation guessed the name with math/rand and +// wrote through ioutil.WriteFile, which follows symlinks. func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { - randFileName := filename + ".tmp." + RandStr(8) - if err := ioutil.WriteFile(randFileName, data, perm); err != nil { + return RenameCopyFromIO(bytes.NewReader(data), filename, perm) +} + +// RenameCopyFromIO streams src into a temporary file created next to dstPath +// and renames it over dstPath once the whole content has been written. +// +// This is the write primitive of the package: opening dstPath in place, as +// CopyFileFromIO used to do with O_CREATE|O_WRONLY|O_TRUNC and no O_NOFOLLOW, +// lets anyone who can create an entry in the destination directory redirect the +// write to another file and truncate it. tcontrollerd runs as root and writes +// the nginx configuration of every site into a path derived from the site id, +// so the destination names are predictable and the target of such a link would +// be overwritten with root privileges. +// +// The rename also makes the update atomic: a reader either sees the complete +// old file or the complete new one, never a half written mixture. +func RenameCopyFromIO(src io.Reader, dstPath string, perm os.FileMode) error { + tmpFile, err := os.CreateTemp(filepath.Dir(dstPath), filepath.Base(dstPath)+".tmp.") + if err != nil { + return err + } + tmpName := tmpFile.Name() + + renamed := false + defer func() { + if !renamed { + _ = os.Remove(tmpName) + } + }() + + if err = tmpFile.Chmod(perm); err != nil { + _ = tmpFile.Close() return err } - return os.Rename(randFileName, filename) + if _, err = io.Copy(tmpFile, src); err != nil { + _ = tmpFile.Close() + return err + } + if err = tmpFile.Close(); err != nil { + return err + } + if err = os.Rename(tmpName, dstPath); err != nil { + return err + } + + renamed = true + return nil } func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { @@ -66,13 +116,14 @@ func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { return RenameWriteFile(path, data, mode) } +// EnsureWriteFile writes a file with the given mode, creating its parent +// directory when needed. +// +// It goes through RenameCopyFromIO, so the write is atomic and cannot be +// redirected through a symlink: ioutil.WriteFile, which this used to call, +// truncates the target of a pre-existing link at the destination path. func EnsureWriteFile(path string, data []byte, mode os.FileMode) error { - err := EnsureFileDir(path) - if err != nil { - return err - } - - return ioutil.WriteFile(path, data, mode) + return EnsureRenameWriteFile(path, data, mode) } func CopyFile(srcPath, dstPath string) error { @@ -80,11 +131,8 @@ func CopyFile(srcPath, dstPath string) error { if err != nil { return err } - defer func(srcFile *os.File) { - err := srcFile.Close() - if err != nil { - - } + defer func(f *os.File) { + _ = f.Close() }(srcFile) fileInfo, err := srcFile.Stat() @@ -95,24 +143,18 @@ func CopyFile(srcPath, dstPath string) error { return CopyFileFromIO(srcFile, dstPath, fileInfo.Mode()) } +// CopyFileFromIO copies src into dstPath. +// +// The copy is written to a temporary file and renamed into place, so it is +// atomic and cannot be redirected through a symlink left at dstPath. Opening +// dstPath with O_CREATE|O_WRONLY|O_TRUNC and no O_NOFOLLOW, which this used to +// do, truncated whatever a planted link pointed at. func CopyFileFromIO(src io.Reader, dstPath string, perm os.FileMode) error { if err := EnsureFileDir(dstPath); err != nil { return err } - dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) - if err != nil { - return err - } - defer func(dstFile *os.File) { - err := dstFile.Close() - if err != nil { - - } - }(dstFile) - - _, err = io.Copy(dstFile, src) - return err + return RenameCopyFromIO(src, dstPath, perm) } func CopyFileIfNotExist(srcPath, dstPath string) error { diff --git a/management/tcontrollerd/utils/random.go b/management/tcontrollerd/utils/random.go index 44ebd98a..3b93401e 100644 --- a/management/tcontrollerd/utils/random.go +++ b/management/tcontrollerd/utils/random.go @@ -1,18 +1,36 @@ package utils import ( - "math/rand" - "time" + "crypto/rand" + "encoding/hex" + "math/big" ) var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +// RandStr returns n letters drawn from a cryptographically secure source. +// +// The value is used to name the temporary file created by RenameWriteFile, so +// it must not be predictable: math/rand is seeded from the clock and its +// output can be reconstructed from a single sample. func RandStr(n int) string { - rand.Seed(time.Now().UnixNano()) - b := make([]rune, n) + limit := big.NewInt(int64(len(letters))) for i := range b { - b[i] = letters[rand.Intn(len(letters))] + idx, err := rand.Int(rand.Reader, limit) + if err != nil { + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + b[i] = letters[idx.Int64()] } return string(b) } + +// RandHex returns a hex string of 2*n characters read from crypto/rand. +func RandHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + return hex.EncodeToString(b) +} diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go index c0b34c6c..eb333e75 100644 --- a/management/webserver/api/auth.go +++ b/management/webserver/api/auth.go @@ -1,8 +1,14 @@ package api import ( + "crypto/subtle" + "fmt" "math" "net/http" + "net/url" + "os" + "strconv" + "strings" "time" "github.com/gin-contrib/sessions" @@ -15,10 +21,70 @@ import ( "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/log" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/sessionopts" ) var logger = log.GetLogger("api") +// loginThrottle slows down passcode guessing. The management console has a +// single account whose only credential is a six digit passcode. +var loginThrottle = newLoginGuard() + +// BootstrapTokenEnv names the environment variable that gates the anonymous +// TFA bootstrap. See GetOTPUrl. +const BootstrapTokenEnv = "MGT_BOOTSTRAP_TOKEN" + +// BootstrapTokenHeader is the header that carries the value of +// BootstrapTokenEnv. +const BootstrapTokenHeader = "X-Bootstrap-Token" + +// BootstrapWindowEnv names the environment variable that holds how long the +// anonymous TFA bootstrap stays open after it was opened. It accepts a Go +// duration, and "0" or "off" leaves it open until the first login. +// +// The window is what keeps an installation that nobody ever bound from being +// claimable forever: the account can be bound without a credential while the +// window is open, and `mgt -reset_user admin` opens it again, which is what an +// operator does when they want to bind a console that was left alone. +const BootstrapWindowEnv = "MGT_BOOTSTRAP_WINDOW" + +// DefaultBootstrapWindow is how long the anonymous TFA bootstrap stays open +// when BootstrapWindowEnv is not set. It is long enough for the operator of an +// installation that is being set up to scan the code, and short enough that an +// installation which is left unbound does not stay open to the network. +const DefaultBootstrapWindow = 30 * time.Minute + +const ( + // bootstrapFailureThreshold is how many rejected bootstrap attempts a + // client address may make before it has to wait. + bootstrapFailureThreshold = 3 + + // bootstrapFailureWindow is how long a rejected attempt is remembered. + bootstrapFailureWindow = 10 * time.Minute + + // bootstrapMaxLockout caps the wait imposed on a caller that keeps trying. + bootstrapMaxLockout = 5 * time.Minute +) + +// bootstrapThrottle slows down guessing of the bootstrap token. +// +// GET /api/OTPUrl is reachable without a session, and the token comparison is +// the only thing between a caller and the TFA secret of the account, so a +// caller that may try values without limit could sit on the endpoint until it +// guesses one. The keys are per client address: a key shared by every caller +// would hand an attacker a way to lock the administrator out of the binding +// instead. +var bootstrapThrottle = newThrottle(bootstrapFailureThreshold, bootstrapFailureWindow, bootstrapMaxLockout) + +// bootstrapKeys returns the throttle keys of a bootstrap attempt. +func bootstrapKeys(clientIP string) []string { + if clientIP == "" { + return nil + } + + return []string{fmt.Sprintf("bootstrap:%s", clientIP)} +} + var OtpOpts = totp.GenerateOpts{ Issuer: constants.ProductName, AccountName: constants.SuperUser, @@ -46,8 +112,32 @@ func PostLogin(c *gin.Context) { var user model.User db.Where(&model.User{Username: constants.SuperUser}).First(&user) - valid := totp.Validate(params.Passcode, user.TFASecret) + keys := loginKeys(c.ClientIP(), user.Username) + if key, wait := loginThrottle.lockout(keys); wait > 0 { + logger.Warnf("Login throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many failed login attempts, please try again later"}, http.StatusTooManyRequests) + return + } + + // Until the first successful login the account is still bound to the address + // the TFA secret was handed to. Without this check a caller that reached + // GET /api/OTPUrl first would hold a secret the administrator is about to + // bind, and knowing the secret is enough to log in. + if user.LastLoginTime == 0 && user.TFASecret != "" && user.TFABootstrapIP != "" && + user.TFABootstrapIP != c.ClientIP() { + loginThrottle.fail(keys) + logger.Warnf("Refused a login from %s before the first successful one: the pending TFA secret was issued to %s", c.ClientIP(), user.TFABootstrapIP) + response.Error(c, response.JSONBody{Err: response.ErrWrongPasscode, Msg: "Failed to verify your passcode"}, http.StatusUnauthorized) + return + } + + now := time.Now() + usedStep, valid := verifyPasscode(user.TFASecret, params.Passcode, user.TFALastUsedStep, now) if !valid { + loginThrottle.fail(keys) + logger.Warnf("Failed login attempt from %s", c.ClientIP()) + millisecondTimestamp := params.Timestamp localTimeStamp := time.Now() if millisecondTimestamp > 0 { @@ -65,19 +155,28 @@ func PostLogin(c *gin.Context) { return } - user.LastLoginTime = time.Now().Unix() - user.IsEnabled = true - db.Save(&user) + // The passcode is spent here, in one conditional update, and the session + // below is only created when this request is the one that spent it. A plain + // Save after the check would leave a window in which two requests that + // carry the same passcode both read the same stored time step and both + // accept it. + consumed, err := consumePasscode(db, &user, usedStep, now.Unix()/int64(OtpOpts.Period), now) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError) + return + } + if !consumed { + loginThrottle.fail(keys) + logger.Warnf("Refused a login from %s: the passcode of time step %d was already used", c.ClientIP(), usedStep) + response.Error(c, response.JSONBody{Err: response.ErrWrongPasscode, Msg: "Failed to verify your passcode"}, http.StatusUnauthorized) + return + } + + loginThrottle.reset(keys) session := sessions.Default(c) - session.Options(sessions.Options{ - Path: "/", - MaxAge: 3600 * 24 * 7, - //Domain: options.Domain, - //HttpOnly: true, - //SameSite: http.SameSiteLaxMode, - //Secure: false, - }) + session.Options(sessionopts.Options(c)) session.Set(constants.DefaultSessionUserKey, user.ID) if err := session.Save(); err != nil { logger.Error(err) @@ -99,14 +198,75 @@ func PostLogout(c *gin.Context) { response.Success(c, nil) } -func GetOTPUrl(c *gin.Context) { - otpKey, err := totp.Generate(OtpOpts) - if err != nil { - logger.Error(err) - response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) - return +// checkBootstrapWindow returns an error when the window in which the account +// may be bound without a credential has closed. +// +// A passcode is the only credential of the account, so the secret that the +// caller of GET /api/OTPUrl receives is the account. Leaving the endpoint open +// until somebody happens to log in means an installation that is deployed and +// then forgotten can be claimed by whoever reaches the console port first; the +// window bounds that to the time around the setup, and every console that is +// bound afterwards reaches this endpoint only to be told to run +// `mgt -reset_user`. +func checkBootstrapWindow() error { + window := BootstrapWindow() + if window <= 0 { + return nil + } + + openedAt, ok := model.BootstrapOpenedAt() + if !ok { + // The record is written when the installation is set up, so a missing + // one means this database was not written by this release. The account + // is not left bound to nobody for that reason: the window starts being + // measured from the moment the account was created. + var user model.User + database.GetDB().Where(&model.User{Username: constants.SuperUser}).First(&user) + if openedAt, ok = user.CreatedAt, !user.CreatedAt.IsZero(); !ok { + openedAt = time.Now() + } + + logger.Warnf("No record of when the TFA bootstrap was opened, measuring the window of %s from %s", window, openedAt.Format(time.RFC3339)) + } + + if elapsed := time.Since(openedAt); elapsed > window { + return fmt.Errorf("the anonymous TFA bootstrap closed %s after it was opened at %s", window, openedAt.Format(time.RFC3339)) + } + + return nil +} + +// BootstrapWindow returns how long the anonymous TFA bootstrap stays open, and +// zero when it stays open until the first login. +func BootstrapWindow() time.Duration { + value := strings.TrimSpace(os.Getenv(BootstrapWindowEnv)) + if value == "" { + return DefaultBootstrapWindow + } + + if strings.EqualFold(value, "off") { + return 0 + } + + window, err := time.ParseDuration(value) + if err != nil || window < 0 { + logger.Warnf("Ignoring %s=%q: it is not a duration, keeping the window of %s", BootstrapWindowEnv, value, DefaultBootstrapWindow) + return DefaultBootstrapWindow } + return window +} + +// GetOTPUrl hands the TFA secret during first-boot binding. +// +// The request must carry X-Bootstrap-Token. Remaining gates: +// - LastLoginTime > 0: no more secret after the first successful login +// - secret is bound to the first ClientIP (TFABootstrapIP) +// - MGT_BOOTSTRAP_WINDOW, bootstrapThrottle, claimBootstrapSecret / adoptBootstrapSecret +// +// The stock console must send the header. Until that image is updated, bind +// with curl using the token written at startup. +func GetOTPUrl(c *gin.Context) { db := database.GetDB() // only SuperUser in v0.9 @@ -118,11 +278,263 @@ func GetOTPUrl(c *gin.Context) { return } - user.TFASecret = otpKey.Secret() - db.Save(&user) + clientIP := c.ClientIP() + throttleKeys := bootstrapKeys(clientIP) + if key, wait := bootstrapThrottle.lockout(throttleKeys); wait > 0 { + logger.Warnf("TFA bootstrap throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many attempts, please try again later"}, http.StatusTooManyRequests) + return + } + + if err := checkBootstrapToken(c); err != nil { + bootstrapThrottle.fail(throttleKeys) + logger.Warnf("Refused to hand out the TFA secret to %s: %s", clientIP, err) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusUnauthorized) + return + } + + bootstrapThrottle.reset(throttleKeys) + + // The console displays the QR code on the login page, before any session + // exists, so this endpoint is reachable while the account has never logged + // in. It must not hand out a fresh secret on every call, and the secret it + // does hand out belongs to the one address that asked for it: an anonymous + // caller that fetched a secret the administrator is about to bind would + // otherwise be able to log in with it, because the passcode is the only + // credential. + if user.TFASecret != "" { + if user.TFABootstrapIP == "" { + // An installation that already carries a pending secret from a + // release that did not record the address keeps working: the first + // caller adopts it, exactly as it did before. + // + // The window applies here as well, because adopting the pending + // secret is the same thing as being handed one. + if err := checkBootstrapWindow(); err != nil { + logger.Warnf("Refused to hand out the pending TFA secret to %s: %s", clientIP, err) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusForbidden) + return + } + + adopted, err := adoptBootstrapSecret(db, user.TFASecret, clientIP) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) + return + } + if !adopted { + refusePendingSecret(c, clientIP) + return + } + } else if user.TFABootstrapIP != clientIP { + refusePendingSecret(c, clientIP) + return + } + + logger.Debugf("Reusing the pending TFA secret for %s", clientIP) + response.Success(c, gin.H{"url": otpURL(user.TFASecret)}) + return + } + + if err := checkBootstrapWindow(); err != nil { + logger.Warnf("Refused to issue a TFA secret to %s: %s; run 'mgt -reset_user admin' to open the bootstrap again", clientIP, err) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The TFA bootstrap of this installation is closed, run 'mgt -reset_user admin' to bind a new authenticator"}, http.StatusForbidden) + return + } + + otpKey, err := totp.Generate(OtpOpts) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) + return + } + + // The secret is installed by an update that only matches while the row + // still has none, so two calls that arrive together cannot both be told + // that the secret they generated is the one the account carries: the + // caller that loses reads the stored secret below and hands that one out + // when it belongs to the same address, which is what a console that + // retries produces, and is refused otherwise. + issued, err := claimBootstrapSecret(db, clientIP, otpKey.Secret()) + if err != nil { + logger.Error(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError) + return + } + if !issued { + logger.Warnf("Lost the TFA bootstrap race to another caller, %s is served the stored secret if it is its own", clientIP) + var stored model.User + db.Where(&model.User{Username: constants.SuperUser}).First(&stored) + if stored.TFASecret == "" || stored.TFABootstrapIP != clientIP { + refusePendingSecret(c, clientIP) + return + } + + response.Success(c, gin.H{"url": otpURL(stored.TFASecret)}) + return + } + + logger.Warnf("Issued a new TFA secret to %s; it can only be bound and used from that address until the first login", clientIP) response.Success(c, gin.H{"url": otpKey.URL()}) } +// refusePendingSecret answers a caller that may not be handed the pending TFA +// secret, because it was issued to another address. +func refusePendingSecret(c *gin.Context, clientIP string) { + logger.Warnf("Refused to hand out the pending TFA secret to %s: it belongs to another address", clientIP) + response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The pending TFA secret belongs to another address, run 'mgt -reset_user ' to bind a new one"}, http.StatusForbidden) +} + +// claimBootstrapSecret installs secret as the pending TFA secret of the +// account and reports whether this call is the one that installed it. +// +// The update matches only while the row carries no secret yet, which is what +// makes the claim exclusive. Reading the row and writing it back would let two +// callers that arrive while the account has no secret both store the one they +// generated, so both would be told the account is bound to the secret they +// hold, while the database keeps only the last write. +func claimBootstrapSecret(db *database.PostgresDB, clientIP, secret string) (bool, error) { + result := db.Model(&model.User{}). + Where("username = ? AND (tfa_secret = '' OR tfa_secret IS NULL)", constants.SuperUser). + Updates(map[string]interface{}{ + "tfa_secret": secret, + "tfa_bootstrap_ip": clientIP, + }) + if result.Error != nil { + return false, result.Error + } + + return result.RowsAffected == 1, nil +} + +// adoptBootstrapSecret records clientIP as the address of a pending secret +// that was stored before the address was recorded. +// +// The update is restricted to the secret that was read, so a caller that lost a +// race against the installation of a new secret adopts nothing. +func adoptBootstrapSecret(db *database.PostgresDB, pending, clientIP string) (bool, error) { + result := db.Model(&model.User{}). + Where("username = ? AND tfa_secret = ? AND (tfa_bootstrap_ip = '' OR tfa_bootstrap_ip IS NULL)", constants.SuperUser, pending). + Updates(map[string]interface{}{ + "tfa_bootstrap_ip": clientIP, + }) + if result.Error != nil { + return false, result.Error + } + + return result.RowsAffected == 1, nil +} + +// consumePasscode records an accepted passcode together with the login it +// belongs to, as a single conditional update. +// +// The update is what decides whether the time step of the passcode was already +// spent: it only matches while the stored step is below the accepted one, so +// the second of two concurrent requests that carry the same passcode affects no +// row and is treated as a replay. currentStep is the step the passcode was +// verified against; a stored step ahead of it means the clock moved backwards, +// which this process treats as stale (see verifyPasscode) and repairs here. +func consumePasscode(db *database.PostgresDB, user *model.User, usedStep, currentStep int64, now time.Time) (bool, error) { + result := db.Model(&model.User{}). + Where("id = ? AND (tfa_last_used_step < ? OR tfa_last_used_step > ?)", user.ID, usedStep, currentStep+1). + Updates(map[string]interface{}{ + "tfa_last_used_step": usedStep, + // The bootstrap is over: the secret is bound, so it is no longer + // pending and no longer tied to the address that fetched it. + "tfa_bootstrap_ip": "", + "last_login_time": now.Unix(), + "is_enabled": true, + }) + if result.Error != nil { + return false, result.Error + } + + return result.RowsAffected == 1, nil +} + +// verifyPasscode checks a TOTP passcode and returns the time step it belongs to. +// +// lastUsedStep is the step of the passcode that was accepted last; every step +// up to it is refused, which is what makes a captured passcode unusable: the +// plain totp.Validate call the handler used before accepted the same code for +// as long as it stayed inside the window of the current step. +func verifyPasscode(secret, passcode string, lastUsedStep int64, now time.Time) (int64, bool) { + if secret == "" || passcode == "" { + return 0, false + } + + period := int64(OtpOpts.Period) + currentStep := now.Unix() / period + if lastUsedStep > currentStep+1 { + // The recorded step is ahead of the current window, which means the + // clock moved backwards. Keeping it would lock the account out until + // the clock catches up, so the record is treated as stale. + lastUsedStep = 0 + } + + opts := totp.ValidateOpts{ + Period: OtpOpts.Period, + Digits: OtpOpts.Digits, + Algorithm: OtpOpts.Algorithm, + } + + // The current step and its two neighbours: the same window totp.Validate + // accepts by default, so a client clock that is off by one period still + // works. + var matched int64 + for _, step := range []int64{currentStep - 1, currentStep, currentStep + 1} { + if step <= lastUsedStep { + continue + } + + code, err := totp.GenerateCodeCustom(secret, time.Unix(step*period, 0), opts) + if err != nil { + continue + } + + if subtle.ConstantTimeCompare([]byte(code), []byte(passcode)) == 1 { + matched = step + } + } + + return matched, matched != 0 +} + +// otpURL rebuilds the otpauth URL that totp.Generate returns for a secret. +func otpURL(secret string) string { + values := url.Values{} + values.Set("secret", secret) + values.Set("issuer", OtpOpts.Issuer) + values.Set("period", strconv.FormatUint(uint64(OtpOpts.Period), 10)) + values.Set("algorithm", OtpOpts.Algorithm.String()) + values.Set("digits", OtpOpts.Digits.String()) + + bootstrapURL := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + OtpOpts.Issuer + ":" + OtpOpts.AccountName, + RawQuery: values.Encode(), + } + + return bootstrapURL.String() +} + +// checkBootstrapToken requires X-Bootstrap-Token to match the configured +// bootstrap token. An empty expected value is a server misconfiguration. +func checkBootstrapToken(c *gin.Context) error { + expected := bootstrapToken + if expected == "" { + return fmt.Errorf("bootstrap token is not configured") + } + + if subtle.ConstantTimeCompare([]byte(c.GetHeader(BootstrapTokenHeader)), []byte(expected)) == 1 { + return nil + } + + return fmt.Errorf("missing or wrong %s header", BootstrapTokenHeader) +} + func GetUser(c *gin.Context) { db := database.GetDB() user := model.User{Username: constants.SuperUser} diff --git a/management/webserver/api/behaviour.go b/management/webserver/api/behaviour.go index 0d63fddb..4fa10843 100644 --- a/management/webserver/api/behaviour.go +++ b/management/webserver/api/behaviour.go @@ -14,6 +14,10 @@ type PostBehaviourRequest struct { model.Behaviour } +// maxRouterLength bounds the console routes that are stored. The values come +// from the browser, so they are limited to what the console can produce. +const maxRouterLength = 256 + func PostBehaviour(c *gin.Context) { var params PostBehaviourRequest if err := c.BindJSON(¶ms); err != nil { @@ -21,7 +25,19 @@ func PostBehaviour(c *gin.Context) { response.Error(c, response.ErrorParamNotOK, http.StatusInternalServerError) return } + + if len(params.SrcRouter) > maxRouterLength || len(params.DstRouter) > maxRouterLength { + logger.Warnf("Rejected behaviour entry with %d/%d bytes of route", len(params.SrcRouter), len(params.DstRouter)) + response.Error(c, response.ErrorParamNotOK, http.StatusBadRequest) + return + } + db := database.GetDB() - db.Create(&model.Behaviour{SrcRouter: params.SrcRouter, DstRouter: params.DstRouter}) + if err := db.Create(&model.Behaviour{SrcRouter: params.SrcRouter, DstRouter: params.DstRouter}).Error; err != nil { + logger.Error(err) + response.Error(c, response.ErrorInternal, http.StatusInternalServerError) + return + } + response.Success(c, nil) } diff --git a/management/webserver/api/bootstrap.go b/management/webserver/api/bootstrap.go new file mode 100644 index 00000000..61214fc2 --- /dev/null +++ b/management/webserver/api/bootstrap.go @@ -0,0 +1,133 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +// BootstrapTokenFileEnv names the file the bootstrap token is read from when +// BootstrapTokenEnv is empty. +const BootstrapTokenFileEnv = "MGT_BOOTSTRAP_TOKEN_FILE" + +// DefaultBootstrapTokenFile is next to the control-channel token on the +// shared sock volume. +const DefaultBootstrapTokenFile = "/app/sock/tfa_bootstrap_token" + +const bootstrapTokenBytes = 32 +const bootstrapTokenFileMode os.FileMode = 0600 + +// bootstrapToken is the value GET /api/OTPUrl must present. It is never empty +// after a successful EnsureBootstrapToken. +var bootstrapToken string + +// BootstrapTokenFile returns the path of the persisted bootstrap token. +func BootstrapTokenFile() string { + if path := strings.TrimSpace(os.Getenv(BootstrapTokenFileEnv)); path != "" { + return path + } + return DefaultBootstrapTokenFile +} + +// EnsureBootstrapToken loads MGT_BOOTSTRAP_TOKEN or a persisted file, creating +// the file once when neither is set. The console must send the value as +// X-Bootstrap-Token; an empty token is never accepted. +func EnsureBootstrapToken() (created bool, path string, err error) { + if token := strings.TrimSpace(os.Getenv(BootstrapTokenEnv)); token != "" { + bootstrapToken = token + return false, "", nil + } + + path = BootstrapTokenFile() + token, created, err := loadOrCreateBootstrapToken(path) + if err != nil { + return false, path, err + } + if token == "" { + return false, path, fmt.Errorf("bootstrap token is empty") + } + bootstrapToken = token + return created, path, nil +} + +func loadOrCreateBootstrapToken(path string) (string, bool, error) { + if token, err := readBootstrapToken(path); err == nil && token != "" { + return token, false, nil + } + + raw := make([]byte, bootstrapTokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", false, err + } + value := hex.EncodeToString(raw) + + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return "", false, err + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, bootstrapTokenFileMode) + if err != nil { + if !os.IsExist(err) { + return "", false, err + } + token, readErr := readBootstrapToken(path) + if readErr != nil { + return "", false, readErr + } + if token != "" { + return token, false, nil + } + // Empty leftover file: replace it without following a symlink. Do not + // truncate a non-empty secret. + if err := writeBootstrapTokenNoFollow(path, value); err != nil { + return "", false, err + } + return value, true, nil + } + if _, err = file.WriteString(value + "\n"); err != nil { + _ = file.Close() + return "", false, err + } + if err = file.Close(); err != nil { + return "", false, err + } + return value, true, nil +} + +// readBootstrapToken reads the token without following a symlink. A link placed +// on the shared volume must not be treated as the token file. +func readBootstrapToken(path string) (string, error) { + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + // Read the whole file. A prefix must not be treated as the token: the console + // sends the file contents, and a truncated value would never match. + content, err := io.ReadAll(file) + if err != nil { + return "", err + } + return strings.TrimSpace(string(content)), nil +} + +// writeBootstrapTokenNoFollow overwrites an existing regular file. O_NOFOLLOW +// refuses a symlink instead of writing through it. +func writeBootstrapTokenNoFollow(path, value string) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, bootstrapTokenFileMode) + if err != nil { + return err + } + if _, err = file.WriteString(value + "\n"); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/management/webserver/api/bootstrap_test.go b/management/webserver/api/bootstrap_test.go new file mode 100644 index 00000000..35a34241 --- /dev/null +++ b/management/webserver/api/bootstrap_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureBootstrapTokenCreatesAndReusesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tfa_bootstrap_token") + t.Setenv(BootstrapTokenEnv, "") + t.Setenv(BootstrapTokenFileEnv, path) + + created, gotPath, err := EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if !created || gotPath != path || bootstrapToken == "" { + t.Fatalf("created=%v path=%q token empty=%v", created, gotPath, bootstrapToken == "") + } + first := bootstrapToken + + created, _, err = EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if created { + t.Fatal("second call rotated the token") + } + if bootstrapToken != first { + t.Fatal("token changed") + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) == "" { + t.Fatal("file empty") + } +} + +func TestEnsureBootstrapTokenPrefersEnv(t *testing.T) { + t.Setenv(BootstrapTokenEnv, "from-env") + t.Setenv(BootstrapTokenFileEnv, filepath.Join(t.TempDir(), "unused")) + created, path, err := EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if created || path != "" || bootstrapToken != "from-env" { + t.Fatalf("created=%v path=%q token=%q", created, path, bootstrapToken) + } +} + +func TestEnsureBootstrapTokenReplacesEmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "tfa_bootstrap_token") + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(BootstrapTokenEnv, "") + t.Setenv(BootstrapTokenFileEnv, path) + created, _, err := EnsureBootstrapToken() + if err != nil { + t.Fatal(err) + } + if !created || bootstrapToken == "" { + t.Fatalf("created=%v token empty", created) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(content)) != bootstrapToken { + t.Fatalf("file=%q token=%q", content, bootstrapToken) + } +} diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go index 890104a8..5948b545 100644 --- a/management/webserver/api/cert.go +++ b/management/webserver/api/cert.go @@ -3,12 +3,18 @@ package api import ( "crypto/x509/pkix" "fmt" + "io" + "mime/multipart" "net/http" + "os" "path/filepath" + "strconv" + "time" "github.com/gin-gonic/gin" "chaitin.cn/patronus/safeline-2/management/webserver/api/response" + "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" "chaitin.cn/patronus/safeline-2/management/webserver/utils" ) @@ -17,6 +23,49 @@ type postSSLCertRequest struct { Hostname string `json:"hostname"` } +const ( + // certGenerationThreshold is how many key pairs a client address may ask + // for within certGenerationWindow before it has to wait. + certGenerationThreshold = 5 + + // certGenerationWindow is how long a generation is counted. + certGenerationWindow = 10 * time.Minute + + // certGenerationMaxLockout caps the wait imposed on a caller that keeps + // asking. + certGenerationMaxLockout = 5 * time.Minute + + // maxUploadedCertBytes is the largest certificate or key this endpoint will + // write. A PEM certificate or key is far smaller than this. + maxUploadedCertBytes int64 = 1 << 20 +) + +// certGenerationThrottle bounds how often this endpoint may spend a key +// generation. +// +// Every accepted request generates a fresh 4096 bit RSA key in the request +// goroutine; the random file name prefix makes the existence check in +// WriteCertIfNotExist a no-op, so nothing is ever reused. An authenticated +// session that loops over this route therefore keeps the process busy with RSA +// key generation and fills the certificate directory of the site, which is the +// same process that compiles and pushes the detection rules. The throttle is +// counted per client address, and one generation is counted whether it +// succeeds or not. +var certGenerationThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout) + +// certUploadThrottle bounds how often an authenticated session may write into +// the certificate directory. Generation already has a throttle; upload did not. +var certUploadThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout) + +// certGenerationKeys returns the throttle keys of a certificate request. +func certGenerationKeys(clientIP string) []string { + if clientIP == "" { + return nil + } + + return []string{fmt.Sprintf("sslcert:%s", clientIP)} +} + // SSLCertDir is the dir of tengine conf, not mgt-api nginx certs dir defined by constants.CertsPath const ( CRT = ".crt" @@ -33,23 +82,46 @@ func PostUploadSSLCert(c *gin.Context) { response.Error(c, response.ErrorParamNotOK, http.StatusInternalServerError) return } + + // The console uploads both the certificate and the private key through + // this endpoint. The extension decides which of the two it is, and the key + // must not be created with the default (0666 before umask) mode. + mode := os.FileMode(0644) switch filepath.Ext(file.Filename) { - case CRT, PEM, KEY: + case CRT: + logger.Debugf("File: %v is valid", file.Filename) + case PEM, KEY: logger.Debugf("File: %v is valid", file.Filename) + mode = os.FileMode(0600) default: logger.Errorf("Filename: %s, ext: %s", file.Filename, filepath.Ext(file.Filename)) response.Error(c, response.JSONBody{Err: response.ErrWrongFileType, Msg: "Wrong file type, please upload a file of .crt or .key"}, http.StatusUnsupportedMediaType) return } + if file.Size <= 0 || file.Size > maxUploadedCertBytes { + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Certificate file is empty or too large"}, http.StatusBadRequest) + return + } + + clientIP := c.ClientIP() + throttleKeys := certGenerationKeys(clientIP) + if key, wait := certUploadThrottle.lockout(throttleKeys); wait > 0 { + logger.Warnf("Certificate upload throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many certificate uploads, please try again later"}, http.StatusTooManyRequests) + return + } + certUploadThrottle.fail(throttleKeys) + var dstPath string - filename := fmt.Sprintf("%s_%s", utils.RandStr(16), file.Filename) + filename := fmt.Sprintf("%s_%s", utils.RandStr(16), filepath.Base(file.Filename)) if config.GlobalConfig.Server.DevMode { dstPath = filepath.Join("./nginx", SSLCertDir, filename) } else { dstPath = filepath.Join(config.GlobalConfig.NgxResDir, SSLCertDir, filename) } - if err = c.SaveUploadedFile(file, dstPath); err != nil { + if err = saveUploadedFile(file, dstPath, mode); err != nil { logger.Error(err) response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when saving file"}, http.StatusInternalServerError) return @@ -58,6 +130,38 @@ func PostUploadSSLCert(c *gin.Context) { response.Success(c, gin.H{"filename": filename}) } +// saveUploadedFile stores an uploaded file with an explicit permission mode. +// +// gin.Context.SaveUploadedFile calls os.Create, which asks for 0666 (0644 +// after the usual umask) for every file it is given, including private keys. +// O_CREATE|O_EXCL also makes sure an existing file - or a symlink pointing +// outside the certificate directory - is never followed. +func saveUploadedFile(file *multipart.FileHeader, dstPath string, mode os.FileMode) error { + if err := utils.EnsureFileDir(dstPath); err != nil { + return err + } + + src, err := file.Open() + if err != nil { + return err + } + defer func() { + _ = src.Close() + }() + + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + + if _, err = io.Copy(dst, io.LimitReader(src, maxUploadedCertBytes)); err != nil { + _ = dst.Close() + return err + } + + return dst.Close() +} + func PostSSLCert(c *gin.Context) { var params postSSLCertRequest if err := c.BindJSON(¶ms); err != nil { @@ -66,6 +170,35 @@ func PostSSLCert(c *gin.Context) { return } + // The host name is written into the certificate as a DNS name, and a value + // that is not a host name produces a certificate that stands for nothing in + // particular. It is checked with the same rule the front end uses for a + // server name before it reaches the certificate. + if params.Hostname == "" { + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "The host name of the certificate is required"}, http.StatusBadRequest) + return + } + + if err := model.ValidateServerName(params.Hostname); err != nil { + logger.Warn(err) + response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + + clientIP := c.ClientIP() + throttleKeys := certGenerationKeys(clientIP) + if key, wait := certGenerationThrottle.lockout(throttleKeys); wait > 0 { + logger.Warnf("Certificate generation throttled by %s, retry in %s", key, wait) + c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10)) + response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many certificate requests, please try again later"}, http.StatusTooManyRequests) + return + } + + // The generation below is the expensive part of this handler, so the + // attempt is recorded up front: a caller that only ever produces failing + // requests must not get an unlimited number of key generations either. + certGenerationThrottle.fail(throttleKeys) + filePrefix := utils.RandStr(16) certFilename := fmt.Sprintf("%s_backend.crt", filePrefix) keyFilename := fmt.Sprintf("%s_backend.key", filePrefix) diff --git a/management/webserver/api/common.go b/management/webserver/api/common.go index 426b8263..a9847e05 100644 --- a/management/webserver/api/common.go +++ b/management/webserver/api/common.go @@ -3,8 +3,10 @@ package api import ( "encoding/json" "fmt" + "io" "io/ioutil" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -18,13 +20,43 @@ import ( const VersionInfoEntrypoint = "/release/latest/version.json" +// maxVersionInfoBytes bounds the upgrade information this server reads from +// the platform. The body is parsed and logged on error, so a large or endless +// answer from a misbehaving (or hostile) endpoint must not be buffered whole. +const maxVersionInfoBytes = 1 << 20 + +// maxLoggedBodyBytes bounds how much of an answer from another host may be put +// into the log. +const maxLoggedBodyBytes = 256 + +// loggableBody renders a bounded, quoted, single line version of a response +// body that came from another host. +// +// The body is remote input, and the log is read by people and by tools that +// follow it: a verbatim body lets the other end forge log lines (or worse, if +// the log is read in a terminal) with a carriage return or an escape sequence. +// strconv.Quote escapes the newlines and the control characters, and the +// truncation keeps a misbehaving endpoint from filling the disk through the +// log file. +func loggableBody(body []byte) string { + if len(body) > maxLoggedBodyBytes { + return fmt.Sprintf("%s... (%d bytes in total)", strconv.Quote(string(body[:maxLoggedBodyBytes])), len(body)) + } + + return strconv.Quote(string(body)) +} + type idsRequest struct { IDs []uint `json:"ids" form:"ids"` } type pageRequest struct { + // PageSize is capped at 100: the value is handed to SQL LIMIT, so an + // unbounded one lets a single request ask the server to load an arbitrary + // number of rows. 100 is the largest page the console offers and the same + // range the MCP tools accept. Page int `json:"page" form:"page,default=1" binding:"min=1"` - PageSize int `json:"page_size" form:"page_size,default=10" binding:"min=1"` + PageSize int `json:"page_size" form:"page_size,default=10" binding:"min=1,max=100"` } type versionInfoResponse struct { @@ -52,7 +84,12 @@ func GetUpgradeTips(ctx *gin.Context) { response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) return } - body, err := ioutil.ReadAll(versionInfoRsp.Body) + // The response body has to be released on every path, otherwise the + // connection (and its file descriptor) stays around until the transport + // times it out. + defer versionInfoRsp.Body.Close() + + body, err := ioutil.ReadAll(io.LimitReader(versionInfoRsp.Body, maxVersionInfoBytes)) if err != nil { logger.Warn(err) response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) @@ -62,7 +99,7 @@ func GetUpgradeTips(ctx *gin.Context) { versionInfo := &versionInfoResponse{} err = json.Unmarshal(body, versionInfo) if err != nil { - logger.Warnf("err: %v, body: %s", err, body) + logger.Warnf("Failed to parse %s: %v, body: %s", config.GlobalConfig.PlatformAddr+VersionInfoEntrypoint, err, loggableBody(body)) response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) return } @@ -72,7 +109,7 @@ func GetUpgradeTips(ctx *gin.Context) { recVersionCmp := semver.Compare(currentVersion, versionInfo.RecVersion) if semver.Compare(versionInfo.LatestVersion, versionInfo.RecVersion) == -1 || latestVersionCmp == 1 { logger.Warnf("The version number is invalid, current version: %s, latest version: %s, rec version: %s", - currentVersion, versionInfo.LatestVersion, versionInfo.RecVersion) + currentVersion, strconv.Quote(versionInfo.LatestVersion), strconv.Quote(versionInfo.RecVersion)) response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade}) return } diff --git a/management/webserver/api/detectlog.go b/management/webserver/api/detectlog.go index 220e95d9..333eae43 100644 --- a/management/webserver/api/detectlog.go +++ b/management/webserver/api/detectlog.go @@ -8,8 +8,6 @@ import ( "strconv" "strings" - "chaitin.cn/dev/go/errors" - "chaitin.cn/patronus/safeline-2/management/webserver/pkg" "github.com/gin-gonic/gin" @@ -51,7 +49,7 @@ func getDetectLog(eventId string) (*model.DetectLog, error) { var detectLogBasic model.DetectLogBasic res := db.Where(&model.DetectLogBasic{EventId: eventId}).First(&detectLogBasic) if res.RowsAffected == 0 { - return nil, errors.New("Data queried does not exist") + return nil, errDataNotExist } var detectLogDetail model.DetectLogDetail @@ -172,6 +170,9 @@ func PostFalsePositives(c *gin.Context) { response.Success(c, nil) return } + // The caller owns the response returned by DoPostTelemetry and has to + // release it, otherwise every report leaks a connection. + defer rsp.Body.Close() if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusCreated { log.Warn(fmt.Sprintf("Transfer telemetry failed, status code = %d", rsp.StatusCode), err) diff --git a/management/webserver/api/errors.go b/management/webserver/api/errors.go new file mode 100644 index 00000000..059cc812 --- /dev/null +++ b/management/webserver/api/errors.go @@ -0,0 +1,31 @@ +package api + +import ( + "chaitin.cn/dev/go/errors" + + "chaitin.cn/patronus/safeline-2/management/webserver/api/response" +) + +// Errors whose message is meant for the administrator. Everything else that +// comes back from a database or a runtime call is answered with +// response.ErrorInternal: its text describes the inside of the deployment and +// belongs in the server log, where the operator can read it. +var ( + errDataNotExist = errors.New("Data queried does not exist") + errRulesCompile = errors.New("Rules compile error, please check your params.") + errPolicyRuleEmpty = errors.New("Policy rule must contain at least one pattern") +) + +// responseMessage returns the text that may be sent to the caller. +func responseMessage(err error) string { + switch err { + case errDataNotExist: + return errDataNotExist.Error() + case errRulesCompile: + return errRulesCompile.Error() + case errPolicyRuleEmpty: + return errPolicyRuleEmpty.Error() + default: + return response.ErrorInternal.Msg + } +} diff --git a/management/webserver/api/login_guard.go b/management/webserver/api/login_guard.go new file mode 100644 index 00000000..29b13681 --- /dev/null +++ b/management/webserver/api/login_guard.go @@ -0,0 +1,193 @@ +package api + +import ( + "fmt" + "strings" + "sync" + "time" +) + +const ( + // loginFailureThreshold is the number of failed attempts that are tolerated + // before the caller has to wait. + loginFailureThreshold = 5 + + // loginFailureWindow is how long a failed attempt is remembered. + loginFailureWindow = 10 * time.Minute + + // loginMaxLockout caps the wait that is imposed on a caller that keeps + // failing. A six digit passcode stays out of reach of a brute force attack + // with this delay, even over a long period. + loginMaxLockout = 5 * time.Minute + + // loginMaxEntries bounds the memory the throttle may use. The keys carry a + // client address, and a client can choose as many of those as it likes. + loginMaxEntries = 1024 +) + +// loginAttempt is the state of one throttling key. +type loginAttempt struct { + failures int + lastFailure time.Time +} + +// loginGuard throttles an endpoint that an unauthenticated or partially +// authenticated caller can drive per account and per client address. +// +// The management console has a single account and the passcode is its only +// credential, so a caller that is allowed to try passcodes without limit can +// simply enumerate them. The same guard covers the TFA bootstrap, where the +// only thing between a caller and the secret of the account is a token +// comparison. +type loginGuard struct { + mu sync.Mutex + attempts map[string]*loginAttempt + + // failureThreshold is how many failures are tolerated before the caller has + // to wait, failureWindow how long a failure is remembered and maxLockout + // caps the wait that is imposed. + failureThreshold int + failureWindow time.Duration + maxLockout time.Duration + + // now is a seam for the tests. + now func() time.Time +} + +// newLoginGuard returns the throttle of the login endpoint. +func newLoginGuard() *loginGuard { + return newThrottle(loginFailureThreshold, loginFailureWindow, loginMaxLockout) +} + +// newThrottle returns a throttle with its own thresholds, which lets an +// endpoint that is cheaper to reach than the login pick stricter numbers. +func newThrottle(threshold int, window, maxLockout time.Duration) *loginGuard { + return &loginGuard{ + attempts: make(map[string]*loginAttempt), + failureThreshold: threshold, + failureWindow: window, + maxLockout: maxLockout, + now: time.Now, + } +} + +// accountKeyPrefix marks the throttle key that every caller of an account +// shares, as opposed to the keys that belong to a single client address. +const accountKeyPrefix = "account:" + +// isAccountKey reports whether a throttle key protects an account rather than a +// single client address. +func isAccountKey(key string) bool { + return strings.HasPrefix(key, accountKeyPrefix) +} + +// loginKeys returns the throttle keys of a login attempt. +func loginKeys(clientIP, username string) []string { + keys := []string{accountKeyPrefix + username} + if clientIP != "" { + keys = append(keys, fmt.Sprintf("ip:%s", clientIP)) + } + + return keys +} + +// lockout reports how long the caller has to wait, and the key that caused it. +func (g *loginGuard) lockout(keys []string) (string, time.Duration) { + g.mu.Lock() + defer g.mu.Unlock() + + now := g.now() + g.prune(now) + + var ( + blockedKey string + wait time.Duration + ) + + for _, key := range keys { + attempt, ok := g.attempts[key] + if !ok || attempt.failures < g.failureThreshold { + continue + } + + if remaining := g.backoff(attempt.failures) - now.Sub(attempt.lastFailure); remaining > wait { + blockedKey, wait = key, remaining + } + } + + return blockedKey, wait +} + +// fail records an authentication failure. +func (g *loginGuard) fail(keys []string) { + g.mu.Lock() + defer g.mu.Unlock() + + now := g.now() + g.prune(now) + + for _, key := range keys { + attempt, ok := g.attempts[key] + if !ok || now.Sub(attempt.lastFailure) > g.failureWindow { + attempt = &loginAttempt{} + g.attempts[key] = attempt + } + + attempt.failures++ + attempt.lastFailure = now + } +} + +// reset forgets the failures of a key, which is what a successful login does. +func (g *loginGuard) reset(keys []string) { + g.mu.Lock() + defer g.mu.Unlock() + + for _, key := range keys { + delete(g.attempts, key) + } +} + +// prune drops records that are no longer useful and keeps the map bounded. +func (g *loginGuard) prune(now time.Time) { + for key, attempt := range g.attempts { + if now.Sub(attempt.lastFailure) > g.failureWindow+g.maxLockout { + delete(g.attempts, key) + } + } + + // The map has to stay bounded, because a client can invent client addresses + // faster than the failures expire. Dropping an address key only weakens the + // throttle of a caller that stopped trying, but an account key is never + // dropped: it is the one that keeps the passcode out of reach, and being + // able to reset it by filling the map with invented addresses would make + // the whole throttle useless. + for key := range g.attempts { + if len(g.attempts) <= loginMaxEntries { + break + } + if isAccountKey(key) { + continue + } + delete(g.attempts, key) + } +} + +// backoff returns how long a caller that failed n times in a row has to wait +// after its last failure. The delay doubles per failure and is capped. +func (g *loginGuard) backoff(failures int) time.Duration { + shift := failures - g.failureThreshold + if shift < 0 { + shift = 0 + } + if shift > 8 { + shift = 8 + } + + backoff := time.Second << uint(shift) + if backoff > g.maxLockout { + backoff = g.maxLockout + } + + return backoff +} diff --git a/management/webserver/api/policygroup.go b/management/webserver/api/policygroup.go index 8a4eb638..753e0d5b 100644 --- a/management/webserver/api/policygroup.go +++ b/management/webserver/api/policygroup.go @@ -7,8 +7,6 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "chaitin.cn/dev/go/errors" - "chaitin.cn/patronus/safeline-2/management/webserver/api/response" "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" @@ -24,6 +22,15 @@ func PutPolicyGroupGlobal(ctx *gin.Context) { return } + // Reject unknown modules and modes before anything is persisted: the map + // keys are rendered into the skynet configuration, so a bad key has to be + // reported to the caller instead of reaching the engine or the renderer. + if err := model.ValidatePolicyGroup(params); err != nil { + logger.Error(err) + response.Error(ctx, response.JSONBody{Err: response.ErrorParamNotOK.Err, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { var pggOption model.Options @@ -32,13 +39,13 @@ func PutPolicyGroupGlobal(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } pggStr, err := json.Marshal(params) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return err } @@ -46,14 +53,14 @@ func PutPolicyGroupGlobal(ctx *gin.Context) { tx.Save(&pggOption) if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -68,7 +75,7 @@ func GetPolicyGroupGlobal(ctx *gin.Context) { err := json.Unmarshal([]byte(pggOption.Value), &pgg) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -91,13 +98,13 @@ func PutSrcIPConfig(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } scStr, err := json.Marshal(params) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return err } @@ -105,14 +112,14 @@ func PutSrcIPConfig(ctx *gin.Context) { tx.Save(&scOption) if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } diff --git a/management/webserver/api/policyrule.go b/management/webserver/api/policyrule.go index 784b4274..8b1b0e32 100644 --- a/management/webserver/api/policyrule.go +++ b/management/webserver/api/policyrule.go @@ -1,15 +1,15 @@ package api import ( + "encoding/json" "net/http" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/fvm" "github.com/gin-gonic/gin" + "gorm.io/datatypes" "gorm.io/gorm" - "chaitin.cn/dev/go/errors" - "chaitin.cn/patronus/safeline-2/management/webserver/api/response" "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" @@ -28,6 +28,11 @@ func PostPolicyRule(ctx *gin.Context) { return } + if err := validatePolicyRulePatterns(params.Pattern); err != nil { + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { policyRule := &model.PolicyRule{Action: params.Action, Comment: params.Comment, IsEnabled: params.IsEnabled, Pattern: params.Pattern} @@ -37,14 +42,14 @@ func PostPolicyRule(ctx *gin.Context) { } if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -61,23 +66,27 @@ func PutSwitchPolicyRule(ctx *gin.Context) { db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { - res := tx.Model(&model.PolicyRule{}).Where(params.IDs).Updates(model.PolicyRule{IsEnabled: params.IsEnabled}) + // IsEnabled=false is the Go zero value. GORM skips zero-value fields in + // Updates(struct), so a disable request would leave the row enabled. + res := tx.Model(&model.PolicyRule{}).Where(params.IDs). + Select("is_enabled"). + Updates(model.PolicyRule{IsEnabled: params.IsEnabled}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } } @@ -90,6 +99,11 @@ func PutPolicyRule(ctx *gin.Context) { return } + if err := validatePolicyRulePatterns(params.Pattern); err != nil { + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { var policyRule model.PolicyRule @@ -98,7 +112,7 @@ func PutPolicyRule(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } policyRule.Action = params.Action @@ -108,14 +122,14 @@ func PutPolicyRule(ctx *gin.Context) { tx.Save(&policyRule) if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -137,24 +151,37 @@ func DeletePolicyRule(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } if err := fvm.PushFSL(tx); err != nil { - return errors.New("Rules compile error, please check your params.") + return errRulesCompile } return nil }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } response.Success(ctx, nil) } +// validatePolicyRulePatterns rejects a rule that would compile to a selector +// with no WHERE. An empty pattern list matches every request. +func validatePolicyRulePatterns(raw datatypes.JSON) error { + if len(raw) == 0 { + return errPolicyRuleEmpty + } + var patterns []model.PolicyRulePattern + if err := json.Unmarshal(raw, &patterns); err != nil || len(patterns) == 0 { + return errPolicyRuleEmpty + } + return nil +} + func GetPolicyRule(ctx *gin.Context) { var params pageRequest if err := ctx.BindQuery(¶ms); err != nil { diff --git a/management/webserver/api/response/error.go b/management/webserver/api/response/error.go index d62b815c..4d03fbd3 100644 --- a/management/webserver/api/response/error.go +++ b/management/webserver/api/response/error.go @@ -1,11 +1,13 @@ package response const ( - ErrLoginRequired = "login-required" - ErrWrongPasscode = "wrong-passcode" - ErrWrongTimeGap = "wrong-time-gap" - ErrInternalError = "internal-error" - ErrDataNotExist = "data-not-exist" - ErrWrongFileType = "wrong-filetype" - ErrReadOnly = "read-only" + ErrLoginRequired = "login-required" + ErrWrongPasscode = "wrong-passcode" + ErrWrongTimeGap = "wrong-time-gap" + ErrInternalError = "internal-error" + ErrDataNotExist = "data-not-exist" + ErrWrongFileType = "wrong-filetype" + ErrReadOnly = "read-only" + ErrTooManyAttempts = "too-many-attempts" + ErrForbidden = "forbidden" ) diff --git a/management/webserver/api/response/jsonbody.go b/management/webserver/api/response/jsonbody.go index 4af6e615..9e498b4b 100644 --- a/management/webserver/api/response/jsonbody.go +++ b/management/webserver/api/response/jsonbody.go @@ -17,6 +17,12 @@ var ( ErrorParamNotOK = JSONBody{ErrInternalError, "Error occurred when extracting params", nil} ErrorDataNotExist = JSONBody{ErrDataNotExist, "Data queried does not exist", nil} ErrorReadOnly = JSONBody{ErrReadOnly, "This environment is read only", nil} + + // ErrorInternal is the answer to a failed database or runtime call. The + // text of the underlying error stays in the server log: it can carry + // schema names, addresses and other details of the deployment that the + // caller has no use for. + ErrorInternal = JSONBody{ErrInternalError, "Error occurred when processing the request, please check the server log", nil} ) func Success(c *gin.Context, data interface{}) { diff --git a/management/webserver/api/website.go b/management/webserver/api/website.go index c21b3d43..2f9e3168 100644 --- a/management/webserver/api/website.go +++ b/management/webserver/api/website.go @@ -5,8 +5,6 @@ import ( "net/http" "strconv" - "chaitin.cn/dev/go/errors" - "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -53,6 +51,14 @@ func PostWebsite(ctx *gin.Context) { return } + if err := params.Validate(); err != nil { + logger.Warn(err) + // The message of a validation error is written for the administrator + // ("invalid port ..."), so it is passed on as it is. + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { website := &model.Website{Comment: params.Comment, ServerNames: params.ServerNames, Upstreams: params.Upstreams, Ports: params.Ports, @@ -75,7 +81,7 @@ func PostWebsite(ctx *gin.Context) { }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -90,6 +96,14 @@ func PutWebsite(ctx *gin.Context) { return } + if err := params.Validate(); err != nil { + logger.Warn(err) + // The message of a validation error is written for the administrator + // ("invalid port ..."), so it is passed on as it is. + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest) + return + } + db := database.GetDB() err := db.Transaction(func(tx *gorm.DB) error { var website model.Website @@ -98,7 +112,7 @@ func PutWebsite(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } website.Comment = params.Comment @@ -108,13 +122,17 @@ func PutWebsite(ctx *gin.Context) { website.CertFilename = params.CertFilename website.KeyFilename = params.KeyFilename website.IsEnabled = true - tx.Save(&website) + if err := tx.Save(&website).Error; err != nil { + return err + } if config.GlobalConfig.Server.DevMode { return nil } - err := publishWebsiteConfig(¶ms) + // Publish the row that was just saved, not the request body. The saved + // record forces IsEnabled and may differ from params. + err := publishWebsiteConfig(&website) if err != nil { return err } @@ -123,7 +141,7 @@ func PutWebsite(ctx *gin.Context) { }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -145,7 +163,7 @@ func DeleteWebsite(ctx *gin.Context) { return res.Error } if res.RowsAffected == 0 { - return errors.New("Data queried does not exist") + return errDataNotExist } if config.GlobalConfig.Server.DevMode { @@ -161,7 +179,7 @@ func DeleteWebsite(ctx *gin.Context) { }) if err != nil { logger.Error(err) - response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError) + response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError) return } @@ -188,13 +206,15 @@ func GetWebsite(ctx *gin.Context) { var statistics []model.SystemStatistics var websiteIds []string for _, i := range websiteList { - websiteIds = append(websiteIds, strconv.Itoa(int(i.ID))) + // FormatUint renders the identifier without narrowing it: it is a uint, + // and going through int would truncate it on a 32 bit build. + websiteIds = append(websiteIds, strconv.FormatUint(uint64(i.ID), 10)) } db.Where("created_at >= date_trunc('day',now())").Where("website in (?)", websiteIds).Find(&statistics) for i, website := range websiteList { for _, j := range statistics { - if strconv.Itoa(int(website.ID)) == j.Website { + if strconv.FormatUint(uint64(website.ID), 10) == j.Website { if j.Type == "website-req" { websiteList[i].ReqValue = j.Value } else if j.Type == "website-denied" { diff --git a/management/webserver/cmd/gen_certs.go b/management/webserver/cmd/gen_certs.go index 509e6e44..489d11fc 100644 --- a/management/webserver/cmd/gen_certs.go +++ b/management/webserver/cmd/gen_certs.go @@ -1,11 +1,16 @@ package cmd import ( + "crypto/x509" "crypto/x509/pkix" + "encoding/pem" + "os" "path/filepath" + "time" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" + "chaitin.cn/patronus/safeline-2/management/webserver/rpc" "chaitin.cn/patronus/safeline-2/management/webserver/utils" ) @@ -16,9 +21,196 @@ func GenCerts() error { if err := genClientCACert(); err != nil { return err } + if err := genControlChannelCerts(); err != nil { + return err + } return nil } +// genControlChannelCerts issues the credentials of the control channel between +// the management server and tcontrollerd. +// +// The channel carries the complete site configuration of the installation, and +// it used to be served in the clear with a token in the metadata as its only +// gate: the token is readable by anything that shares the network of the +// containers, and a caller that reads it can subscribe as tcontrollerd. Both +// ends are therefore issued by a CA that only this installation knows, and each +// end checks the other against it. +// +// The server credentials stay in the resources directory, which only the +// management container mounts. The credentials of tcontrollerd are published in +// the directory both containers mount, next to the token file, because +// tcontrollerd runs in the tengine container. +func genControlChannelCerts() error { + caCert, caKey, err := controlChannelCA() + if err != nil { + return err + } + + // The name is fixed on both sides: tcontrollerd dials an address of the + // deployment, which no certificate can be issued for. The addresses of the + // standard deployment are listed as well, so that a client which verifies + // the address instead of the name still works. + hostnames := []string{rpc.ServerName, "mgt", "safeline-mgt", "localhost"} + + if err := ensureSignedCert( + filepath.Join(rpc.CertDir(), rpc.ServerCertFile), + filepath.Join(rpc.CertDir(), rpc.ServerKeyFile), + caCert, + caKey, + hostnames, + &pkix.Name{ + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + Organization: []string{"Beijing WAF Technology Co., Ltd."}, + OrganizationalUnit: []string{"Service Infrastructure Department"}, + CommonName: rpc.ServerName, + }, + false, + ); err != nil { + return err + } + + clientDir := rpc.ClientCertDir() + + if err := ensureSignedCert( + filepath.Join(clientDir, rpc.ClientCertFile), + filepath.Join(clientDir, rpc.ClientKeyFile), + caCert, + caKey, + []string{}, + &pkix.Name{ + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + Organization: []string{"Beijing WAF Technology Co., Ltd."}, + OrganizationalUnit: []string{"Service Infrastructure Department"}, + CommonName: "tcontrollerd", + }, + true, + ); err != nil { + return err + } + + // tcontrollerd verifies the server with the CA, so it needs a copy. A + // certificate is public, unlike the key that never leaves the management + // container. + return utils.EnsureRenameWriteFile(filepath.Join(clientDir, rpc.CertAuthorityFile), caCert, 0644) +} + +// controlChannelCA reads the certificate authority of the control channel, +// creating it when it is missing. +func controlChannelCA() ([]byte, []byte, error) { + certPath := filepath.Join(rpc.CertDir(), rpc.CertAuthorityFile) + keyPath := filepath.Join(rpc.CertDir(), "grpc_ca.key") + + if err := utils.WriteCertIfNotExist( + certPath, + keyPath, + func() ([]byte, []byte, error) { + return utils.GenerateCert( + []string{}, + 3650, + 4096, + &pkix.Name{ + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + Organization: []string{"Beijing WAF Technology Co., Ltd."}, + OrganizationalUnit: []string{"Service Infrastructure Department"}, + CommonName: "WAF Control Channel Certificate Authority", + }, + true, + ) + }); err != nil { + return nil, nil, err + } + + cert, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, err + } + + key, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, err + } + + return cert, key, nil +} + +// ensureSignedCert writes a certificate signed by the CA of the control +// channel, computing it again when the one on disk does not belong to that CA. +// +// Both ends check the other against the CA, so a certificate that was issued by +// an older CA, or that is about to expire, only shows up as a handshake that +// fails for no visible reason. It is replaced here instead; the key of the +// management server credentials is kept to the owner, the one of tcontrollerd +// is read by the tengine container and is kept to the owner as well. +func ensureSignedCert(certPath, keyPath string, caCert, caKey []byte, hostnames []string, subject *pkix.Name, client bool) error { + reusable, err := signedByAuthority(certPath, caCert) + if err != nil { + return err + } + + if reusable { + if _, err := os.Stat(keyPath); err == nil { + return nil + } + } + + cert, key, err := utils.GenerateSignedCert(caCert, caKey, hostnames, 3650, 4096, subject, client) + if err != nil { + return err + } + + if err := utils.EnsureRenameWriteFile(certPath, cert, 0644); err != nil { + return err + } + + return utils.EnsureRenameWriteFile(keyPath, key, 0600) +} + +// signedByAuthority reports whether the certificate at path is signed by the +// given authority and is not about to expire. +func signedByAuthority(path string, caCertPEM []byte) (bool, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + + return false, err + } + + block, _ := pem.Decode(content) + if block == nil { + return false, nil + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, nil + } + + caBlock, _ := pem.Decode(caCertPEM) + if caBlock == nil { + return false, nil + } + + authority, err := x509.ParseCertificate(caBlock.Bytes) + if err != nil { + return false, nil + } + + if err := certificate.CheckSignatureFrom(authority); err != nil { + return false, nil + } + + return time.Now().Add(24 * time.Hour).Before(certificate.NotAfter), nil +} + func genServerCert() error { certPath := filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath, "server.crt") keyPath := filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath, "server.key") diff --git a/management/webserver/cmd/reset_user.go b/management/webserver/cmd/reset_user.go index c879f0da..5519136f 100644 --- a/management/webserver/cmd/reset_user.go +++ b/management/webserver/cmd/reset_user.go @@ -3,12 +3,33 @@ package cmd import ( "chaitin.cn/patronus/safeline-2/management/webserver/model" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/database" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/log" ) +var logger = log.GetLogger("cmd") + +// ResetUser clears the credentials of a user so that the next visit of the +// console binds a new TFA secret. +// +// The pending secret and the address it was issued to are cleared together: +// a secret that was handed to somebody else cannot be bound by the +// administrator, and starting the bootstrap over from the shell is the only +// way out of that state. +// +// It also opens the anonymous bootstrap window again, because this command is +// how an operator asks for it: the account is unbound at the end of the call, +// and the caller is at the console now. func ResetUser(username string) { db := database.GetDB() var user model.User db.Where(&model.User{Username: username}).First(&user) user.LastLoginTime = 0 + user.TFASecret = "" + user.TFABootstrapIP = "" + user.TFALastUsedStep = 0 db.Save(&user) + + if err := model.OpenBootstrapWindow(); err != nil { + logger.Error(err) + } } diff --git a/management/webserver/go.mod b/management/webserver/go.mod index db79f843..914f6027 100644 --- a/management/webserver/go.mod +++ b/management/webserver/go.mod @@ -1,8 +1,8 @@ module chaitin.cn/patronus/safeline-2/management/webserver -go 1.21 +go 1.25.0 -toolchain go1.21.3 +toolchain go1.25.14 require ( chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 @@ -13,8 +13,8 @@ require ( github.com/pquerna/otp v1.4.0 github.com/robfig/cron/v3 v3.0.1 github.com/rogpeppe/go-internal v1.10.0 - github.com/sirupsen/logrus v1.4.2 - google.golang.org/grpc v1.65.0 + github.com/sirupsen/logrus v1.9.3 + google.golang.org/grpc v1.83.2 gorm.io/datatypes v1.1.1 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.10 @@ -36,7 +36,7 @@ require ( github.com/gorilla/sessions v1.2.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/pgx/v5 v5.11.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -50,8 +50,8 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/management/webserver/go.sum b/management/webserver/go.sum index c1e3978c..a38752aa 100644 --- a/management/webserver/go.sum +++ b/management/webserver/go.sum @@ -1,3 +1,4 @@ +chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8 h1:qljuZkcItfSnd1OvteZKgxlqZzcr48DC9qm+OTnt/Xo= chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 h1:1Qa9ABk907/9ZrOLbbRcS8Fqq9VhjAF/mLjbSP1qAJY= chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo= @@ -7,10 +8,10 @@ chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c h1:tXsraF7o9iUsQY6 chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c/go.mod h1:fUvtmpG8Z8Zf5aciadL9a/vn5SB3knG7pdNJixDplPg= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA= github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= @@ -21,13 +22,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8= github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= @@ -35,6 +39,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= @@ -60,10 +65,16 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -88,9 +99,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= @@ -101,6 +114,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -108,11 +122,13 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -131,53 +147,65 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -190,10 +218,14 @@ google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -212,12 +244,14 @@ gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y= gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc= gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 h1:9qNbmu21nNThCNnF5i2R3kw2aL27U8ZwbzccNjOmW0g= gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/management/webserver/main.go b/management/webserver/main.go index 43e65851..6790a5f4 100644 --- a/management/webserver/main.go +++ b/management/webserver/main.go @@ -136,18 +136,49 @@ func main() { gin.SetMode(gin.ReleaseMode) r := gin.Default() + // gin.ClientIP() returns the first value of X-Forwarded-For as soon as the + // direct peer belongs to the trusted set, and the default trusted set is + // every address. The management port is published on the host and reached + // directly, so a client could otherwise pick the address it is throttled by + // - the login throttle and the TFA bootstrap both key on it. No proxy is + // trusted unless a deployment says so. + if err := r.SetTrustedProxies(nil); err != nil { + logger.Fatalln("Failed to disable trusted proxies: ", err) + } + r.Use(middleware.SecurityHeaders) + // Nothing in front of this server caps the request body, so it is bounded + // here (see middleware.BodyLimit). + r.Use(middleware.BodyLimit(middleware.MaxRequestBodyBytes)) var option model.Options database.GetDB().Where(&model.Options{Key: constants.SecretKey}).First(&option) - logger.Debugf("Secret: %s", option.Value) store := cookie.NewStore([]byte(option.Value)) r.Use(sessions.Sessions("session", store)) + created, tokenPath, err := api.EnsureBootstrapToken() + if err != nil { + logger.Fatalln("Failed to initialize the TFA bootstrap token: ", err) + } + if os.Getenv(api.BootstrapTokenEnv) != "" { + logger.Infof("GET /api%s requires the %s header from %s.", api.OTPUrl, api.BootstrapTokenHeader, api.BootstrapTokenEnv) + } else if created { + logger.Infof("Wrote the TFA bootstrap token to %s (mode 0600). GET /api%s requires the %s header. The stock console must send it; until then bind with curl.", tokenPath, api.OTPUrl, api.BootstrapTokenHeader) + } else { + logger.Infof("GET /api%s requires the %s header; token loaded from %s.", api.OTPUrl, api.BootstrapTokenHeader, tokenPath) + } + if window := api.BootstrapWindow(); window <= 0 { + logger.Warnf("%s is off: the account can be bound from the network until somebody logs in. A console that is left unbound stays claimable.", + api.BootstrapWindowEnv) + } else if openedAt, ok := model.BootstrapOpenedAt(); ok { + logger.Infof("The TFA bootstrap of the account is open until %s (%s after it was opened at %s); run 'mgt -reset_user admin' to open it again.", + openedAt.Add(window).Format(time.RFC3339), window, openedAt.Format(time.RFC3339)) + } + publicRouters := r.Group("/api") + publicRouters.Use(middleware.SameOrigin) publicRouters.POST(api.Login, api.PostLogin) publicRouters.POST(api.Logout, api.PostLogout) - publicRouters.POST(api.Behaviour, api.PostBehaviour) - publicRouters.POST(api.FalsePositives, api.PostFalsePositives) + // First-boot QR still has no session, but requires X-Bootstrap-Token. publicRouters.GET(api.OTPUrl, api.GetOTPUrl) publicRouters.GET(api.Version, api.GetVersion) publicRouters.GET(api.UpgradeTips, api.GetUpgradeTips) @@ -159,22 +190,24 @@ func main() { }) limitedRouters := r.Group("/api") - noAuth, existed := os.LookupEnv("NO_AUTH") - if existed && len(noAuth) >= 0 { - logger.Warn("No auth") + limitedRouters.Use(middleware.SameOrigin) + if envEnabled(os.Getenv("NO_AUTH")) { + logger.Warn("Authentication is disabled because NO_AUTH is enabled") } else { limitedRouters.Use(middleware.AuthRequired) } - readOnly, existed := os.LookupEnv("READ_ONLY") - if existed && len(readOnly) >= 0 { - logger.Warn("Read only") + if envEnabled(os.Getenv("READ_ONLY")) { + logger.Warn("Write requests are rejected because READ_ONLY is enabled") limitedRouters.Use(middleware.ReadOnly) } limitedRouters.GET(api.User, api.GetUser) + limitedRouters.POST(api.Behaviour, api.PostBehaviour) + limitedRouters.GET(api.DetectLogList, api.GetDetectLogList) limitedRouters.GET(api.DetectLogDetail, api.GetDetectLogDetail) + limitedRouters.POST(api.FalsePositives, api.PostFalsePositives) limitedRouters.POST(api.Website, api.PostWebsite) limitedRouters.PUT(api.Website, api.PutWebsite) @@ -208,3 +241,16 @@ func main() { logger.Fatalln("Error occurred when running web server: ", err) } } + +// envEnabled reports whether an environment variable holds an explicit truthy +// value. Switches such as NO_AUTH and READ_ONLY disable a protection, so only +// values that actually ask for it may enable them: an empty string (or any +// other unparsable value) must never do it. +func envEnabled(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/management/webserver/middleware/auth.go b/management/webserver/middleware/auth.go index f200175c..e77757d5 100644 --- a/management/webserver/middleware/auth.go +++ b/management/webserver/middleware/auth.go @@ -8,6 +8,7 @@ import ( "chaitin.cn/patronus/safeline-2/management/webserver/api/response" "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/sessionopts" ) func AuthRequired(c *gin.Context) { @@ -21,17 +22,15 @@ func AuthRequired(c *gin.Context) { } // extend session expired time - session.Options(sessions.Options{ - Path: "/", - MaxAge: 3600 * 24 * 7, - //Domain: options.Domain, - //HttpOnly: true, - //SameSite: http.SameSiteLaxMode, - //Secure: false, - }) + session.Options(sessionopts.Options(c)) if err := session.Save(); err != nil { response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError) + // The request has to stop here. Gin runs the handlers that follow a + // middleware from the same loop that called this one, so returning + // without Abort would still let the request reach the protected handler + // even though the answer above already reported a failure. + c.Abort() return } diff --git a/management/webserver/middleware/bodylimit.go b/management/webserver/middleware/bodylimit.go new file mode 100644 index 00000000..1e592201 --- /dev/null +++ b/management/webserver/middleware/bodylimit.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// MaxRequestBodyBytes bounds the size of a request body the management API +// accepts. +// +// This server terminates TLS itself and its port is published on the host, so +// no proxy caps the body in front of it: without a limit a client can make it +// buffer an arbitrary amount of data. The largest body the API takes is an SSL +// certificate upload, which is a few tens of kilobytes, so the limit leaves a +// wide margin while still bounding what a single request may consume. +const MaxRequestBodyBytes = 16 << 20 // 16 MiB + +// BodyLimit caps the request body at maxBytes. +// +// The body is replaced by a reader that reports an error once the limit is +// exceeded, so a handler that parses the body fails with a parameter error +// instead of reading it all. Requests without a body are not affected. +func BodyLimit(maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + } + c.Next() + } +} diff --git a/management/webserver/middleware/origin.go b/management/webserver/middleware/origin.go new file mode 100644 index 00000000..35e06158 --- /dev/null +++ b/management/webserver/middleware/origin.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "chaitin.cn/patronus/safeline-2/management/webserver/api/response" +) + +// SameOrigin rejects cross-site state-changing requests using the Origin +// header. The management console is a cookie-session SPA; Lax already drops +// the cookie on cross-site POST, and this check covers non-browser clients +// that still send Origin, plus browsers that attach the cookie on same-site +// confusing cases. +// +// Safe methods are left alone. Requests without a usable Origin are refused: +// Referrer-Policy is no-referrer, so Referer is not a fallback. +func SameOrigin(c *gin.Context) { + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + c.Next() + return + } + + origin := strings.TrimSpace(c.GetHeader("Origin")) + if origin == "" || strings.EqualFold(origin, "null") { + response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden) + c.Abort() + return + } + + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden) + c.Abort() + return + } + + if !originMatchesRequest(parsed, c) { + response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden) + c.Abort() + return + } + + c.Next() +} + +func originMatchesRequest(origin *url.URL, c *gin.Context) bool { + // Scheme is not compared: the console is reached over HTTPS while gin + // often sees HTTP behind the container TLS terminator. Host still has + // to match, so a foreign site cannot satisfy this check. + if !strings.EqualFold(origin.Scheme, "http") && !strings.EqualFold(origin.Scheme, "https") { + return false + } + return strings.EqualFold(origin.Host, c.Request.Host) +} diff --git a/management/webserver/middleware/origin_test.go b/management/webserver/middleware/origin_test.go new file mode 100644 index 00000000..bfb68596 --- /dev/null +++ b/management/webserver/middleware/origin_test.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestOriginMatchesRequestIgnoresScheme(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "http://127.0.0.1:9001/api/Login", nil) + c.Request.Host = "example.com:9443" + + origin, err := url.Parse("https://example.com:9443") + if err != nil { + t.Fatal(err) + } + if !originMatchesRequest(origin, c) { + t.Fatal("https Origin should match Host behind HTTP gin") + } + + evil, err := url.Parse("https://evil.example") + if err != nil { + t.Fatal(err) + } + if originMatchesRequest(evil, c) { + t.Fatal("foreign Origin must not match") + } +} diff --git a/management/webserver/middleware/security.go b/management/webserver/middleware/security.go new file mode 100644 index 00000000..9b5af017 --- /dev/null +++ b/management/webserver/middleware/security.go @@ -0,0 +1,32 @@ +package middleware + +import "github.com/gin-gonic/gin" + +// SecurityHeaders sets the response headers that keep a browser from +// interpreting an API answer as something else, and keep the console out of +// frames. +// +// The policy deliberately restricts only embedding, plugins and the base URI: +// the management console is served from the same host, so a stricter policy +// would have to be coordinated with the console bundle. +func SecurityHeaders(c *gin.Context) { + // A JSON answer must never be sniffed into HTML or JavaScript. + c.Header("X-Content-Type-Options", "nosniff") + + // The console must not be embedded, so that another page cannot click + // through it on behalf of the administrator. + c.Header("X-Frame-Options", "DENY") + c.Header("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'none'") + + // Keep the console URL out of the Referer header of third party requests. + c.Header("Referrer-Policy", "no-referrer") + + // The console is reached over TLS; a browser that saw this header once will + // refuse to fall back to plain HTTP for the following year. includeSubDomains + // is deliberately left out: the console shares its host with whatever else + // the deployment serves from it, and pinning every subdomain of that name is + // not this service's decision to make. + c.Header("Strict-Transport-Security", "max-age=31536000") + + c.Next() +} diff --git a/management/webserver/model/detectlog.go b/management/webserver/model/detectlog.go index b12473a6..ef8fafd1 100644 --- a/management/webserver/model/detectlog.go +++ b/management/webserver/model/detectlog.go @@ -160,10 +160,13 @@ func InitDetectLogSamples() { protocolList := []int{constants.ProtocolHTTP, constants.ProtocolHTTPS} portList := []uint{80, 443} - provinceList := []string{} - cityList := []string{} - ipList := []string{} - ruleIdList := []string{} + // These lists are indexed with randInt%len(...), so they must not be + // empty: a zero length makes every one of the lookups below a division by + // zero, which panics instead of generating sample data. + provinceList := []string{"Beijing", "Shanghai", "Guangdong"} + cityList := []string{"Beijing", "Shanghai", "Guangzhou"} + ipList := []string{"10.2.35.143", "192.168.1.10", "172.16.0.5"} + ruleIdList := []string{"10001", "20002", "30003"} for i := 0; i < 100; i++ { randInt := rand.Intn(1000) diff --git a/management/webserver/model/option.go b/management/webserver/model/option.go index 538bf5f9..88ee80d7 100644 --- a/management/webserver/model/option.go +++ b/management/webserver/model/option.go @@ -4,6 +4,10 @@ import ( "bytes" "encoding/json" "net/http" + "regexp" + "strconv" + "strings" + "time" "gorm.io/gorm/clause" @@ -24,14 +28,122 @@ type Options struct { func initOptions() error { db := database.GetDB() - secretKey := Options{Key: constants.SecretKey, Value: utils.RandStr(32)} + // The session signing key authorises every management request, so it has + // to come from a cryptographically secure source. + secretKey := Options{Key: constants.SecretKey, Value: utils.RandHex(32)} db.Clauses(clause.OnConflict{DoNothing: true}).Create(&secretKey) + // The account exists but has never been bound, so the anonymous TFA + // bootstrap is open from now on: an installation that is left unbound has + // to stop being claimable by whoever reaches the console first, and this + // record is what that window is measured from. + if err := OpenBootstrapWindowIfMissing(); err != nil { + return err + } + machineId := Options{Key: constants.MachineID, Value: utils.RandStr(32)} _ = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&machineId) go NotifyInstallation(machineId.Value) - return nil + return rotatePredictableSecretKey() +} + +// predictableSecretKeyPattern matches the shape of a key produced by the +// former RandStr: 32 letters out of [a-zA-Z]. RandHex produces 64 hexadecimal +// characters, so a value matching this pattern can only come from the +// predictable generator. +var predictableSecretKeyPattern = regexp.MustCompile(`^[a-zA-Z]{32}$`) + +// rotatePredictableSecretKey replaces a session signing key that was generated +// with the predictable time seeded PRNG. +// +// The key is only created when it is missing (OnConflict DoNothing), so an +// installation that was set up by an older release keeps its weak value +// forever. Such a key has to be treated as public: it is rotated here, which +// invalidates the sessions that were signed with it, and the administrator +// simply logs in again. +func rotatePredictableSecretKey() error { + db := database.GetDB() + + var secretKey Options + if err := db.Where(&Options{Key: constants.SecretKey}).First(&secretKey).Error; err != nil { + return err + } + + if !predictableSecretKeyPattern.MatchString(secretKey.Value) { + return nil + } + + logger.Warn("The session signing key was generated by a predictable random source, rotating it. Existing sessions are invalidated.") + secretKey.Value = utils.RandHex(32) + + return db.Save(&secretKey).Error +} + +// OpenBootstrapWindow records now as the moment the anonymous TFA bootstrap of +// the account was opened. +// +// This is deliberately not tied to the start of the process: a deployment that +// is restarted, upgraded or power cycled would otherwise open the window again +// on every boot, and the window is what keeps an installation that nobody ever +// bound from being claimable forever. Only the setup of an installation and +// `mgt -reset_user admin` move it. +func OpenBootstrapWindow() error { + return setOption(constants.BootstrapOpenedAt, strconv.FormatInt(time.Now().Unix(), 10)) +} + +// BootstrapOpenedAt returns the moment the anonymous TFA bootstrap was opened +// and whether a record exists. +func BootstrapOpenedAt() (time.Time, bool) { + db := database.GetDB() + + var option Options + if err := db.Where(&Options{Key: constants.BootstrapOpenedAt}).First(&option).Error; err != nil { + return time.Time{}, false + } + + seconds, err := strconv.ParseInt(strings.TrimSpace(option.Value), 10, 64) + if err != nil { + return time.Time{}, false + } + + return time.Unix(seconds, 0), true +} + +// OpenBootstrapWindowIfMissing records the moment the anonymous TFA bootstrap +// was opened, and leaves the record of an installation that already has one +// alone. It is called when the daemon starts, which is why a restart does not +// open the window again. +// +// A fresh installation has just created the account, so the window starts now. +// An installation that is being upgraded keeps the age of its account instead: +// a console that nobody bound in months must not become claimable again because +// an upgrade restarted the daemon, and `mgt -reset_user admin` is what opens +// the window when an operator does want to bind it. +func OpenBootstrapWindowIfMissing() error { + db := database.GetDB() + + openedAt := time.Now() + + var user User + if err := db.Where(&User{Username: constants.SuperUser}).First(&user).Error; err == nil && !user.CreatedAt.IsZero() { + openedAt = user.CreatedAt + } + + opened := Options{Key: constants.BootstrapOpenedAt, Value: strconv.FormatInt(openedAt.Unix(), 10)} + + // A restart must not open the window again, so an existing record wins. + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&opened).Error +} + +// setOption stores value under key, replacing what is there. +func setOption(key, value string) error { + db := database.GetDB() + + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value"}), + }).Create(&Options{Key: key, Value: value}).Error } func NotifyInstallation(machineId string) { @@ -58,6 +170,9 @@ func NotifyInstallation(machineId string) { logger.Error(err) return } + // The caller owns the response returned by DoPostTelemetry and has to + // release it, otherwise every report leaks a connection. + defer rsp.Body.Close() if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusCreated { logger.Errorf("transfer telemetry %s failed, status code = %d", constants.Installation, rsp.StatusCode) diff --git a/management/webserver/model/policygroup.go b/management/webserver/model/policygroup.go index ba6cce8e..636170cf 100644 --- a/management/webserver/model/policygroup.go +++ b/management/webserver/model/policygroup.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "fmt" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -79,9 +80,54 @@ func initSrcIPConfig() error { return nil } +// skynetConfigStr is the skynet configuration of 5.3.9. It is also the +// source of truth for the module names the engine knows about: the keys of a +// policy group are module names that end up in this document. +const skynetConfigStr = "{\"decode_config\":{\"decode_methods\":[\"url decode\",\"JSON\",\"base64\",\"hex\",\"eval\",\"XML\",\"PHP deserialize\",\"utf7\"]},\"deep_detect\":false,\"modules\":{\"m_asp_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_cmd_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_csrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_include\":{\"detect_config\":{\"detect_suspicious_schema\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_upload\":{\"detect_config\":{\"action_of_handling_custome_file\":[\"deny transmission\"],\"detect_file_content\":true,\"detect_real_file_content\":false,\"enable_module_in_detecting_file_content\":[\"java\",\"asp_code_injection\",\"php_code_injection\"],\"file_type_of_custome_action\":[],\"forbidden_extra_token\":false,\"forbidden_multiple_filename\":true,\"forbidden_suspicious_filename\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_http\":{\"detect_config\":{\"warning_http_parse_failed\":false,\"warning_suspicious_http_version\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_java\":{\"detect_config\":{\"detect_lookup\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_java_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_response\":{\"detect_config\":{\"detection_configure\":[\"detect_jsp_code_leak\",\"detect_php_code_leak\",\"detect_webshell\"],\"error_types\":[\"directory indexing\",\"SQL execution error\",\"server exception\"]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_rule\":{\"detect_config\":{\"check_info_leak_by_rsp\":false,\"compatibility_mode\":false,\"detection_configure\":[\"detect_phpinfo\",\"detect_admin_page\",\"detect_backdoor\",\"detect_php_vuln\",\"detect_nginx\",\"detect_dedecms\",\"detect_apache\",\"detect_xml\",\"detect_struts2\",\"detect_java_vuln\",\"detect_php168\",\"detect_wordpress\",\"detect_directory_traversal\",\"detect_iis\",\"detect_gogs_gitea\",\"detect_thinkphp\",\"detect_jenkins\",\"detect_ecshop\",\"detect_nexus\",\"detect_drupal\",\"detect_ghostscript\",\"detect_atlassian\",\"detect_weblogic\",\"detect_coremail\",\"detect_phpcms\",\"detect_spring\",\"detect_southidc\",\"detect_fastjson\",\"detect_tbk_dvr\",\"detect_joomla\",\"detect_ecology_oa\",\"detect_jackson\",\"detect_xstream\",\"detect_activemq\",\"detect_solr\",\"detect_csii\",\"detect_big_ip\",\"detect_apisix\",\"detect_druid\",\"detect_log4j\"],\"info_leak_types\":[\"test file\",\"backup file\",\"code repository\",\"server sensitive file\"],\"rules_config\":{\"disable_ruleid\":[],\"enable_ruleid\":[],\"rules_status\":[]}},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_scanner\":{\"detect_config\":{\"language_types\":[\"python_scanner\",\"go_scanner\"],\"other_types\":[\"normal_scanner\"],\"spider_types\":[]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_sqli\":{\"detect_config\":{\"detect_non_injection_sql\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssti\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_xss\":{\"detect_config\":{\"detect_complete_html\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"}},\"timeout\":{\"threshold\":1000,\"log\":\"enabled\"}}" + +// SkynetModules returns the set of module names the engine knows about. +func SkynetModules() (map[string]struct{}, error) { + var sc SkynetConfig + if err := json.Unmarshal([]byte(skynetConfigStr), &sc); err != nil { + return nil, err + } + + modules := make(map[string]struct{}, len(sc.Modules)) + for module := range sc.Modules { + modules[module] = struct{}{} + } + + return modules, nil +} + +// ValidatePolicyGroup checks that a policy group only names modules and modes +// that the engine understands. +// +// The keys of the map are written into the skynet configuration, so an unknown +// module would either be handed to an engine that has never heard of it or make +// the rendering step below dereference a nil module entry. +func ValidatePolicyGroup(pgg PolicyGroup) error { + modules, err := SkynetModules() + if err != nil { + return err + } + + for module, mode := range pgg { + if _, ok := modules[module]; !ok { + return fmt.Errorf("unknown module %q", module) + } + + switch mode { + case StrictMode, DefaultMode, DisableMode: + default: + return fmt.Errorf("module %q: unknown mode %q", module, mode) + } + } + + return nil +} + func GetSkynetConfig(db *gorm.DB) (string, error) { - // skynetConfigStr skynet config in 5.3.9 - const skynetConfigStr = "{\"decode_config\":{\"decode_methods\":[\"url decode\",\"JSON\",\"base64\",\"hex\",\"eval\",\"XML\",\"PHP deserialize\",\"utf7\"]},\"deep_detect\":false,\"modules\":{\"m_asp_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_cmd_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_csrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_include\":{\"detect_config\":{\"detect_suspicious_schema\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_upload\":{\"detect_config\":{\"action_of_handling_custome_file\":[\"deny transmission\"],\"detect_file_content\":true,\"detect_real_file_content\":false,\"enable_module_in_detecting_file_content\":[\"java\",\"asp_code_injection\",\"php_code_injection\"],\"file_type_of_custome_action\":[],\"forbidden_extra_token\":false,\"forbidden_multiple_filename\":true,\"forbidden_suspicious_filename\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_http\":{\"detect_config\":{\"warning_http_parse_failed\":false,\"warning_suspicious_http_version\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_java\":{\"detect_config\":{\"detect_lookup\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_java_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_response\":{\"detect_config\":{\"detection_configure\":[\"detect_jsp_code_leak\",\"detect_php_code_leak\",\"detect_webshell\"],\"error_types\":[\"directory indexing\",\"SQL execution error\",\"server exception\"]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_rule\":{\"detect_config\":{\"check_info_leak_by_rsp\":false,\"compatibility_mode\":false,\"detection_configure\":[\"detect_phpinfo\",\"detect_admin_page\",\"detect_backdoor\",\"detect_php_vuln\",\"detect_nginx\",\"detect_dedecms\",\"detect_apache\",\"detect_xml\",\"detect_struts2\",\"detect_java_vuln\",\"detect_php168\",\"detect_wordpress\",\"detect_directory_traversal\",\"detect_iis\",\"detect_gogs_gitea\",\"detect_thinkphp\",\"detect_jenkins\",\"detect_ecshop\",\"detect_nexus\",\"detect_drupal\",\"detect_ghostscript\",\"detect_atlassian\",\"detect_weblogic\",\"detect_coremail\",\"detect_phpcms\",\"detect_spring\",\"detect_southidc\",\"detect_fastjson\",\"detect_tbk_dvr\",\"detect_joomla\",\"detect_ecology_oa\",\"detect_jackson\",\"detect_xstream\",\"detect_activemq\",\"detect_solr\",\"detect_csii\",\"detect_big_ip\",\"detect_apisix\",\"detect_druid\",\"detect_log4j\"],\"info_leak_types\":[\"test file\",\"backup file\",\"code repository\",\"server sensitive file\"],\"rules_config\":{\"disable_ruleid\":[],\"enable_ruleid\":[],\"rules_status\":[]}},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_scanner\":{\"detect_config\":{\"language_types\":[\"python_scanner\",\"go_scanner\"],\"other_types\":[\"normal_scanner\"],\"spider_types\":[]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_sqli\":{\"detect_config\":{\"detect_non_injection_sql\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssti\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_xss\":{\"detect_config\":{\"detect_complete_html\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"}},\"timeout\":{\"threshold\":1000,\"log\":\"enabled\"}}" var sc SkynetConfig err := json.Unmarshal([]byte(skynetConfigStr), &sc) if err != nil { @@ -98,28 +144,30 @@ func GetSkynetConfig(db *gorm.DB) (string, error) { } for module, mode := range pgg { + // The module names come from a request body, so a key that the engine + // does not know about must be rejected instead of being dereferenced + // (sc.Modules[module] is a nil pointer for an unknown module). + scm, ok := sc.Modules[module] + if !ok || scm == nil { + return "", fmt.Errorf("unknown module %q", module) + } + switch mode { case StrictMode: - scm := sc.Modules[module] scm.HighRiskAction = "deny" scm.MediumRiskAction = "deny" scm.LowRiskAction = "deny" scm.State = "enabled" - sc.Modules[module] = scm case DefaultMode: - scm := sc.Modules[module] scm.HighRiskAction = "deny" scm.MediumRiskAction = "continue" scm.LowRiskAction = "continue" scm.State = "enabled" - sc.Modules[module] = scm case DisableMode: - scm := sc.Modules[module] scm.HighRiskAction = "continue" scm.MediumRiskAction = "continue" scm.LowRiskAction = "continue" scm.State = "disabled" - sc.Modules[module] = scm default: return "", errors.New("no such mode") } diff --git a/management/webserver/model/user.go b/management/webserver/model/user.go index eef58e73..dc1dd57e 100644 --- a/management/webserver/model/user.go +++ b/management/webserver/model/user.go @@ -16,7 +16,21 @@ type User struct { TFAEnabled bool `gorm:"column:tfa_enabled;default:true"` TFASecret string `gorm:"column:tfa_secret"` LastLoginTime int64 `gorm:"default:0"` - IsEnabled bool `gorm:"default:true"` + + // TFALastUsedStep is the time step (unix time divided by the TOTP period) + // of the passcode that was accepted last. A passcode is only accepted when + // it belongs to a later step, so a captured passcode cannot be replayed + // while it is still inside the validity window. + TFALastUsedStep int64 `gorm:"column:tfa_last_used_step;default:0"` + + // TFABootstrapIP is the client address the pending TFA secret was handed to + // before the account ever logged in. The secret is only served again to + // that address, and the first login has to come from it, so a caller that + // reaches the API before the administrator cannot bind the account to a + // secret it knows. + TFABootstrapIP string `gorm:"column:tfa_bootstrap_ip"` + + IsEnabled bool `gorm:"default:true"` } func initAdminUser() error { diff --git a/management/webserver/model/website.go b/management/webserver/model/website.go index 4eeb7bbf..af530e7d 100644 --- a/management/webserver/model/website.go +++ b/management/webserver/model/website.go @@ -1,6 +1,57 @@ package model -import "gorm.io/datatypes" +import ( + "encoding/json" + "fmt" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" + + "gorm.io/datatypes" +) + +const ( + // maxPort is the largest TCP port that may be listened on. + maxPort = 65535 + + // maxServerNameLength and maxLabelLength follow the DNS limits. + maxServerNameLength = 253 + maxLabelLength = 63 + + // defaultHttpsPort is the port tcontrollerd appends to an upstream that + // names https without a port. + defaultHttpsPort = "443" +) + +var ( + // portPattern matches the decimal notation of a TCP port. + portPattern = regexp.MustCompile(`^[0-9]{1,5}$`) + + // serverNameLabelPattern matches one label of a literal host name. The + // accepted character set is deliberately narrow: the value ends up in an + // nginx "server_name" directive, so anything that could terminate the + // directive (or start another one) must not pass. + serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`) + + // certFilenamePattern matches the file name of an uploaded certificate or + // key. The name is interpolated into "ssl_certificate" and + // "ssl_certificate_key", and it is joined to the certificate directory, so + // only a plain file name without a path separator, a control character or + // any nginx syntax may pass. + certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + + // upstreamSchemePattern matches the two schemes an upstream may use. The + // scheme is interpolated into "proxy_pass", so anything else is rejected. + upstreamSchemePattern = regexp.MustCompile(`^https?$`) + + // upstreamHostPattern matches the host of an upstream: a host name, an IPv4 + // address or a bracketed IPv6 address, with an optional port. The host is + // interpolated into the "server" directive of the site, so a value carrying + // nginx syntax (a space, a semicolon, a "$", a quote) must not pass. + upstreamHostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\.?)(?::([0-9]{1,5}))?$`) +) type Website struct { Base @@ -14,3 +65,179 @@ type Website struct { IsEnabled bool `gorm:"is_enabled;default=true" json:"is_enabled"` } + +// Validate checks the fields that tcontrollerd renders into the nginx +// configuration of the site. +// +// Ports and server names are interpolated into nginx directives, so they have +// to be plain ports and host names: a value carrying nginx syntax must be +// rejected here, before it is stored and pushed to the engine. +func (w *Website) Validate() error { + serverNames, err := unmarshalStringList(w.ServerNames) + if err != nil { + return fmt.Errorf("invalid server_names: %v", err) + } + + ports, err := unmarshalStringList(w.Ports) + if err != nil { + return fmt.Errorf("invalid ports: %v", err) + } + + for _, serverName := range serverNames { + if err = ValidateServerName(serverName); err != nil { + return err + } + } + + for _, port := range ports { + if err = ValidatePort(port); err != nil { + return err + } + } + + upstreams, err := unmarshalStringList(w.Upstreams) + if err != nil { + return fmt.Errorf("invalid upstreams: %v", err) + } + + for _, upstream := range upstreams { + if err = ValidateUpstream(upstream); err != nil { + return err + } + } + + if err = ValidateCertFilename(w.CertFilename); err != nil { + return err + } + + if err = ValidateCertFilename(w.KeyFilename); err != nil { + return err + } + + return nil +} + +// ValidateUpstream accepts the address of a site upstream. +// +// The scheme and the host of this value are interpolated into the "proxy_pass" +// and "server" directives of the site, so a value carrying nginx syntax must be +// rejected here, before it is stored and pushed to the engine. +func ValidateUpstream(upstream string) error { + parsed, err := url.Parse(upstream) + if err != nil { + return fmt.Errorf("invalid upstream %q: %v", upstream, err) + } + + if !upstreamSchemePattern.MatchString(parsed.Scheme) { + return fmt.Errorf("invalid upstream %q: the scheme has to be http or https", upstream) + } + + host := parsed.Host + if parsed.Scheme == "https" && parsed.Port() == "" { + // tcontrollerd appends the default HTTPS port to this value before it + // renders it, so validate the value it renders. + host = host + ":" + defaultHttpsPort + } + + matches := upstreamHostPattern.FindStringSubmatch(host) + if matches == nil { + return fmt.Errorf("invalid upstream %q: %q is not a host name, an IP address or a host with a port", upstream, host) + } + + if port := matches[1]; port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid upstream %q: port has to be between 1 and %d", upstream, maxPort) + } + } + + return nil +} + +// maxCertFilenameLength bounds a certificate file name. +const maxCertFilenameLength = 255 + +// ValidateCertFilename accepts the name of a file in the certificate directory. +// +// The console stores uploaded certificates under a generated name, so a value +// that is not a plain file name is either a mistake or an attempt to inject +// nginx directives (through the rendered ssl_certificate directives) or to +// address a file outside the certificate directory. +func ValidateCertFilename(name string) error { + // An empty name means "this site is served over plain HTTP". + if name == "" { + return nil + } + + if len(name) > maxCertFilenameLength { + return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength) + } + + if name != filepath.Base(name) || name == "." || name == ".." { + return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name) + } + + if !certFilenamePattern.MatchString(name) { + return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name) + } + + return nil +} + +// ValidatePort accepts the decimal notation of a TCP port. +func ValidatePort(port string) error { + if !portPattern.MatchString(port) { + return fmt.Errorf("invalid port %q: only digits are allowed", port) + } + + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > maxPort { + return fmt.Errorf("invalid port %q: must be between 1 and %d", port, maxPort) + } + + return nil +} + +// ValidateServerName accepts a literal host name, optionally with a wildcard +// as its first label, or "*" for the default site. +func ValidateServerName(name string) error { + // "*" (and the empty string, which the console sends for the same purpose) + // makes the site the default server and is rendered as "_". + if name == "*" || name == "" { + return nil + } + + if len(name) > maxServerNameLength { + return fmt.Errorf("invalid server name %q: longer than %d characters", name, maxServerNameLength) + } + + for i, label := range strings.Split(name, ".") { + if label == "*" { + // nginx only accepts a wildcard as the left most label. + if i != 0 { + return fmt.Errorf("invalid server name %q: wildcard must be the first label", name) + } + continue + } + + if len(label) > maxLabelLength || !serverNameLabelPattern.MatchString(label) { + return fmt.Errorf("invalid server name %q: %q is not a valid host name label", name, label) + } + } + + return nil +} + +// unmarshalStringList decodes one of the JSON list columns of a website. +func unmarshalStringList(raw datatypes.JSON) ([]string, error) { + if len(raw) == 0 { + return nil, nil + } + + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + + return values, nil +} diff --git a/management/webserver/pkg/constants/option.go b/management/webserver/pkg/constants/option.go index e959897c..876d9bdf 100644 --- a/management/webserver/pkg/constants/option.go +++ b/management/webserver/pkg/constants/option.go @@ -5,4 +5,11 @@ const ( MachineID = "machine_id" PolicyGroupGlobal = "policy_group_global" SrcIPConfig = "src_ip_config" + + // BootstrapOpenedAt records when the anonymous TFA bootstrap of the + // account was opened, which is what bounds how long a caller may claim the + // TFA secret without a credential (see api.GetOTPUrl). It is written when + // the installation is set up and by `mgt -reset_user`, and never by a + // restart. + BootstrapOpenedAt = "tfa_bootstrap_opened_at" ) diff --git a/management/webserver/pkg/cron/update_policy.go b/management/webserver/pkg/cron/update_policy.go index ab5b6f86..7de3c447 100644 --- a/management/webserver/pkg/cron/update_policy.go +++ b/management/webserver/pkg/cron/update_policy.go @@ -71,10 +71,19 @@ func CheckAndUpdatePolicy() { statRspData := statResponseBody{} statRsp, err := client.Do(statReq) - if err != nil || statRsp.StatusCode != http.StatusOK { + if err != nil { logger.Warn(err) return } + // The version check below returns early on every branch that is not "an + // update is needed", so the body has to be released on the way out instead + // of only on the path that continues. + defer statRsp.Body.Close() + + if statRsp.StatusCode != http.StatusOK { + logger.Warnf("%s get policy version, return %d", addr, statRsp.StatusCode) + return + } body, err := ioutil.ReadAll(statRsp.Body) if err != nil { @@ -92,23 +101,15 @@ func CheckAndUpdatePolicy() { return } - err = statRsp.Body.Close() - if err != nil { - logger.Warn(err) - } - logger.Info("Update fsl bytecode") updateRsp, err := client.Do(updateReq) if err != nil { logger.Warn(err) return } + defer updateRsp.Body.Close() if updateRsp.StatusCode != http.StatusOK { logger.Warnf("%s update policy, return %d", addr, updateRsp.StatusCode) } - err = updateRsp.Body.Close() - if err != nil { - logger.Warn(err) - } } diff --git a/management/webserver/pkg/fvm/fsl/quote.go b/management/webserver/pkg/fvm/fsl/quote.go index fdfea494..d4ed9079 100644 --- a/management/webserver/pkg/fvm/fsl/quote.go +++ b/management/webserver/pkg/fvm/fsl/quote.go @@ -28,7 +28,11 @@ func appendEscapedByte(buf []byte, b byte) []byte { case '\\': return append(buf, `\\`...) default: - return append(buf, fmt.Sprintf("\\x%x", b)...) + // The escape has to be two digits wide: "\x5" followed by a literal + // "a" is read by the lexer as "\x5a" ("Z"), so a rule whose comment or + // header value ends with a control byte would silently change the + // source that is handed to the compiler. + return append(buf, fmt.Sprintf("\\x%02x", b)...) } } diff --git a/management/webserver/pkg/fvm/fvm.go b/management/webserver/pkg/fvm/fvm.go index 32a34390..b9dcf7ef 100644 --- a/management/webserver/pkg/fvm/fvm.go +++ b/management/webserver/pkg/fvm/fvm.go @@ -19,10 +19,6 @@ import ( "C" //nolint:typecheck ) import ( - "crypto/tls" - "fmt" - "io" - "net/http" "net/url" "unsafe" @@ -60,11 +56,24 @@ func maybePointer(array []byte) unsafe.Pointer { } func (u *FVMUpdate) ToBytes() []byte { + if u == nil || u.update == nil { + return nil + } return C.GoBytes(unsafe.Pointer(u.update.buf.ptr), C.int(u.update.buf.length)) } +// FromBytes builds an update from the serialized byte code, as it is stored in +// the database. +// +// An empty buffer is rejected before the address of its first byte is taken: +// &out[0] panics on a zero length slice, and fvm_update_create would have to +// read the buffer that the length describes. func (u *FVMUpdate) FromBytes(out []byte) error { - ptr := C.build_update(unsafe.Pointer(&out[0]), C.ulong(len(out))) + if len(out) == 0 { + return errors.New("refuse to create an update from an empty buffer") + } + + ptr := C.build_update(maybePointer(out), C.ulong(len(out))) if ptr == nil { return errors.New("failed to create update from db") } @@ -83,7 +92,11 @@ func (u *FVMUpdate) MergeUpdate(patch *FVMUpdate) error { } func (u *FVMUpdate) Release() { + if u == nil || u.update == nil { + return + } C.fvm_update_destroy(u.update) + u.update = nil } func (r *FVMRe) ToBytes() []byte { @@ -183,85 +196,28 @@ func ReleaseOutput(out *FVMOutput) { } func (f *FVM) PushFsl(server string, update *FVMUpdate) error { + if update == nil || update.update == nil { + return errors.New("refuse to push a nil policy update") + } log.Infof("Push FSL to %s", server) var length = uint32(update.update.buf.length) - var p = uintptr(unsafe.Pointer(update.update.buf.ptr)) + ptr := unsafe.Pointer(update.update.buf.ptr) log.Infof("Get update length %d, %d", length, C.int(update.update.buf.length)) - u := &upload{ - Length: length, - P: p, - } + // Copy the compiled policy out of the C buffer. The C memory can be + // released while the request is in flight, and the Go side needs a value + // it can replay for every attempt. + policy := C.GoBytes(ptr, C.int(length)) base, _ := url.Parse(server) base.Path = "/update/policy" snURL := base.String() - req, err := http.NewRequest( - "POST", - snURL, - u, - ) - if err != nil { - return errors.Annotatef(err, "create request failed") - } - req.Header.Add("Content-Type", "application/octet-stream") - // req.Header.Set("Connection", "close") - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - var ( - respErr error - resp *http.Response - ) - for i := 0; i < 3; i++ { - resp, respErr = httpClient.Do(req) - if respErr == nil { - break - } - } - if respErr != nil { - return errors.Annotatef(respErr, "Get response failed") - } - if resp != nil && resp.StatusCode != 200 { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - bodyString := string(bodyBytes) - - return errors.New(fmt.Sprintf("Push FSL response(%d %s)", resp.StatusCode, bodyString)) - } - - return nil -} - -type upload struct { - Length uint32 - off uint32 - P uintptr -} - -func (u *upload) Read(p []byte) (n int, err error) { - if u.off >= u.Length { - if len(p) == 0 { - return 0, nil - } - return 0, io.EOF - } - plen := uint32(len(p)) - var i uint32 - for i = 0; i < plen; i++ { - if i+u.off >= u.Length { - break - } - p[i] = *(*byte)(unsafe.Pointer(u.P + unsafe.Sizeof(p[0])*uintptr(i+u.off))) - } - u.off = i + u.off - return int(i), nil + // The compiled policy is pushed to the detector over the container network + // and replaces the bytecode the engine executes, so the certificate of an + // HTTPS detector must be verified: a man in the middle could otherwise + // replace the policy and silently disable the WAF. + return pushPolicy(snURL, policy) } diff --git a/management/webserver/pkg/fvm/generator.go b/management/webserver/pkg/fvm/generator.go index e638333f..8f946fd3 100644 --- a/management/webserver/pkg/fvm/generator.go +++ b/management/webserver/pkg/fvm/generator.go @@ -205,6 +205,13 @@ func PolicyRuleTable(db *gorm.DB) (prFSL string, err error) { } } + // An empty pattern list would compile to a selector with no WHERE, which + // matches every request. Skip it instead of emitting an unconditional + // DROP or ACCEPT. + if len(wheres) == 0 { + continue + } + // save log only when deny and dry_run logOption := "1" attackType := "-3" diff --git a/management/webserver/pkg/fvm/helper.go b/management/webserver/pkg/fvm/helper.go index 9aba93bc..e3f6e9c0 100644 --- a/management/webserver/pkg/fvm/helper.go +++ b/management/webserver/pkg/fvm/helper.go @@ -24,10 +24,15 @@ func CompileAndSave(text string) error { defer ReleaseOutput(output) update := Serialize(output, false, 0, 1) + if update == nil { + return errors.New("failed to serialize rules") + } defer update.Release() - // save to file - if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0666); err != nil { + // save to file. The bytecode is not secret, but nothing but the management + // process has a reason to write it, so it is not created group or world + // writable either. + if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0644); err != nil { return errors.Annotate(err, "failed to save bytecode") } @@ -42,10 +47,15 @@ func CompileAndPush(text, serverAddr string) error { defer ReleaseOutput(output) update := Serialize(output, false, 0, 1) + if update == nil { + return errors.New("failed to serialize rules") + } defer update.Release() - // save to file - if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0666); err != nil { + // save to file. The bytecode is not secret, but nothing but the management + // process has a reason to write it, so it is not created group or world + // writable either. + if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0644); err != nil { return errors.Annotate(err, "failed to save bytecode") } diff --git a/management/webserver/pkg/fvm/push.go b/management/webserver/pkg/fvm/push.go new file mode 100644 index 00000000..0f381021 --- /dev/null +++ b/management/webserver/pkg/fvm/push.go @@ -0,0 +1,63 @@ +package fvm + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "chaitin.cn/dev/go/errors" + "chaitin.cn/dev/go/log" +) + +const ( + // pushPolicyAttempts is how often a failed push is retried. + pushPolicyAttempts = 3 + + // maxPushPolicyResponseBody bounds the part of an error response that is + // kept for the message. + maxPushPolicyResponseBody = 4 << 10 +) + +// pushPolicy sends a compiled policy to the detector. +// +// The body of an http.Request is a stream that can be read once, so a retry +// that reuses a request sends an empty body. The detector accepts an empty +// update with 200, which would leave the previous policy in place while the +// caller believes the new one was applied: every attempt therefore builds its +// own request around the same bytes. +func pushPolicy(snURL string, policy []byte) error { + client := &http.Client{} + + var lastErr error + for attempt := 0; attempt < pushPolicyAttempts; attempt++ { + req, err := http.NewRequest(http.MethodPost, snURL, bytes.NewReader(policy)) + if err != nil { + return errors.Annotatef(err, "create request failed") + } + req.Header.Add("Content-Type", "application/octet-stream") + req.ContentLength = int64(len(policy)) + + resp, err := client.Do(req) + if err != nil { + lastErr = err + continue + } + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxPushPolicyResponseBody)) + if closeErr := resp.Body.Close(); closeErr != nil { + log.Warnf("Close push FSL response failed: %s", closeErr) + } + if readErr != nil { + log.Warnf("Read push FSL response failed: %s", readErr) + } + + if resp.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("Push FSL response(%d %s)", resp.StatusCode, string(body))) + } + + return nil + } + + return errors.Annotatef(lastErr, "Get response failed") +} diff --git a/management/webserver/pkg/log/log.go b/management/webserver/pkg/log/log.go index 3af9a18b..b6e3ad70 100644 --- a/management/webserver/pkg/log/log.go +++ b/management/webserver/pkg/log/log.go @@ -5,6 +5,7 @@ import ( "os" "runtime" "strings" + "syscall" "github.com/sirupsen/logrus" @@ -43,7 +44,9 @@ func InitLogger() error { return err } - fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC + // O_NOFOLLOW closes the window between Stat and OpenFile: a symlink swapped + // in for the log path is refused instead of followed. + fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC | syscall.O_NOFOLLOW if !exist { if err := utils.EnsureFileDir(config.GlobalConfig.Log.Output); err != nil { return err @@ -51,11 +54,17 @@ func InitLogger() error { fileFlag = fileFlag | os.O_CREATE } - if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil { + // os.ModePerm (0777) let every local user rewrite the audit log. The + // file is created with an owner/group readable mode instead. + if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil { return fmt.Errorf("failed to open log file: %s", err.Error()) } else { log.SetOutput(log.AllLoggers, log.NewLockOutput(fp)) } + + // The mode above only applies when the file is created, so a log file + // that an older release left world writable is tightened here. + tightenLogFileMode(config.GlobalConfig.Log.Output) } // hook @@ -67,6 +76,28 @@ func InitLogger() error { return nil } +// logFileMode is the permission the log file is created with. Logs are audit +// material: they must not be writable by other local users. +const logFileMode os.FileMode = 0640 + +// looseLogFileMask matches the group and world write bits. +const looseLogFileMask os.FileMode = 0o022 + +// tightenLogFileMode removes the group and world write bits from an existing +// log file, so that a log file created by an older release cannot stay +// tamperable by other local users. +func tightenLogFileMode(path string) { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm()&looseLogFileMask == 0 { + return + } + + log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode) + if err := os.Chmod(path, logFileMode); err != nil { + log.Warnf("Cannot tighten the mode of log file %s: %s", path, err) + } +} + type RuntimeHook struct{} func (h *RuntimeHook) Levels() []logrus.Level { diff --git a/management/webserver/pkg/sessionopts/sessionopts.go b/management/webserver/pkg/sessionopts/sessionopts.go new file mode 100644 index 00000000..24f74f20 --- /dev/null +++ b/management/webserver/pkg/sessionopts/sessionopts.go @@ -0,0 +1,64 @@ +// Package sessionopts holds the cookie attributes of the management console +// session. The login handler and the authentication middleware share it so the +// flags cannot drift apart from each other. +package sessionopts + +import ( + "net/http" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" +) + +// maxAge is the lifetime of a management console session: one week. +const maxAge = 3600 * 24 * 7 + +// Options returns the cookie attributes used for the management console +// session. +// +// HttpOnly keeps the session cookie out of reach of JavaScript, SameSite=Lax +// prevents browsers from attaching the cookie to cross-site POST/PUT (which +// would otherwise turn the state-changing API into CSRF targets), and Secure +// keeps the cookie on HTTPS outside of a development setup. +// +// There is no separate CSRF header by design: the console is a cookie-session +// SPA, and Lax already drops the cookie on cross-site mutating requests. +// Adding a token would require a frontend change; do not treat the missing +// middleware as an accidental gap. +func Options(c *gin.Context) sessions.Options { + return sessions.Options{ + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: secureCookie(c), + SameSite: http.SameSiteLaxMode, + } +} + +// secureCookie reports whether the session cookie may be restricted to HTTPS. +// +// The console is only reachable over TLS in production, but the webserver +// itself sits behind a TLS terminating nginx, so a plain HTTP request in front +// of it can still be a legitimate request of an HTTPS session. +func secureCookie(c *gin.Context) bool { + if !config.GlobalConfig.Server.DevMode { + return true + } + + if c.Request.TLS != nil { + return true + } + + // Honour the scheme reported by a TLS terminating proxy, which is what the + // development setup is expected to use when it wants to exercise the + // production cookie attributes. + proto := c.Request.Header.Get("X-Forwarded-Proto") + if i := strings.IndexByte(proto, ','); i >= 0 { + proto = proto[:i] + } + + return strings.EqualFold(strings.TrimSpace(proto), "https") +} diff --git a/management/webserver/rpc/auth.go b/management/webserver/rpc/auth.go new file mode 100644 index 00000000..fc7163a1 --- /dev/null +++ b/management/webserver/rpc/auth.go @@ -0,0 +1,176 @@ +package rpc + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +const ( + // TokenEnv holds the shared secret of the control channel between + // tcontrollerd and this server. + TokenEnv = "MGT_GRPC_TOKEN" + + // TokenFileEnv names the file the secret is read from when TokenEnv is + // empty. + TokenFileEnv = "MGT_GRPC_TOKEN_FILE" + + // DefaultTokenFile is where the secret is read from (and created) by + // default. The standard deployment mounts the same host directory at + // /app/sock in the management container and in the tengine container, + // which is where tcontrollerd runs, so both sides find the same value + // without any configuration. + DefaultTokenFile = "/app/sock/mgt_grpc_token" + + // tokenMetadataKey is the gRPC metadata key that carries the secret. gRPC + // metadata keys have to be lower case. + tokenMetadataKey = "mgt-token" + + // tokenBytes is the size of a generated secret. + tokenBytes = 32 +) + +// tokenFileMode keeps the secret readable for the owner only. +const tokenFileMode os.FileMode = 0600 + +// controlToken is the secret that Subscribe requires. It is empty when the +// server could not obtain one, in which case every subscription is refused. +var controlToken string + +// InitControlToken loads the token of the control channel, creating it when it +// does not exist yet. +// +// A failure here does not stop the server: it disables the control channel, +// which is reported by Publish as an error, and leaves the console usable so +// that an operator can fix the deployment. +func InitControlToken() error { + if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" { + controlToken = token + return nil + } + + token, err := loadOrCreateToken(TokenFileFromEnv()) + if err != nil { + return err + } + + controlToken = token + return nil +} + +// TokenFileFromEnv returns the path of the token file. +func TokenFileFromEnv() string { + if path := strings.TrimSpace(os.Getenv(TokenFileEnv)); path != "" { + return path + } + + return DefaultTokenFile +} + +// loadOrCreateToken reads the control channel token from path, creating it with +// a fresh random value when the file does not exist yet. +func loadOrCreateToken(path string) (string, error) { + if token := readToken(path); token != "" { + return token, nil + } + + token := make([]byte, tokenBytes) + if _, err := rand.Read(token); err != nil { + return "", err + } + value := hex.EncodeToString(token) + + // 0750: the directory holds the secret of the control channel, so it is not + // left traversable by other users. It is a shared mount point whose mode is + // normally set by the deployment, so this only applies when this call is the + // one that creates it. + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return "", err + } + + // O_EXCL: another process may have created the file between the read above + // and this call, in which case its value wins. + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, tokenFileMode) + if err != nil { + if os.IsExist(err) { + return readToken(path), nil + } + return "", err + } + + if _, err = file.WriteString(value + "\n"); err != nil { + _ = file.Close() + return "", err + } + + if err = file.Close(); err != nil { + return "", err + } + + return value, nil +} + +// readToken returns the token stored in path, or an empty string when it is not +// available. +func readToken(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + + return strings.TrimSpace(string(content)) +} + +// subscribeAuthInterceptor refuses a Subscribe call that does not prove that it +// knows the control channel token. +// +// Without it, any client that can reach the gRPC port becomes the subscriber: +// it receives the full site configuration of the installation and silently +// takes the place of tcontrollerd, which then stops receiving its updates. +func subscribeAuthInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if controlToken == "" { + logger.Error("The control channel token is not configured, refusing subscriptions. Set " + TokenEnv + " or make " + TokenFileFromEnv() + " writable.") + return status.Error(codes.Unauthenticated, "the control channel token is not configured on the server") + } + + if subtle.ConstantTimeCompare([]byte(incomingToken(ss.Context())), []byte(controlToken)) != 1 { + logger.Warnf("Rejected a subscription from %s: invalid control channel token", peerAddress(ss.Context())) + return status.Error(codes.Unauthenticated, "a valid control channel token is required") + } + + return handler(srv, ss) +} + +// incomingToken returns the token that the client sent in the metadata. +func incomingToken(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + + values := md.Get(tokenMetadataKey) + if len(values) == 0 { + return "" + } + + return values[0] +} + +// peerAddress returns the address of the client for the log. +func peerAddress(ctx context.Context) string { + if p, ok := peer.FromContext(ctx); ok && p.Addr != nil { + return p.Addr.String() + } + + return "unknown" +} diff --git a/management/webserver/rpc/main.go b/management/webserver/rpc/main.go index 3b5ae8c9..dc65e55a 100644 --- a/management/webserver/rpc/main.go +++ b/management/webserver/rpc/main.go @@ -26,12 +26,43 @@ const ( var logger = log.GetLogger("grpc") +// ServerOptions returns the options the control channel is served with. +// +// The channel carries the complete site configuration of the installation and +// republishes it on every change, so a subscription has to prove that it is +// tcontrollerd: the client certificate proves that the caller holds a +// credential of this installation, and the token is the second gate, which is +// what keeps a caller that reaches the port without one out. +func ServerOptions() ([]grpc.ServerOption, error) { + opts := []grpc.ServerOption{ + grpc.StreamInterceptor(subscribeAuthInterceptor), + } + + creds, err := ServerCreds() + if err != nil { + return nil, err + } + opts = append(opts, creds) + return opts, nil +} + func StartGRPCSever() error { lis, err := net.Listen("tcp", config.GlobalConfig.GPRC.ListenAddr) if err != nil { return errors.Wrap(err, "Failed to listen") } - var opts []grpc.ServerOption + + if err := InitControlToken(); err != nil { + // Not fatal: the console keeps working, and every subscription is + // refused with an explanation until the token is available. + logger.Errorf("Failed to init the control channel token: %s", err) + } + + opts, err := ServerOptions() + if err != nil { + return err + } + grpcServer := grpc.NewServer(opts...) pb.RegisterWebsiteServer(grpcServer, GetWebsiteServer()) go func() { diff --git a/management/webserver/rpc/tls.go b/management/webserver/rpc/tls.go new file mode 100644 index 00000000..ab4b1d80 --- /dev/null +++ b/management/webserver/rpc/tls.go @@ -0,0 +1,139 @@ +package rpc + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + "chaitin.cn/dev/go/errors" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config" + "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants" +) + +const ( + // TLSEnv names the environment variable that used to allow a plaintext + // control channel. Plaintext is no longer supported: a value of "0" / + // off / false / no is an error. Unset it and run `mgt -gen_certs`. + TLSEnv = "MGT_GRPC_TLS" + + // CertDirEnv names the environment variable that holds the directory of + // the server credentials. + CertDirEnv = "MGT_GRPC_CERT_DIR" + + // ClientCertDirEnv names the environment variable that holds the directory + // the credentials of tcontrollerd are published in. + ClientCertDirEnv = "MGT_GRPC_CLIENT_CERT_DIR" + + // ServerCertFile, ServerKeyFile and CertAuthorityFile are the names of the + // server credentials inside CertDir(). + ServerCertFile = "grpc_server.crt" + ServerKeyFile = "grpc_server.key" + CertAuthorityFile = "grpc_ca.crt" + + // ClientCertFile and ClientKeyFile are the names of the credentials of + // tcontrollerd inside ClientCertDir(). + ClientCertFile = "tcd_client.crt" + ClientKeyFile = "tcd_client.key" + + // ServerName is the name the server certificate is issued for. + // + // tcontrollerd reaches the management server at an address of the + // deployment (169.254.0.4:9002 in the standard compose file), which no + // certificate can be issued for. The name is therefore fixed on both sides + // and checked against the certificate, which is what makes the peer + // verifiable: the address may change, the name may not. + ServerName = "waf-management-server" +) + +// CertDir returns the directory the server credentials of the control channel +// are stored in. +func CertDir() string { + if dir := strings.TrimSpace(os.Getenv(CertDirEnv)); dir != "" { + return dir + } + + return filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath) +} + +// ClientCertDir returns the directory the credentials of tcontrollerd are +// published in. +// +// It is a directory below the one both containers mount, because the tengine +// container is where tcontrollerd runs and it does not mount the resources +// directory of the management container. +func ClientCertDir() string { + if dir := strings.TrimSpace(os.Getenv(ClientCertDirEnv)); dir != "" { + return dir + } + + return filepath.Join(filepath.Dir(TokenFileFromEnv()), "grpc") +} + +// ServerCreds returns the transport credentials of the control channel. +// +// The channel is always served over TLS. Missing credentials or an explicit +// request for plaintext are errors: the subscription token would otherwise +// travel on the same network as the containers. +func ServerCreds() (grpc.ServerOption, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) { + case "0", "off", "false", "no": + return nil, errors.New("plaintext is not supported: unset " + TLSEnv + " and run mgt -gen_certs") + } + + creds, err := loadServerCreds() + if err != nil { + return nil, err + } + + logger.Infof("The control channel is served over TLS and only accepts a client certificate issued by %s", filepath.Join(CertDir(), CertAuthorityFile)) + + return grpc.Creds(creds), nil +} + +// loadServerCreds reads the server credentials and builds the TLS configuration +// of the control channel from them. +func loadServerCreds() (credentials.TransportCredentials, error) { + certPath := filepath.Join(CertDir(), ServerCertFile) + keyPath := filepath.Join(CertDir(), ServerKeyFile) + + certificate, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, errors.Wrapf(err, "load %s", certPath) + } + + clientCAs, err := certPool(filepath.Join(CertDir(), CertAuthorityFile)) + if err != nil { + return nil, err + } + + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{certificate}, + // The subscription token already keeps a caller that only reaches the + // port out, but it travels over this connection: without a client + // certificate, anyone who can read the traffic of the deployment can + // replay it and subscribe as tcontrollerd. + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + MinVersion: tls.VersionTLS12, + }), nil +} + +// certPool reads a PEM encoded certificate authority. +func certPool(path string) (*x509.CertPool, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrapf(err, "read %s", path) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(content) { + return nil, errors.New("no certificate found in " + path) + } + + return pool, nil +} diff --git a/management/webserver/rpc/website.go b/management/webserver/rpc/website.go index 08012923..932db6be 100644 --- a/management/webserver/rpc/website.go +++ b/management/webserver/rpc/website.go @@ -3,6 +3,7 @@ package rpc import ( "encoding/json" "io" + "sync" "time" "chaitin.cn/dev/go/errors" @@ -12,25 +13,49 @@ import ( ) var ( - ping = pb.Event{Type: Ping, Msg: nil} - Subscriber *StreamClient // only ONE sub + ping = pb.Event{Type: Ping, Msg: nil} + + // subscriberMu guards subscriber, which is written by the goroutine that + // serves the Subscribe stream and read by every request handler that + // publishes a configuration. + subscriberMu sync.RWMutex + subscriber *StreamClient // only ONE sub ) func Publish(msg []byte, eventType string) error { - if Subscriber == nil { + // The subscriber is read once and every use below goes through the local + // variable: a new stream may replace it at any moment, and re-reading the + // global pointer would let this call talk to a closing stream or to a + // different subscription than the one it started with. + sub := currentSubscriber() + if sub == nil { return errors.New("Service is abnormal, and the nginx conf cannot be updated for the time being. Please go to the shell to check the relevant logs") } - err := Subscriber.stream.Send(&pb.Event{ + + // Only one exchange may be outstanding on the stream. The response carries + // no request id, so two overlapping callers that share the single slot below + // would each read the answer of the other: one would commit a database + // change that tcontrollerd refused, the other would roll back one it + // applied. The generated client does not allow concurrent sends on one + // stream either. + sub.exchangeMu.Lock() + defer sub.exchangeMu.Unlock() + + // A response whose own request already gave up is still in the slot, and + // this call would read it as its own answer. + discardPendingResponses(sub) + + err := sub.stream.Send(&pb.Event{ Type: eventType, Msg: msg, }) if err != nil { select { - case <-Subscriber.stream.Context().Done(): - err = Subscriber.stream.Context().Err() // context canceled - Subscriber = nil + case <-sub.stream.Context().Done(): + err = sub.stream.Context().Err() // context canceled + clearSubscriber(sub) return err - case Subscriber.errCh <- errors.Wrapf(err, "Send event err: %s", msg): + case sub.errCh <- errors.Wrapf(err, "Send event err: %s", msg): return errors.Wrapf(err, "Send event err: %s", msg) } } @@ -40,7 +65,7 @@ func Publish(msg []byte, eventType string) error { for { select { - case rsp := <-Subscriber.rspCh: + case rsp := <-sub.rspCh: if rsp.Err { return errors.New(string(rsp.Msg)) } else { @@ -48,11 +73,37 @@ func Publish(msg []byte, eventType string) error { return nil } case <-rspTimeoutTicker.C: + // The device did not answer, so what it applied is unknown: an + // answer that arrives later would be read by the next request as + // if it belonged to it. The stream is dropped instead, which + // leaves the slot this call abandons unreachable and makes + // tcontrollerd reconnect and synchronise with a full push. + clearSubscriber(sub) + select { + case sub.errCh <- errors.New("Wait timeout for updating result"): + default: + } + return errors.New("Wait timeout for updating result") } } } +// discardPendingResponses drops the responses that are sitting in the slot. +// +// It is called while the exchange of the caller is held, so nothing in the slot +// can belong to a request that is still waiting. +func discardPendingResponses(sc *StreamClient) { + for { + select { + case rsp := <-sc.rspCh: + logger.Warnf("Discarding an unclaimed control channel response of type %s", rsp.GetType()) + default: + return + } + } +} + // StreamClient is the instance for every client stream connected type StreamClient struct { stream pb.Website_SubscribeServer @@ -60,6 +111,48 @@ type StreamClient struct { errCh chan error rspCh chan *pb.Response quit chan struct{} // quit stream client gracefully + + // exchangeMu serialises the send-and-wait of Publish. The response of a + // request is not labelled with the request it answers, so at most one + // request may be in flight at a time. + exchangeMu sync.Mutex +} + +// currentSubscriber returns the active subscriber, or nil when no stream is +// connected. +func currentSubscriber() *StreamClient { + subscriberMu.RLock() + defer subscriberMu.RUnlock() + + return subscriber +} + +// setSubscriber makes sc the active subscriber. +func setSubscriber(sc *StreamClient) { + subscriberMu.Lock() + defer subscriberMu.Unlock() + + if subscriber != nil && subscriber != sc { + // A client that restarts reconnects while its previous stream may not + // have timed out yet, so a replacement is expected; it is logged + // because only a client that knows the control channel token can + // trigger it. + logger.Warn("A new subscriber replaces the previous one") + } + + subscriber = sc +} + +// clearSubscriber forgets the active subscriber, but only when it is still the +// one that asks: a stream that ends after it was replaced must not disconnect +// its successor. +func clearSubscriber(sc *StreamClient) { + subscriberMu.Lock() + defer subscriberMu.Unlock() + + if subscriber == sc { + subscriber = nil + } } func newStreamClient(stream pb.Website_SubscribeServer) *StreamClient { @@ -77,7 +170,7 @@ func (sc *StreamClient) pingLoop() { defer pingTicker.Stop() for { - if Subscriber != nil && sc != Subscriber { + if current := currentSubscriber(); current != nil && current != sc { // new subscriber in replace of the old one. logger.Debug("New subscriber in replace of the old one") return @@ -101,7 +194,7 @@ func (sc *StreamClient) pingLoop() { func (sc *StreamClient) recvLoop() { for { - if Subscriber != nil && sc != Subscriber { + if current := currentSubscriber(); current != nil && current != sc { // new subscriber in replace of the old one. close(sc.quit) logger.Debug("New subscriber in replace of the old one") @@ -123,7 +216,18 @@ func (sc *StreamClient) recvLoop() { if rsp.Type != Pong { // receive response logger.Infof("Recv updating website rsp: err(%t), msg(%s)", rsp.GetErr(), rsp.GetMsg()) - sc.rspCh <- rsp + + // This loop is also the goroutine that reads the keepalive pongs + // and resets the timer, so it must not block on a full slot: it + // would disconnect a stream whose peer is perfectly healthy. Only + // one exchange is outstanding at a time (see Publish) and it + // empties the slot before it sends, so a slot that is already full + // holds a response nobody is waiting for. + select { + case sc.rspCh <- rsp: + default: + logger.Warn("Discarding a control channel response that no request is waiting for") + } continue } sc.timer.Reset(KeepaliveTimeout) @@ -136,13 +240,32 @@ type WebsiteServer struct { } func GetWebsiteServer() *WebsiteServer { - return &WebsiteServer{} + // UnimplementedWebsiteServer is embedded through a pointer, and the code + // that registers a service checks that the pointer is not nil: the check + // is there because a nil pointer would only be noticed when a method that + // this server does not implement is called. It has to be set here, or the + // registration panics as soon as the generated code carries that check. + return &WebsiteServer{UnimplementedWebsiteServer: &pb.UnimplementedWebsiteServer{}} } func publishFullWebsite() error { var websites []model.Website db := database.GetDB().DB - db.Model(&model.Website{}).Find(&websites) + + // A failed query leaves the slice empty, and an empty list means "this + // installation has no sites" to tcontrollerd: it would delete every nginx + // configuration it manages. The error has to stop the push instead. + if err := db.Model(&model.Website{}).Find(&websites).Error; err != nil { + return errors.Wrap(err, "load websites") + } + + if websites == nil { + // A nil slice marshals to "null", which tcontrollerd reads as a + // malformed push. An installation that really has no site sends an + // empty list. + websites = []model.Website{} + } + byteWebsites, err := json.Marshal(&websites) if err != nil { return err @@ -153,11 +276,16 @@ func publishFullWebsite() error { // Subscribe is gRPC API entrypoint func (ws *WebsiteServer) Subscribe(stream pb.Website_SubscribeServer) error { - Subscriber = newStreamClient(stream) - defer Subscriber.timer.Stop() + sc := newStreamClient(stream) + setSubscriber(sc) + + defer sc.timer.Stop() + // A stream that ends without being replaced leaves the channel unusable + // until a new client connects, so the global pointer is released here. + defer clearSubscriber(sc) - go Subscriber.pingLoop() - go Subscriber.recvLoop() + go sc.pingLoop() + go sc.recvLoop() if err := publishFullWebsite(); err != nil { // triggered when tcd starts, ignore push error messages @@ -165,17 +293,16 @@ func (ws *WebsiteServer) Subscribe(stream pb.Website_SubscribeServer) error { } select { - case <-Subscriber.quit: + case <-sc.quit: logger.Infof("Disconnected gracefully") return nil - case <-Subscriber.timer.C: + case <-sc.timer.C: logger.Error("Keepalive timeout") - case err := <-Subscriber.errCh: + case err := <-sc.errCh: logger.WithError(err).Error() return err - case <-Subscriber.stream.Context().Done(): - logger.Infof("Subscribe context done: %s", Subscriber.stream.Context().Err()) - Subscriber = nil + case <-sc.stream.Context().Done(): + logger.Infof("Subscribe context done: %s", sc.stream.Context().Err()) } return nil diff --git a/management/webserver/utils/cert.go b/management/webserver/utils/cert.go index 816cd0c1..8b38be1b 100644 --- a/management/webserver/utils/cert.go +++ b/management/webserver/utils/cert.go @@ -9,7 +9,10 @@ import ( "encoding/pem" "io/ioutil" "math/big" + "os" "time" + + "chaitin.cn/dev/go/errors" ) func GenerateCert(hostnames []string, days int64, keyBits int, subject *pkix.Name, isCA bool) ([]byte, []byte, error) { @@ -79,7 +82,16 @@ func WriteCertIfNotExist(certFilePath, keyFilePath string, generator func() ([]b return err } - if err = EnsureRenameWriteFile(keyFilePath, key, 0644); err != nil { + // A certificate is public, its private key is not: 0644 lets every local + // user read the key and impersonate the service it belongs to. + if err = EnsureRenameWriteFile(keyFilePath, key, 0600); err != nil { + return err + } + + // The file may already have existed with the permissive mode that older + // releases gave it, in which case the mode passed to the write above is + // not applied by the kernel. + if err = os.Chmod(keyFilePath, 0600); err != nil { return err } @@ -107,3 +119,92 @@ func genCertIfNotExist(certFilePath, keyFilePath string, generator func() ([]byt return cert, key, nil } } + +// GenerateSignedCert issues a certificate for hostnames that is signed by the +// certificate authority that certPEM and keyPEM belong to. +// +// GenerateCert produces a self signed certificate, which is what the console +// uses: the browser is told to accept it. The control channel cannot work that +// way, because both ends have to recognise each other without trusting the +// network they talk over, so the management server issues the credentials of +// both ends from a CA that only this installation knows. +func GenerateSignedCert(certPEM, keyPEM []byte, hostnames []string, days int64, keyBits int, subject *pkix.Name, client bool) ([]byte, []byte, error) { + caCert, caKey, err := parseCertAuthority(certPEM, keyPEM) + if err != nil { + return nil, nil, err + } + + privateKey, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + return nil, nil, err + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, err + } + + extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + keyUsage := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + if client { + extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } + + notBefore := time.Now() + template := x509.Certificate{ + SerialNumber: serialNumber, + NotBefore: notBefore, + NotAfter: notBefore.Add(time.Duration(days) * 24 * time.Hour), + DNSNames: hostnames, + BasicConstraintsValid: true, + IsCA: false, + ExtKeyUsage: extKeyUsage, + KeyUsage: keyUsage, + } + if subject != nil { + template.Subject = *subject + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &privateKey.PublicKey, caKey) + if err != nil { + return nil, nil, err + } + + var certBuffer bytes.Buffer + if err = pem.Encode(&certBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, nil, err + } + + var keyBuffer bytes.Buffer + if err = pem.Encode(&keyBuffer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}); err != nil { + return nil, nil, err + } + + return certBuffer.Bytes(), keyBuffer.Bytes(), nil +} + +// parseCertAuthority reads the certificate and the private key of a CA. +func parseCertAuthority(certPEM, keyPEM []byte) (*x509.Certificate, *rsa.PrivateKey, error) { + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return nil, nil, errors.New("the certificate authority is not PEM encoded") + } + + caCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, nil, err + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, nil, errors.New("the private key of the certificate authority is not PEM encoded") + } + + caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, nil, err + } + + return caCert, caKey, nil +} diff --git a/management/webserver/utils/file.go b/management/webserver/utils/file.go index 4ae2d88c..0fde0d62 100644 --- a/management/webserver/utils/file.go +++ b/management/webserver/utils/file.go @@ -2,7 +2,6 @@ package utils import ( "fmt" - "io/ioutil" "os" "path/filepath" ) @@ -49,11 +48,41 @@ func FilesExist(paths ...string) (bool, error) { } func RenameWriteFile(filename string, data []byte, perm os.FileMode) error { - randFileName := filename + ".tmp." + RandStr(8) - if err := ioutil.WriteFile(randFileName, data, perm); err != nil { + // os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a + // file or symlink that was planted at the temporary path beforehand cannot + // be followed and the payload is never readable by other users before the + // rename. The previous implementation built a predictable name with + // math/rand and wrote through ioutil.WriteFile, which does both. + tmpFile, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp.") + if err != nil { + return err + } + tmpName := tmpFile.Name() + + renamed := false + defer func() { + if !renamed { + _ = os.Remove(tmpName) + } + }() + + if err = tmpFile.Chmod(perm); err != nil { + _ = tmpFile.Close() + return err + } + if _, err = tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + return err + } + if err = tmpFile.Close(); err != nil { return err } - return os.Rename(randFileName, filename) + if err = os.Rename(tmpName, filename); err != nil { + return err + } + + renamed = true + return nil } func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { @@ -65,11 +94,14 @@ func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error { return RenameWriteFile(path, data, mode) } +// EnsureWriteFile writes a file with the given mode, creating its parent +// directory when needed. +// +// It goes through RenameWriteFile, so the write is atomic and cannot be +// redirected through a symlink: ioutil.WriteFile, which this used to call, +// truncates whatever a link planted at the destination path points at. The +// detection engine byte code is written through here, and a reader either sees +// the complete old file or the complete new one instead of a torn mixture. func EnsureWriteFile(path string, data []byte, mode os.FileMode) error { - err := EnsureFileDir(path) - if err != nil { - return err - } - - return ioutil.WriteFile(path, data, mode) + return EnsureRenameWriteFile(path, data, mode) } diff --git a/management/webserver/utils/httpclient.go b/management/webserver/utils/httpclient.go index d3874d0a..89a4a9ae 100644 --- a/management/webserver/utils/httpclient.go +++ b/management/webserver/utils/httpclient.go @@ -1,25 +1,62 @@ package utils import ( - "crypto/tls" + "net" "net/http" "net/url" "os" + "sync" "time" ) const proxyName = "HTTPS_PROXY" -var httpClient *http.Client +const ( + // httpClientTimeout bounds one request end to end, from the connection + // attempt to the last byte of the body. + httpClientTimeout = 15 * time.Second + // httpClientDialTimeout bounds the connection attempt, and + // httpClientTLSHandshakeTimeout the handshake that follows it. + httpClientDialTimeout = 5 * time.Second + httpClientTLSHandshakeTimeout = 5 * time.Second + + // httpClientResponseHeaderTimeout bounds the wait for the response header + // once the request was written. + httpClientResponseHeaderTimeout = 10 * time.Second +) + +var ( + httpClientOnce sync.Once + httpClient *http.Client +) + +// GetHTTPClient returns the shared HTTP client. +// +// The client is built once: the API handlers and the cron jobs call this at the +// same time, and the unsynchronized lazy assignment used before let two of them +// build (and see) a client at the same time. +// +// Every phase of a request is bounded. The client is used by endpoints that any +// caller can reach, such as the upgrade tip lookup, and the endpoints it talks +// to are remote: an upstream that accepts the connection and then stops +// answering would otherwise hold the handler goroutine and its socket for as +// long as the connection lives, which a caller can repeat without limit. func GetHTTPClient() *http.Client { - if httpClient == nil { + httpClientOnce.Do(func() { + // Certificates are verified: this client talks to the telemetry + // endpoint and to the upgrade server, both of which are remote and can + // be spoofed by a man in the middle when verification is disabled. tr := &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 30 * time.Second, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, + DialContext: (&net.Dialer{ + Timeout: httpClientDialTimeout, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: httpClientTLSHandshakeTimeout, + ResponseHeaderTimeout: httpClientResponseHeaderTimeout, + ExpectContinueTimeout: 1 * time.Second, } proxyUrl, existed := os.LookupEnv(proxyName) @@ -28,8 +65,8 @@ func GetHTTPClient() *http.Client { tr.Proxy = http.ProxyURL(uri) } - httpClient = &http.Client{Transport: tr} - } + httpClient = &http.Client{Transport: tr, Timeout: httpClientTimeout} + }) return httpClient } diff --git a/management/webserver/utils/random.go b/management/webserver/utils/random.go index 44ebd98a..bd09f32c 100644 --- a/management/webserver/utils/random.go +++ b/management/webserver/utils/random.go @@ -1,18 +1,42 @@ package utils import ( - "math/rand" - "time" + "crypto/rand" + "encoding/hex" + "math/big" ) var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +// RandStr returns n letters drawn from a cryptographically secure source. +// +// The values produced here are used as session signing keys, temporary file +// names and event ids. math/rand is seeded from a predictable value (and its +// output can be reconstructed from a single sample), so it must not be used +// for any of them. func RandStr(n int) string { - rand.Seed(time.Now().UnixNano()) - b := make([]rune, n) + limit := big.NewInt(int64(len(letters))) for i := range b { - b[i] = letters[rand.Intn(len(letters))] + idx, err := rand.Int(rand.Reader, limit) + if err != nil { + // crypto/rand fails only when the system entropy source is + // unusable. Continuing with a predictable value is not an option, + // because callers use the result as a secret. + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + b[i] = letters[idx.Int64()] } return string(b) } + +// RandHex returns a hex string of 2*n characters read from crypto/rand. Every +// byte of the result carries eight bits of entropy, which makes it the right +// helper for keys and secrets. +func RandHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("utils: cannot read from the system entropy source: " + err.Error()) + } + return hex.EncodeToString(b) +} diff --git a/mcp_server/.dockerignore b/mcp_server/.dockerignore new file mode 100644 index 00000000..6960136b --- /dev/null +++ b/mcp_server/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +secrets/ +state/ +*.log +mcp-server +coverage.out diff --git a/mcp_server/.gitignore b/mcp_server/.gitignore new file mode 100644 index 00000000..770444c1 --- /dev/null +++ b/mcp_server/.gitignore @@ -0,0 +1,5 @@ +/secrets/ +/state/ +/mcp-server +*.log +coverage.out diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index 3d966342..3eaf0072 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,28 +1,29 @@ -FROM golang:1.24-alpine AS builder +FROM golang:1.25.14-alpine3.23 AS builder -WORKDIR /app +ARG GOPROXY + +WORKDIR /src RUN apk add --no-cache git COPY go.mod go.sum ./ - RUN go mod download COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mcp-server . -RUN CGO_ENABLED=0 go build -o mcp-server . - -FROM alpine:latest - -WORKDIR /app +FROM alpine:3.22 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata \ + && install -d -m 700 /var/lib/safeline-mcp \ + && install -d -m 755 /etc/safeline-mcp -COPY --from=builder /app/mcp-server . -COPY --from=builder /app/config.yaml . +COPY --from=builder /out/mcp-server /app/mcp-server +COPY config.yaml /etc/safeline-mcp/config.yaml ENV TZ=Asia/Shanghai EXPOSE 5678 -CMD ["./mcp-server"] \ No newline at end of file +ENTRYPOINT ["/app/mcp-server"] +CMD ["--config", "/etc/safeline-mcp/config.yaml"] diff --git a/mcp_server/README.md b/mcp_server/README.md index b88b1c4e..e2653865 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -1,256 +1,204 @@ # SafeLine MCP Server -SafeLine MCP Server is an implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) that provides complete management and control capabilities for SafeLine WAF. - -[![Docker](https://img.shields.io/badge/Docker-Supported-2496ED?style=flat-square&logo=docker&logoColor=white)](docker-compose.yml) -[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) - -## Use Cases - -- Automated management and control of SafeLine WAF instances -- WAF configuration and policy management through API -- Building AI-based security protection tools and applications - -## Prerequisites +SafeLine MCP Server provides read-only, multi-instance access to SafeLine CE through the Model Context Protocol. This release intentionally exposes one Tool and one SafeLine API: + +| MCP Tool | SafeLine request | Effect | +|---|---|---| +| `get_attack_events` | `GET /api/open/events` | Read only | + +The original site-creation, blacklist, and whitelist Tools have been removed. The downstream client has no generic write method and cannot issue SafeLine `POST`, `PUT`, `PATCH`, or `DELETE` requests. + +## Protocol + +- MCP SDK: `mcp-go v1.0.0-beta.1` +- Go: `1.25.14` or newer +- Transport: Streamable HTTP +- Endpoint: `POST /mcp` +- Latest supported MCP revision: `2026-07-28` +- Removed legacy routes: `/sse` and `/message` + +The server supports the 2026-07-28 stateless protocol and retains Streamable HTTP compatibility with older MCP clients on the same `/mcp` endpoint. + +## Configuration + +Instances are deployment configuration, not MCP Tools. Each Tool call must specify an `instance_id`; the server never guesses an instance or reuses another instance's credential. + +```yaml +server: + name: "SafeLine MCP Server" + version: "2.0.0" + host: "127.0.0.1" + port: 5678 + +logger: + level: "info" + file_path: "" + console: true + caller: false + development: false + +instances: + - id: "production-a" + display_name: "Production A" + base_url: "https://10.0.0.10:9443" + token_file: "/run/secrets/production-a.token" + timeout: 30 + debug: false + ca_file: "" + + - id: "production-b" + display_name: "Production B" + base_url: "https://10.0.0.11:9443" + token_file: "/run/secrets/production-b.token" + timeout: 30 + debug: false + ca_file: "" +``` -1. Install [Docker](https://www.docker.com/) (if running in container) -2. Configure SafeLine API Token (obtained from SafeLine console) +`LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file. -## Features +`ca_file` is the certificate authority that signed the certificate of the +instance. A SafeLine console serves the certificate it generated for itself, +which no public authority signed: point `ca_file` at that certificate (or at the +authority that issued it) and the connection stays verified. Relative paths are +resolved from the directory of the configuration file. -- Complete MCP (Management Control Protocol) server implementation -- Support for SafeLine WAF instance management and control -- Flexible configuration system supporting file configuration and environment variables -- Docker containerization support -- Secure API communication +`insecure_skip_verify` is rejected at startup. Use `ca_file` for a self-signed +instance certificate. `base_url` must be `https`, or `http` only when the host +is loopback (`127.0.0.1`, `localhost`, `::1`). -## Quick Start +Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions. -### Environment Variables +SafeLine CE does not yet provide a dedicated read-only service credential for all required management APIs. This release therefore uses an administrator API Token per instance, while the MCP process enforces the fixed read-only API allowlist. -| Environment Variable | Description | Default Value | Required | -|---------|------|--------|-----| -| LISTEN_PORT | Service listening port | 5678 | No | -| LISTEN_ADDRESS | Service listening address | 0.0.0.0 | No | -| SAFELINE_SECRET | SSE server secret | - | No | -| SAFELINE_ADDRESS | SafeLine API address | - | Yes | -| SAFELINE_API_TOKEN | SafeLine API authentication token | - | Yes | +## AI client authentication -### Using Docker +MCP client authentication is independent from the downstream SafeLine API Tokens. -#### Method 1: Using docker run +Authentication is on by default: the server always installs the bearer gate +unless a deployment explicitly opts out, and it refuses to start without a token +on any non-loopback listener. -```bash -docker run -d \ - --name safeline-mcp \ - -p 5678:5678 \ - -e SAFELINE_API_TOKEN="your_api_token" \ - -e SAFELINE_ADDRESS="https://your.safeline.com" \ - -e LISTEN_PORT=5678 \ - -e LISTEN_ADDRESS="0.0.0.0" \ - chaitin/safeline-mcp:latest -``` +### Initialization -#### Method 2: Using docker-compose +Generate the MCP client Bearer Token once during deployment initialization: ```bash -# 1. Clone repository -git clone https://github.com/chaitin/safeline-mcp.git -cd safeline-mcp - -# 2. Edit docker-compose.yml to configure environment variables -# Example docker-compose.yml: -# version: '3' -# services: -# mcp: -# image: chaitin/safeline-mcp:latest -# container_name: safeline-mcp -# ports: -# - "5678:5678" -# environment: -# - SAFELINE_API_TOKEN=your_api_token -# - SAFELINE_ADDRESS=https://your.safeline.com -# - LISTEN_PORT=5678 -# - LISTEN_ADDRESS=0.0.0.0 - -# 3. Start service -docker compose -f docker-compose.yml up -d +docker compose run --rm mcp_server auth init ``` -#### Method 3: Using Go +The command uses `crypto/rand` to generate a 256-bit token. It displays the plaintext once and stores only its SHA-256 hash and creation time in `state/auth.json` with mode `0600`. A normal service restart only reads that state and never changes or prints the token. -```bash -# 1. Clone repository -git clone https://github.com/chaitin/SafeLine.git -cd safeline-mcp +Clients send the generated token on every MCP request: -# 2. Install dependencies -go mod download - -# 3. Configure config.yaml -cp config.yaml.example config.yaml -# Edit config.yaml with necessary configurations - -# 4. Run service -go run main.go +```http +Authorization: Bearer slmcp_... ``` -For more API details, please refer to the [API Documentation](https://demo.waf.chaitin.com:9443/swagger/index.html). - -## Tools - -### Application Management - -- **create_application** - -### Rule Management -- **create_blacklist_rule** -- **create_whitelist_rule** - -### Analyze -- **get_attack_events** +Loopback (`127.0.0.1` / `localhost` / `::1`) may listen on plain HTTP. +A non-loopback bind, including Docker `LISTEN_ADDRESS=0.0.0.0`, requires +`MCP_TLS_CERT` and `MCP_TLS_KEY` (or `tls_cert_file` / `tls_key_file`). +The process refuses to start without them. -## Development Guide +The server refuses to start while this state is missing, so a deployment cannot +serve any request before the token exists. Explicit rotation is available +locally: -The Go API in this project is currently under development, and APIs may change. If you have specific requirements, please submit an Issue for discussion. - -### Directory Structure - -``` -internal/ -├── api/ # API implementation -│ ├── app/ # Application-related APIs -│ │ └── create_application.go -│ └── rule/ # Rule-related APIs -│ └── create_rule.go -└── tools/ # MCP tool implementation - ├── app/ # Application-related tools - │ └── create_application.go - └── rule/ # Rule-related tools - └── create_rule.go +```bash +docker compose run --rm mcp_server auth rotate ``` -### Adding New Tools - -1. **Create Tool File** - - Create corresponding directory and file under `internal/tools` - - File name should match tool name - - Use separate file for each tool - - Example: `internal/tools/app/create_application.go` - -2. **Tool Implementation Template** -```go -package app - -type ToolName struct{} - -type ToolParams struct { - // Parameter definitions - Param1 string `json:"param1" desc:"parameter description" required:"true"` - Param2 int `json:"param2" desc:"parameter description" required:"false"` -} +After the service restarts, rotation invalidates the previous token. This +static Bearer gate is intended for self-hosted deployment; it is not presented +as a complete MCP OAuth 2.1 implementation. +Run `auth rotate` as a single serialized deployment operation; concurrent +rotation commands are not supported. -type ToolResult struct { - Field1 string `json:"field1"` -} +### Disabling authentication -func (t *ToolName) Name() string { - return "tool_name" -} +`MCP_AUTH_DISABLED=true` (or `MCP_AUTH_ENABLED=false`) removes the gate. The +server then refuses to start unless the listener is loopback-only, so a disabled +gate cannot silently publish attack telemetry to the network: -func (t *ToolName) Description() string { - return "tool description" -} - -func (t *ToolName) Validate(params ToolParams) error { - // Parameter validation logic - return nil -} - -func (t *ToolName) Execute(ctx context.Context, params ToolParams) (result ToolResult, err error) { - // Tool execution logic - return result, nil -} +```text +MCP_AUTH_DISABLED=true ``` -3. **[Optional]Create API Implementation** +## Docker deployment -If you need to use some APIs that have not been implemented yet, you need to create corresponding files in the api directory for implementation - - Create same directory structure under `internal/api` - - File name should match tool func - - Example: `internal/api/app/create_application.go` +1. Build the image. The included `.dockerignore` excludes `secrets/` and + `state/` from the build context even if local fallback directories exist: -**API Implementation Template** -```go -package app + ```bash + docker compose build + ``` -type RequestType struct { - // Request parameter definitions - Param1 string `json:"param1"` - Param2 int `json:"param2"` -} +2. Prefer protected deployment directories outside the source checkout, then + create one SafeLine token file per instance: -func APIName(ctx context.Context, req *RequestType) (ResultType, error) { - if req == nil { - return nil, errors.New("request is required") - } + ```bash + install -d -m 700 /etc/safeline-mcp/secrets /var/lib/safeline-mcp + install -m 600 /path/to/production-a.token /etc/safeline-mcp/secrets/production-a.token + install -m 600 /path/to/production-b.token /etc/safeline-mcp/secrets/production-b.token + export SAFELINE_MCP_SECRETS_DIR=/etc/safeline-mcp/secrets + export SAFELINE_MCP_STATE_DIR=/var/lib/safeline-mcp + ``` - var resp api.Response[ResultType] - err := api.Service().Post(ctx, "/api/path", req, &resp) - if err != nil { - return nil, errors.Wrap(err, "failed to execute") - } + For local-only development, `./secrets` and `./state` are accepted and are + excluded by both `.gitignore` and `.dockerignore`; create them with the same + permissions before running Compose. - if resp.Err != nil { - return nil, errors.New(resp.Msg) - } +3. Edit `config.yaml` so each `token_file` matches its file under `/run/secrets`. - return resp.Data, nil -} -``` -4. **Tool Registration (init.go)** +4. Run the one-time `auth init` command and save the displayed token. The + service refuses to start until this state exists. -The tool registration file `internal/tools/init.go` is used to centrally manage all tool registrations - - Register all tools uniformly in the `init()` function - - Use the `AppendTool()` method for registration - - Example: - ```go - // Register create application tool - AppendTool(&app.CreateApp{}) - - // Register create blacklist rule tool - AppendTool(&rule.CreateBlacklistRule{}) - ``` +5. Start the service: -### Development Standards + ```bash + docker compose up -d + ``` -1. **Naming Conventions** - - Use lowercase letters and underscores for tool names - - File names should match tool names +Compose refuses to auto-create missing bind-mount paths. Its host port is +loopback-only unless `MCP_PUBLISH_ADDRESS` is explicitly changed. -2. **Directory Organization** - - Divide directories by functional modules (e.g., app, rule, etc.) - - Maintain consistent structure between tools and api directories - - Keep related functionality in the same directory +## Running with Go -3. **Code Standards** - - Follow Go standard code conventions - - Add necessary parameter validation - - Use unified error handling approach - - Add appropriate logging +```bash +go test ./... +go build -o mcp-server . +./mcp-server auth init --state-file "$PWD/state/auth.json" +./mcp-server --config ./config.yaml +``` -4. **Documentation Requirements** - - Provide clear functional description in tool Description - - Add detailed description for parameters - - Update API toolkit documentation in README +The default configuration listens on `127.0.0.1`, which is the only address the +server accepts while authentication is disabled: -### Example +```bash +MCP_AUTH_DISABLED=true ./mcp-server --config ./config.yaml +``` -Refer to the implementation of the `create_application` tool: -- Tool implementation: `internal/tools/app/create_application.go` -- API implementation: `internal/api/app/create_application.go` +## Tool contract +`get_attack_events` accepts: +| Field | Type | Required | Constraint | +|---|---|---|---| +| `instance_id` | string | Yes | Must match a configured instance | +| `page` | integer | No | Default `1`, minimum `1` | +| `page_size` | integer | No | Default `10`, range `1..100` | +| `ip` | string | No | Source IP, CIDR, or partial-IP filter | +| `host` | string | No | Protected host filter | +| `port` | integer | No | Range `1..65535` | +| `start` | integer | No | Unix milliseconds, non-negative | +| `end` | integer | No | Unix milliseconds, non-negative and not before `start` | +Unknown input fields and fractional integers are rejected by server-side JSON Schema validation. Successful results include both text fallback content and `structuredContent` with: +- `source.instance_id` +- `source.display_name` +- `page` and `page_size` +- `nodes` +- `total` +SafeLine HTTP failures, HTTP-200 envelopes with a non-empty `err`, unknown instances, and input validation failures are returned as MCP Tool Execution Errors with `result.isError=true`. The MCP server does not interpret human-readable `msg` content when `err` is empty or absent. diff --git a/mcp_server/config.yaml b/mcp_server/config.yaml index 87b5b78c..bed6c1c8 100644 --- a/mcp_server/config.yaml +++ b/mcp_server/config.yaml @@ -1,27 +1,38 @@ -# Server Configuration server: name: "SafeLine MCP Server" - version: "1.0.0" - # Can be overridden by environment variable LISTEN_PORT + version: "2.0.0" + # LISTEN_ADDRESS and LISTEN_PORT can override these listener values. + host: "127.0.0.1" port: 5678 - # Can be overridden by environment variable LISTEN_ADDRESS - host: "0.0.0.0" - # Can be overridden by environment variable SAFELINE_SECRET - secret: "" # Secret for SSE server -# Logger Configuration + # Required when host is not loopback. MCP_TLS_CERT / MCP_TLS_KEY override these. + tls_cert_file: "" + tls_key_file: "" + logger: - level: "info" # Log level: debug, info, warn, error - file_path: "" # Log file path - console: true # Whether to output to console - caller: false # Whether to record caller information - development: true # Whether to use development mode + level: "info" + file_path: "" + console: true + caller: false + development: false -# API Configuration -api: - # Can be overridden by environment variable SAFELINE_ADDRESS - base_url: "" # API service address - # Can be overridden by environment variable SAFELINE_API_TOKEN - token: "" # Authentication token - timeout: 30 # Timeout in seconds - debug: false # Whether to enable debug mode - insecure_skip_verify: true # Whether to skip certificate verification +# SafeLine API Tokens remain administrator credentials in this release. Keep +# one token file per instance; the MCP server never returns them to clients. +instances: + - id: "safeline-a" + display_name: "SafeLine A" + base_url: "https://safeline-a.example:9443" + token_file: "/run/secrets/safeline-a.token" + timeout: 30 + debug: false + # ca_file is the certificate authority that signed the certificate of the + # instance. A SafeLine console serves the certificate it generated for + # itself, which no public authority signed: point this at that certificate + # (or at its CA) instead of switching the check off. + ca_file: "" + - id: "safeline-b" + display_name: "SafeLine B" + base_url: "https://safeline-b.example:9443" + token_file: "/run/secrets/safeline-b.token" + timeout: 30 + debug: false + ca_file: "" diff --git a/mcp_server/docker-compose.yml b/mcp_server/docker-compose.yml index 2ffdafe1..be26bc6b 100644 --- a/mcp_server/docker-compose.yml +++ b/mcp_server/docker-compose.yml @@ -1,15 +1,42 @@ -version: '3.8' - services: mcp_server: - image: chaitin/safeline-mcp:latest - container_name: mcp_server - restart: always + build: . + image: safeline-mcp:local + container_name: safeline-mcp + restart: unless-stopped ports: - - "5678:5678" + - "${MCP_PUBLISH_ADDRESS:-127.0.0.1}:${LISTEN_PORT:-5678}:${LISTEN_PORT:-5678}" environment: - - SAFELINE_SECRET=your_secret_key # optional, if you want to use secret key to authenticate - - SAFELINE_ADDRESS=https://your_safeline_ip:9443 # required, your SafeLine WAF address - - SAFELINE_API_TOKEN=your_safeline_api_token # required, your SafeLine WAF api token - - LISTEN_PORT=5678 # optional, default is 5678 - - LISTEN_ADDRESS=0.0.0.0 # optional, default is 0.0.0.0 \ No newline at end of file + LISTEN_ADDRESS: "0.0.0.0" + LISTEN_PORT: "${LISTEN_PORT:-5678}" + # Required because LISTEN_ADDRESS is not loopback. + MCP_TLS_CERT: "${MCP_TLS_CERT:-}" + MCP_TLS_KEY: "${MCP_TLS_KEY:-}" + # Authentication is enabled by default. Only set MCP_AUTH_DISABLED=true + # for a loopback-only deployment; a non-loopback listener without a token + # is refused at startup. + MCP_AUTH_DISABLED: "${MCP_AUTH_DISABLED:-}" + MCP_AUTH_STATE_FILE: "/var/lib/safeline-mcp/auth.json" + read_only: true + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=16m,mode=1777 + volumes: + - type: bind + source: ${SAFELINE_MCP_CONFIG_FILE:-./config.yaml} + target: /etc/safeline-mcp/config.yaml + read_only: true + bind: + create_host_path: false + - type: bind + source: ${SAFELINE_MCP_SECRETS_DIR:-./secrets} + target: /run/secrets + read_only: true + bind: + create_host_path: false + - type: bind + source: ${SAFELINE_MCP_STATE_DIR:-./state} + target: /var/lib/safeline-mcp + bind: + create_host_path: false diff --git a/mcp_server/go.mod b/mcp_server/go.mod index 7b836231..5ad13ca2 100644 --- a/mcp_server/go.mod +++ b/mcp_server/go.mod @@ -1,13 +1,20 @@ module github.com/chaitin/SafeLine/mcp_server -go 1.24.1 +go 1.25.14 require ( - github.com/mark3labs/mcp-go v0.18.0 + github.com/mark3labs/mcp-go v1.0.0-beta.1 go.uber.org/zap v1.27.0 gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + golang.org/x/text v0.41.0 // indirect +) + require ( github.com/google/uuid v1.6.0 // indirect github.com/mcuadros/go-defaults v1.2.0 diff --git a/mcp_server/go.sum b/mcp_server/go.sum index 5c31060f..9aace7e6 100644 --- a/mcp_server/go.sum +++ b/mcp_server/go.sum @@ -1,18 +1,36 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao= -github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v1.0.0-beta.1 h1:hrPZlyUCfykLdAyO8Yr6Dkk0alPbpeLjkeS0BuNBGK8= +github.com/mark3labs/mcp-go v1.0.0-beta.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s= github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -21,8 +39,15 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp_server/internal/api/analyze/get_event_list.go b/mcp_server/internal/api/analyze/get_event_list.go index 29eac72a..51113404 100644 --- a/mcp_server/internal/api/analyze/get_event_list.go +++ b/mcp_server/internal/api/analyze/get_event_list.go @@ -2,20 +2,32 @@ package analyze import ( "context" - "fmt" + "net/url" + "strconv" "github.com/chaitin/SafeLine/mcp_server/internal/api" ) type GetEventListRequest struct { - Page int `json:"page"` - PageSize int `json:"page_size"` - IP string `json:"ip"` - Start int64 `json:"start"` - End int64 `json:"end"` + InstanceID string `json:"instance_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` + IP string `json:"ip"` + Host string `json:"host"` + Port int `json:"port"` + Start int64 `json:"start"` + End int64 `json:"end"` } type GetEventListResponse struct { + Source api.InstanceSource `json:"source"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Nodes []Event `json:"nodes"` + Total int64 `json:"total"` +} + +type eventListData struct { Nodes []Event `json:"nodes"` Total int64 `json:"total"` } @@ -38,10 +50,42 @@ type Event struct { } func GetEventList(ctx context.Context, req *GetEventListRequest) (*GetEventListResponse, error) { - var resp api.Response[GetEventListResponse] - err := api.Service().Get(ctx, fmt.Sprintf("/api/open/events?page=%d&page_size=%d&ip=%s&start=%d&end=%d", req.Page, req.PageSize, req.IP, req.Start, req.End), &resp) + client, err := api.ClientFor(req.InstanceID) if err != nil { return nil, err } - return &resp.Data, nil + + query := make(url.Values) + query.Set("page", strconv.Itoa(req.Page)) + query.Set("page_size", strconv.Itoa(req.PageSize)) + if req.IP != "" { + query.Set("ip", req.IP) + } + if req.Host != "" { + query.Set("host", req.Host) + } + if req.Port != 0 { + query.Set("port", strconv.Itoa(req.Port)) + } + if req.Start != 0 { + query.Set("start", strconv.FormatInt(req.Start, 10)) + } + if req.End != 0 { + query.Set("end", strconv.FormatInt(req.End, 10)) + } + + var resp api.Response[eventListData] + if err := client.GetAttackEvents(ctx, query, &resp); err != nil { + return nil, err + } + if err := resp.Validate(); err != nil { + return nil, err + } + return &GetEventListResponse{ + Source: client.Source(), + Page: req.Page, + PageSize: req.PageSize, + Nodes: resp.Data.Nodes, + Total: resp.Data.Total, + }, nil } diff --git a/mcp_server/internal/api/analyze/get_event_list_test.go b/mcp_server/internal/api/analyze/get_event_list_test.go new file mode 100644 index 00000000..e5355a55 --- /dev/null +++ b/mcp_server/internal/api/analyze/get_event_list_test.go @@ -0,0 +1,96 @@ +package analyze + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/internal/api" + "github.com/chaitin/SafeLine/mcp_server/internal/config" + "github.com/chaitin/SafeLine/mcp_server/pkg/logger" +) + +func TestGetEventListUsesCurrentSafeLineQueryAndReturnsSource(t *testing.T) { + if err := logger.Init(&logger.Config{Level: "error"}); err != nil { + t.Fatal(err) + } + tokenPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenPath, []byte("instance-token\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/open/events" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("X-SLCE-API-TOKEN"); got != "instance-token" { + t.Errorf("token header = %q", got) + } + wantQuery := map[string]string{ + "page": "2", "page_size": "25", "ip": "10.0.0.0/24", + "host": "example.test&a=b", "port": "9443", "start": "1000", "end": "2000", + } + for key, want := range wantQuery { + if got := r.URL.Query().Get(key); got != want { + t.Errorf("query[%q] = %q, want %q", key, got, want) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": { + "nodes": [{ + "id": 7, + "ip": "10.0.0.8", + "protocol": 1, + "host": "example.test", + "dst_port": 9443, + "updated_at": 1800, + "start_at": 1100, + "end_at": 1700, + "deny_count": 3, + "pass_count": 0, + "finished": true, + "country": "CN", + "province": "Beijing", + "city": "Beijing" + }], + "total": 1 + }, + "err": null, + "msg": "" + }`)) + })) + defer server.Close() + + if err := api.InitInstances([]*config.InstanceConfig{{ + ID: "dev152", DisplayName: "Development 152", BaseURL: server.URL, TokenFile: tokenPath, + }}); err != nil { + t.Fatalf("InitInstances() error = %v", err) + } + + response, err := GetEventList(context.Background(), &GetEventListRequest{ + InstanceID: "dev152", + Page: 2, + PageSize: 25, + IP: "10.0.0.0/24", + Host: "example.test&a=b", + Port: 9443, + Start: 1000, + End: 2000, + }) + if err != nil { + t.Fatalf("GetEventList() error = %v", err) + } + if response.Source.InstanceID != "dev152" || response.Source.DisplayName != "Development 152" { + t.Errorf("source = %#v", response.Source) + } + if response.Page != 2 || response.PageSize != 25 || response.Total != 1 { + t.Errorf("pagination/total = page %d, size %d, total %d", response.Page, response.PageSize, response.Total) + } + if len(response.Nodes) != 1 || response.Nodes[0].ID != 7 || response.Nodes[0].DstPort != 9443 { + t.Fatalf("nodes = %#v", response.Nodes) + } +} diff --git a/mcp_server/internal/api/app/create_application.go b/mcp_server/internal/api/app/create_application.go deleted file mode 100644 index 3d7d0419..00000000 --- a/mcp_server/internal/api/app/create_application.go +++ /dev/null @@ -1,34 +0,0 @@ -package app - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" -) - -type CreateAppRequest struct { - ServerNames []string `json:"server_names"` - Ports []string `json:"ports"` - Upstreams []string `json:"upstreams"` - Comment string `json:"comment"` -} - -// CreateApp Create new website or app -func CreateApp(ctx context.Context, req *CreateAppRequest) (int64, error) { - if req == nil { - return 0, errors.New("request is required") - } - - var resp api.Response[int64] - err := api.Service().Post(ctx, "/api/open/site", req, &resp) - if err != nil { - return 0, errors.Wrap(err, "failed to create app") - } - - if resp.Err != nil { - return 0, errors.New(resp.Msg) - } - - return resp.Data, nil -} diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go index 79781725..d094efa2 100644 --- a/mcp_server/internal/api/client.go +++ b/mcp_server/internal/api/client.go @@ -1,106 +1,178 @@ package api import ( - "bytes" "context" "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" + "os" + "strings" "time" "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" ) -// Client API client -type Client struct { - baseURL string - httpClient *http.Client - headers map[string]string -} +const ( + // tokenHeader carries the deployment token of the configured instance. + tokenHeader = "X-SLCE-API-TOKEN" -// ClientOption Client configuration options -type ClientOption func(*Client) + maxErrorBodySize = 4 << 10 + maxResponseBodySize = 4 << 20 -// WithTimeout Set timeout duration -func WithTimeout(timeout time.Duration) ClientOption { - return func(c *Client) { - c.httpClient.Timeout = timeout + // maxRedirects bounds how many redirects one request may follow. + maxRedirects = 10 +) + +// loadRootCAs reads the certificate authority the instance is issued by. +// +// It is a file rather than the system pool because the installation that the +// MCP server talks to usually carries the certificate it generated for itself, +// which no system pool knows about. +func loadRootCAs(path string) (*x509.CertPool, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrap(err, "read ca_file failed") } -} -// WithHeader Set request header -func WithHeader(key, value string) ClientOption { - return func(c *Client) { - c.headers[key] = value + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(content) { + return nil, errors.New("ca_file does not contain a PEM encoded certificate") } + + return roots, nil } -// WithBaseURL Set base URL -func WithBaseURL(baseURL string) ClientOption { - return func(c *Client) { - c.baseURL = baseURL - } +// Client is the transport used by a single SafeLine instance. Its request +// method is intentionally unexported; APIClient exposes only approved read-only +// operations. +type Client struct { + baseURL *url.URL + httpClient *http.Client + headers http.Header } -// WithInsecureSkipVerify Set whether to skip certificate verification -func WithInsecureSkipVerify(skip bool) ClientOption { - return func(c *Client) { - if transport, ok := c.httpClient.Transport.(*http.Transport); ok { - if transport.TLSClientConfig == nil { - transport.TLSClientConfig = &tls.Config{} - } - transport.TLSClientConfig.InsecureSkipVerify = skip - } +func loopbackURLHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h } + host = strings.Trim(host, "[]") + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() } -// NewClient Create new API client -func NewClient(opts ...ClientOption) *Client { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{}, +func newClient(baseURL string, timeout time.Duration, caFile, token string) (*Client, error) { + parsedURL, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return nil, errors.Wrap(err, "parse base_url failed") + } + if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { + return nil, errors.New("base_url must be an absolute http or https URL") + } + if parsedURL.Scheme == "http" && !loopbackURLHost(parsedURL.Host) { + return nil, errors.New("deployment token must not be sent over non-loopback HTTP") } + if parsedURL.RawQuery != "" || parsedURL.Fragment != "" { + return nil, errors.New("base_url must not contain a query or fragment") + } + parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") + + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if caFile != "" { + // The console of a SafeLine installation serves a certificate that no + // public authority signed. Pinning the authority of the instance keeps + // verification on, which is what keeps the deployment token in the + // request header out of the hands of anything on the path. + roots, err := loadRootCAs(caFile) + if err != nil { + return nil, err + } + tlsConfig.RootCAs = roots + } + + transport := &http.Transport{TLSClientConfig: tlsConfig} - c := &Client{ + client := &Client{ + baseURL: parsedURL, httpClient: &http.Client{ - Timeout: 30 * time.Second, + Timeout: timeout, Transport: transport, + // Every request carries the deployment token in X-SLCE-API-TOKEN. + // net/http drops the Authorization header when a redirect leaves + // the origin, but it knows nothing about this header, so a 3xx + // from the configured instance would hand the token to whatever + // host the Location header names. Redirects are therefore followed + // only while they stay on the configured origin. + CheckRedirect: sameOriginRedirects(parsedURL), }, - headers: make(map[string]string), + headers: make(http.Header), } + client.headers.Set("Accept", "application/json") + client.headers.Set("User-Agent", "SafeLine-MCP/1.0") + client.headers.Set(tokenHeader, token) + return client, nil +} - for _, opt := range opts { - opt(c) - } +// sameOriginRedirects returns the redirect policy of a client: a redirect is +// followed only while it stays on the scheme, host and port of the configured +// instance. Anything else fails the request instead of replaying the token. +func sameOriginRedirects(base *url.URL) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + if !sameOrigin(base, req.URL) { + return fmt.Errorf("refused to follow a redirect to %s: it would send the instance token to another origin", req.URL.Redacted()) + } - return c + return nil + } } -// Request Send request -func (c *Client) Request(ctx context.Context, method, path string, body interface{}, result interface{}) error { - reqURL := fmt.Sprintf("%s%s", c.baseURL, path) +// sameOrigin reports whether target has the same scheme, host and port as base. +func sameOrigin(base, target *url.URL) bool { + return strings.EqualFold(base.Scheme, target.Scheme) && + strings.EqualFold(base.Hostname(), target.Hostname()) && + effectivePort(base) == effectivePort(target) +} - var bodyReader io.Reader - if body != nil { - bodyBytes, err := json.Marshal(body) - if err != nil { - return errors.Wrap(err, "marshal request body failed") - } - bodyReader = bytes.NewReader(bodyBytes) +// effectivePort returns the port a URL connects to, including the default port +// of its scheme. +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port } - logger.With("url", reqURL).Debug("request url") - req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) - if err != nil { - return errors.Wrap(err, "create request failed") + if strings.EqualFold(u.Scheme, "https") { + return "443" } - // Set common headers - req.Header.Set("Content-Type", "application/json") - for k, v := range c.headers { - req.Header.Set(k, v) + return "80" +} + +func (c *Client) get(ctx context.Context, path string, query url.Values, result any) error { + endpoint := *c.baseURL + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path + endpoint.RawPath = "" + endpoint.RawQuery = query.Encode() + + logger.With("url", endpoint.String()).Debug("request url") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return errors.Wrap(err, "create request failed") } + req.Header = c.headers.Clone() resp, err := c.httpClient.Do(req) if err != nil { @@ -108,50 +180,38 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac } defer resp.Body.Close() - // Read response body - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize+1)) if err != nil { return errors.Wrap(err, "read response body failed") } - - // Check status code - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return errors.New(fmt.Sprintf("request failed with status %d: %s", resp.StatusCode, string(respBody))) + if len(respBody) > maxResponseBodySize { + return errors.New("SafeLine response body exceeds the 4 MiB limit") } - // Parse response - if result != nil { - if err := json.Unmarshal(respBody, result); err == nil { - return nil - } - var respData map[string]interface{} - if err := json.Unmarshal(respBody, &respData); err != nil { - return errors.Wrap(err, "unmarshal response failed") + var envelope struct { + Err any `json:"err"` + Msg string `json:"msg"` + } + envelopeDecoded := json.Unmarshal(respBody, &envelope) == nil + if envelopeDecoded { + if err := responseError(envelope.Err, envelope.Msg); err != nil { + return err } - if respData["err"] != nil || respData["msg"] != nil { - return errors.New(respData["msg"].(string)) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body := strings.TrimSpace(string(respBody)) + if len(body) > maxErrorBodySize { + body = body[:maxErrorBodySize] + "..." } + return errors.New(fmt.Sprintf("SafeLine request failed with status %d: %s", resp.StatusCode, body)) } + if result == nil { + return nil + } + if err := json.Unmarshal(respBody, result); err != nil { + return errors.Wrap(err, "unmarshal response failed") + } return nil } - -// Get Send GET request -func (c *Client) Get(ctx context.Context, path string, result interface{}) error { - return c.Request(ctx, http.MethodGet, path, nil, result) -} - -// Post Send POST request -func (c *Client) Post(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.Request(ctx, http.MethodPost, path, body, result) -} - -// Put Send PUT request -func (c *Client) Put(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.Request(ctx, http.MethodPut, path, body, result) -} - -// Delete Send DELETE request -func (c *Client) Delete(ctx context.Context, path string, result interface{}) error { - return c.Request(ctx, http.MethodDelete, path, nil, result) -} diff --git a/mcp_server/internal/api/client_redirect_test.go b/mcp_server/internal/api/client_redirect_test.go new file mode 100644 index 00000000..81af567d --- /dev/null +++ b/mcp_server/internal/api/client_redirect_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// The client sends the deployment token in a header of its own, while net/http +// only strips the standard Authorization header when a redirect leaves the +// origin. A 3xx answered by the configured instance would therefore hand the +// token to whatever host the Location header names. +func TestClientRefusesCrossOriginRedirect(t *testing.T) { + var tokenAtOtherOrigin atomic.Value + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenAtOtherOrigin.Store(r.Header.Get(tokenHeader)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"err":null,"data":{}}`)) + })) + defer other.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/stolen", http.StatusFound) + })) + defer origin.Close() + + client, err := newClient(origin.URL, 5*time.Second, "", "deployment-token") + if err != nil { + t.Fatal(err) + } + + var result map[string]any + err = client.get(context.Background(), "/api/open/events", url.Values{}, &result) + if err == nil { + t.Fatal("a redirect to another origin has to fail the request") + } + if !strings.Contains(err.Error(), "refused to follow a redirect") { + t.Fatalf("unexpected error: %v", err) + } + if got := tokenAtOtherOrigin.Load(); got != nil { + t.Fatalf("the token reached another origin: %v", got) + } +} + +func TestClientFollowsSameOriginRedirect(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/open/events", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/moved", http.StatusFound) + }) + var tokenAfterRedirect atomic.Value + mux.HandleFunc("/moved", func(w http.ResponseWriter, r *http.Request) { + tokenAfterRedirect.Store(r.Header.Get(tokenHeader)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"err":null,"data":{"ok":true}}`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, err := newClient(server.URL, 5*time.Second, "", "deployment-token") + if err != nil { + t.Fatal(err) + } + + var result map[string]any + if err := client.get(context.Background(), "/api/open/events", url.Values{}, &result); err != nil { + t.Fatalf("a same-origin redirect has to be followed: %v", err) + } + if got := tokenAfterRedirect.Load(); got != "deployment-token" { + t.Fatalf("the token was lost on the way: %v", got) + } +} + +func TestSameOrigin(t *testing.T) { + cases := []struct { + base, target string + want bool + }{ + {"https://safeline.example:9443", "https://safeline.example:9443/other", true}, + {"https://safeline.example:9443", "https://SAFELINE.example:9443/other", true}, + {"https://safeline.example", "https://safeline.example:443/other", true}, + {"http://safeline.example", "http://safeline.example:80/other", true}, + {"https://safeline.example", "http://safeline.example/other", false}, + {"https://safeline.example", "https://safeline.example:8443/other", false}, + {"https://safeline.example:9443", "https://evil.example:9443/other", false}, + {"https://safeline.example:9443", "https://safeline.example.evil.example:9443/other", false}, + } + + for _, c := range cases { + base, err := url.Parse(c.base) + if err != nil { + t.Fatal(err) + } + target, err := url.Parse(c.target) + if err != nil { + t.Fatal(err) + } + if got := sameOrigin(base, target); got != c.want { + t.Errorf("sameOrigin(%s, %s) = %v, want %v", c.base, c.target, got, c.want) + } + } +} + +// TestPlainClientLeaksTokenAcrossRedirects documents what the policy above +// prevents: with the default redirect handling of net/http the custom token +// header arrives at the other origin. +func TestPlainClientLeaksTokenAcrossRedirects(t *testing.T) { + var tokenAtOtherOrigin atomic.Value + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenAtOtherOrigin.Store(r.Header.Get(tokenHeader)) + w.Write([]byte(`{}`)) + })) + defer other.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/stolen", http.StatusFound) + })) + defer origin.Close() + + req, err := http.NewRequest(http.MethodGet, origin.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set(tokenHeader, "deployment-token") + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if got := tokenAtOtherOrigin.Load(); got != "deployment-token" { + t.Fatalf("the plain client was expected to leak the token, got %v", got) + } +} diff --git a/mcp_server/internal/api/client_tls_test.go b/mcp_server/internal/api/client_tls_test.go new file mode 100644 index 00000000..814847ef --- /dev/null +++ b/mcp_server/internal/api/client_tls_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "context" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" +) + +// The console of a SafeLine installation serves the certificate it generated +// for itself, so an MCP deployment that wants verification on has to be able to +// name the authority of that instance. ca_file is that, and it exists so that +// insecure_skip_verify is not the only way to reach an installation with a +// self-signed certificate. +func TestClientUsesTheAuthorityOfTheInstance(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"err":null,"data":{"ok":true}}`)) + })) + defer server.Close() + + // The certificate is not known without the authority, and the request is + // refused rather than sent in the clear or unverified. + untrusted, err := newClient(server.URL, 5*time.Second, "", "deployment-token") + if err != nil { + t.Fatal(err) + } + + var result struct { + Data struct { + OK bool `json:"ok"` + } `json:"data"` + Err any `json:"err"` + } + if err := untrusted.get(context.Background(), "/api/open/events", url.Values{}, &result); err == nil { + t.Fatal("the instance was reached without trusting its certificate authority") + } + + caPath := filepath.Join(t.TempDir(), "instance.crt") + encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(caPath, encoded, 0o600); err != nil { + t.Fatal(err) + } + + trusted, err := newClient(server.URL, 5*time.Second, caPath, "deployment-token") + if err != nil { + t.Fatal(err) + } + + if err := trusted.get(context.Background(), "/api/open/events", url.Values{}, &result); err != nil { + t.Fatalf("the request with the authority of the instance failed: %v", err) + } + if !result.Data.OK || result.Err != nil { + t.Fatalf("the request did not reach the instance: %+v", result) + } +} + +func TestClientRefusesACAFileWithoutACertificate(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "not-a-certificate.crt") + if err := os.WriteFile(caPath, []byte("this is not a certificate\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := newClient("https://instance.example:9443", 5*time.Second, caPath, "token"); err == nil { + t.Fatal("a ca_file without a certificate was accepted") + } +} + +func TestClientReportsAMissingCAFile(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing.crt") + if _, err := newClient("https://instance.example:9443", 5*time.Second, missing, "token"); err == nil { + t.Fatal("a missing ca_file was accepted") + } +} + +func TestClientRejectsNonLoopbackHTTP(t *testing.T) { + _, err := newClient("http://example.com:9443", 5*time.Second, "", "token") + if err == nil || err.Error() != "deployment token must not be sent over non-loopback HTTP" { + t.Fatalf("error = %v", err) + } +} + +func TestClientAllowsLoopbackHTTP(t *testing.T) { + if _, err := newClient("http://127.0.0.1:9443", 5*time.Second, "", "token"); err != nil { + t.Fatal(err) + } + if _, err := newClient("http://localhost:9443", 5*time.Second, "", "token"); err != nil { + t.Fatal(err) + } +} diff --git a/mcp_server/internal/api/response.go b/mcp_server/internal/api/response.go index bfa2993b..98a2237c 100644 --- a/mcp_server/internal/api/response.go +++ b/mcp_server/internal/api/response.go @@ -1,11 +1,45 @@ package api -// Response Common API response structure +import ( + "fmt" + "strings" +) + +// Response is the envelope returned by SafeLine's management API. SafeLine can +// report an application error with HTTP 200, so callers must not infer success +// from the HTTP status alone. type Response[T any] struct { - // Response data - Data T `json:"data"` - // Error message - Err any `json:"err"` - // Prompt message - Msg string `json:"msg"` + Data T `json:"data"` + Err any `json:"err"` + Msg string `json:"msg"` +} + +// ResponseError represents an error carried in a SafeLine response envelope. +type ResponseError struct { + Code any + Message string +} + +func (e *ResponseError) Error() string { + message := strings.TrimSpace(e.Message) + if message == "" { + message = "request failed" + } + return fmt.Sprintf("SafeLine API error %v: %s", e.Code, message) +} + +// Validate returns an error whenever the envelope contains a non-empty err +// value, including responses that otherwise decoded successfully. +func (r *Response[T]) Validate() error { + return responseError(r.Err, r.Msg) +} + +func responseError(code any, message string) error { + if code == nil { + return nil + } + if text, ok := code.(string); ok && strings.TrimSpace(text) == "" { + return nil + } + return &ResponseError{Code: code, Message: message} } diff --git a/mcp_server/internal/api/rule/create_rule.go b/mcp_server/internal/api/rule/create_rule.go deleted file mode 100644 index 6eb88439..00000000 --- a/mcp_server/internal/api/rule/create_rule.go +++ /dev/null @@ -1,35 +0,0 @@ -package rule - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" -) - -type CreateRuleRequest struct { - Name string `json:"name"` - IP []string `json:"ip"` - IsEnabled bool `json:"is_enabled"` - Pattern [][]api.Pattern `json:"pattern"` - Action int `json:"action"` -} - -// CreateRule Create new rule -func CreateRule(ctx context.Context, req *CreateRuleRequest) (int64, error) { - if req == nil { - return 0, errors.New("request is required") - } - - var resp api.Response[int64] - err := api.Service().Post(ctx, "/api/open/policy", req, &resp) - if err != nil { - return 0, errors.Wrap(err, "failed to create policy rule") - } - - if resp.Err != nil { - return 0, errors.New(resp.Msg) - } - - return resp.Data, nil -} diff --git a/mcp_server/internal/api/service.go b/mcp_server/internal/api/service.go index 2c0a62a6..e06c6172 100644 --- a/mcp_server/internal/api/service.go +++ b/mcp_server/internal/api/service.go @@ -2,6 +2,12 @@ package api import ( "context" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" "sync" "time" @@ -10,91 +16,197 @@ import ( "github.com/chaitin/SafeLine/mcp_server/pkg/logger" ) -// APIClient API client implementation +const attackEventsPath = "/api/open/events" +const maxTokenFileSize = 16 << 10 + +// InstanceSource is safe to include in tool output and identifies the exact +// SafeLine instance from which the data was read. +type InstanceSource struct { + InstanceID string `json:"instance_id"` + DisplayName string `json:"display_name"` +} + +// APIClient is an independently authenticated client for one SafeLine instance. type APIClient struct { client *Client - config *config.APIConfig + source InstanceSource } -var ( - instance *APIClient - once sync.Once -) +// ClientRegistry maps stable instance IDs to independently configured clients. +// Credentials are loaded once per instance and are never shared across entries. +type ClientRegistry struct { + clients map[string]*APIClient + order []string +} + +// NewClientRegistry constructs a registry without mutating global state. +func NewClientRegistry(configs []*config.InstanceConfig) (*ClientRegistry, error) { + if len(configs) == 0 { + return nil, errors.New("at least one SafeLine instance is required") + } + + registry := &ClientRegistry{ + clients: make(map[string]*APIClient, len(configs)), + order: make([]string, 0, len(configs)), + } + for index, cfg := range configs { + if cfg == nil { + return nil, errors.New(fmt.Sprintf("instances[%d] is required", index)) + } + if strings.TrimSpace(cfg.ID) == "" { + return nil, errors.New(fmt.Sprintf("instances[%d].id is required", index)) + } + if _, exists := registry.clients[cfg.ID]; exists { + return nil, errors.New(fmt.Sprintf("duplicate instance id %q", cfg.ID)) + } -// Init Initialize API service -func Init(cfg *config.APIConfig) error { - var err error - once.Do(func() { - instance, err = newAPIClient(cfg) + client, err := newAPIClient(cfg) if err != nil { - logger.With("error", err).Error("failed to initialize API service") - return + return nil, errors.Wrap(err, fmt.Sprintf("initialize instance %q failed", cfg.ID)) } - logger.Info("API service initialized successfully") - }) - return err + registry.clients[cfg.ID] = client + registry.order = append(registry.order, cfg.ID) + } + return registry, nil +} + +// Get returns the client for instanceID without falling back to another +// instance. This prevents a typo from accidentally querying with the wrong +// instance credential. +func (r *ClientRegistry) Get(instanceID string) (*APIClient, error) { + if r == nil { + return nil, errors.New("API service not initialized") + } + client, ok := r.clients[instanceID] + if !ok { + return nil, errors.New(fmt.Sprintf("unknown SafeLine instance %q", instanceID)) + } + return client, nil } -// Service Get API service instance -func Service() *APIClient { - if instance == nil { - logger.Error("API service not initialized") - panic("API service not initialized") +// Sources lists configured instances without exposing addresses or credentials. +func (r *ClientRegistry) Sources() []InstanceSource { + if r == nil { + return nil + } + sources := make([]InstanceSource, 0, len(r.order)) + for _, id := range r.order { + sources = append(sources, r.clients[id].source) } - return instance + return sources } -// newAPIClient Create new API client -func newAPIClient(config *config.APIConfig) (*APIClient, error) { - if config == nil { - return nil, errors.New("config is required") +var ( + registryMu sync.RWMutex + activeRegistry *ClientRegistry +) + +// InitInstances initializes the runtime registry used by MCP tools. +func InitInstances(configs []*config.InstanceConfig) error { + registry, err := NewClientRegistry(configs) + if err != nil { + logger.With("error", err).Error("failed to initialize API service") + return err } - if config.BaseURL == "" { + registryMu.Lock() + activeRegistry = registry + registryMu.Unlock() + logger.With("instances", len(configs)).Info("API service initialized successfully") + return nil +} + +// ClientFor returns the explicitly selected instance client. +func ClientFor(instanceID string) (*APIClient, error) { + registryMu.RLock() + registry := activeRegistry + registryMu.RUnlock() + return registry.Get(instanceID) +} + +func newAPIClient(cfg *config.InstanceConfig) (*APIClient, error) { + if strings.TrimSpace(cfg.BaseURL) == "" { return nil, errors.New("base_url is required") } + token, err := loadToken(cfg) + if err != nil { + return nil, err + } timeout := 30 - if config.Timeout > 0 { - timeout = config.Timeout + if cfg.Timeout > 0 { + timeout = cfg.Timeout } - - opts := []ClientOption{ - WithBaseURL(config.BaseURL), - WithTimeout(time.Duration(timeout) * time.Second), - WithHeader("User-Agent", "SafeLine-MCP/1.0"), - WithInsecureSkipVerify(config.InsecureSkipVerify), + client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.CAFile, token) + if err != nil { + return nil, err } - // If token is configured, add authentication header - if config.Token != "" { - opts = append(opts, WithHeader("X-SLCE-API-TOKEN", config.Token)) + displayName := strings.TrimSpace(cfg.DisplayName) + if displayName == "" { + displayName = cfg.ID } - - client := NewClient(opts...) - return &APIClient{ client: client, - config: config, + source: InstanceSource{InstanceID: cfg.ID, DisplayName: displayName}, }, nil } -// Post Send POST request -func (c *APIClient) Post(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.client.Request(ctx, "POST", path, body, result) -} +func loadToken(cfg *config.InstanceConfig) (string, error) { + if cfg.TokenFile != "" { + dirInfo, err := os.Lstat(filepath.Dir(cfg.TokenFile)) + if err != nil { + return "", errors.Wrap(err, "inspect token_file directory failed") + } + if dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o022 != 0 { + return "", errors.New("token_file directory must be a real directory that is not group or world writable") + } + + pathInfo, err := os.Lstat(cfg.TokenFile) + if err != nil { + return "", errors.Wrap(err, "inspect token_file failed") + } + if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || pathInfo.Mode().Perm()&0o077 != 0 { + return "", errors.New("token_file must be a regular file with no group or other permissions") + } + + file, err := os.Open(cfg.TokenFile) + if err != nil { + return "", errors.Wrap(err, "open token_file failed") + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return "", errors.Wrap(err, "inspect opened token_file failed") + } + if !os.SameFile(pathInfo, openedInfo) || !openedInfo.Mode().IsRegular() || openedInfo.Mode().Perm()&0o077 != 0 { + return "", errors.New("token_file changed while opening") + } + + contents, err := io.ReadAll(io.LimitReader(file, maxTokenFileSize+1)) + if err != nil { + return "", errors.Wrap(err, "read token_file failed") + } + if len(contents) > maxTokenFileSize { + return "", errors.New("token_file exceeds the maximum size") + } + token := strings.TrimSpace(string(contents)) + if token == "" { + return "", errors.New("token_file is empty") + } + return token, nil + } -// Get Send GET request -func (c *APIClient) Get(ctx context.Context, path string, result interface{}) error { - return c.client.Request(ctx, "GET", path, nil, result) + return "", errors.New("token_file is required") } -// Put Send PUT request -func (c *APIClient) Put(ctx context.Context, path string, body interface{}, result interface{}) error { - return c.client.Request(ctx, "PUT", path, body, result) +// Source returns the non-sensitive identity included in tool results. +func (c *APIClient) Source() InstanceSource { + return c.source } -// Delete Send DELETE request -func (c *APIClient) Delete(ctx context.Context, path string, result interface{}) error { - return c.client.Request(ctx, "DELETE", path, nil, result) +// GetAttackEvents is the only SafeLine management request exposed by the +// client. The path and HTTP method are fixed rather than supplied by a tool. +func (c *APIClient) GetAttackEvents(ctx context.Context, query url.Values, result any) error { + return c.client.get(ctx, attackEventsPath, query, result) } diff --git a/mcp_server/internal/api/service_test.go b/mcp_server/internal/api/service_test.go new file mode 100644 index 00000000..01270270 --- /dev/null +++ b/mcp_server/internal/api/service_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "context" + stderrors "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/internal/config" + "github.com/chaitin/SafeLine/mcp_server/pkg/logger" +) + +func TestMain(m *testing.M) { + _ = logger.Init(&logger.Config{Level: "error"}) + os.Exit(m.Run()) +} + +func writeTokenFile(t *testing.T, token string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "api_token") + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + return path +} + +func TestClientRegistryKeepsInstanceCredentialsIsolated(t *testing.T) { + type observedRequest struct { + method string + path string + token string + query url.Values + } + requests := make(chan observedRequest, 2) + newServer := func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- observedRequest{ + method: r.Method, + path: r.URL.Path, + token: r.Header.Get("X-SLCE-API-TOKEN"), + query: r.URL.Query(), + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"nodes":[],"total":0},"err":null,"msg":""}`)) + })) + } + one := newServer() + defer one.Close() + two := newServer() + defer two.Close() + + registry, err := NewClientRegistry([]*config.InstanceConfig{ + {ID: "dev152", DisplayName: "Development 152", BaseURL: one.URL, TokenFile: writeTokenFile(t, "token-152")}, + {ID: "dev180", DisplayName: "Development 180", BaseURL: two.URL, TokenFile: writeTokenFile(t, "token-180")}, + }) + if err != nil { + t.Fatalf("NewClientRegistry() error = %v", err) + } + + for _, id := range []string{"dev152", "dev180"} { + client, err := registry.Get(id) + if err != nil { + t.Fatalf("Get(%q) error = %v", id, err) + } + var result Response[map[string]any] + if err := client.GetAttackEvents(context.Background(), url.Values{"page": {"1"}}, &result); err != nil { + t.Fatalf("GetAttackEvents(%q) error = %v", id, err) + } + } + + seenTokens := map[string]bool{} + for range 2 { + request := <-requests + if request.method != http.MethodGet || request.path != attackEventsPath { + t.Errorf("request = %s %s, want GET %s", request.method, request.path, attackEventsPath) + } + if request.query.Get("page") != "1" { + t.Errorf("page query = %q", request.query.Get("page")) + } + seenTokens[request.token] = true + } + if !seenTokens["token-152"] || !seenTokens["token-180"] || len(seenTokens) != 2 { + t.Fatalf("observed tokens = %#v, want isolated instance tokens", seenTokens) + } + + sources := registry.Sources() + if len(sources) != 2 || sources[0].InstanceID != "dev152" || sources[1].InstanceID != "dev180" { + t.Fatalf("Sources() = %#v", sources) + } +} + +func TestClientReturnsHTTP200EnvelopeError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{},"err":"token_invalid","msg":"invalid API token"}`)) + })) + defer server.Close() + + client, err := newAPIClient(&config.InstanceConfig{ + ID: "broken", BaseURL: server.URL, TokenFile: writeTokenFile(t, "bad-token"), + }) + if err != nil { + t.Fatalf("newAPIClient() error = %v", err) + } + err = client.GetAttackEvents(context.Background(), nil, &Response[map[string]any]{}) + var responseErr *ResponseError + if !stderrors.As(err, &responseErr) { + t.Fatalf("GetAttackEvents() error = %T %v, want *ResponseError", err, err) + } + if responseErr.Code != "token_invalid" || responseErr.Message != "invalid API token" { + t.Fatalf("ResponseError = %#v", responseErr) + } +} + +func TestClientRegistryRejectsUnknownInstanceAndEmptyToken(t *testing.T) { + registry, err := NewClientRegistry([]*config.InstanceConfig{{ + ID: "known", BaseURL: "https://known.example.test", TokenFile: writeTokenFile(t, "token"), + }}) + if err != nil { + t.Fatalf("NewClientRegistry() error = %v", err) + } + if _, err := registry.Get("missing"); err == nil || !strings.Contains(err.Error(), "unknown SafeLine instance") { + t.Fatalf("Get(missing) error = %v", err) + } + + _, err = NewClientRegistry([]*config.InstanceConfig{{ + ID: "empty", BaseURL: "https://empty.example.test", TokenFile: writeTokenFile(t, ""), + }}) + if err == nil || !strings.Contains(err.Error(), "token_file is empty") { + t.Fatalf("empty token error = %v", err) + } +} + +func TestClientRejectsUnsafeTokenFiles(t *testing.T) { + dir := t.TempDir() + tokenPath := filepath.Join(dir, "api_token") + if err := os.WriteFile(tokenPath, []byte("token"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(tokenPath, 0o644); err != nil { + t.Fatal(err) + } + _, err := NewClientRegistry([]*config.InstanceConfig{{ + ID: "broad-mode", BaseURL: "https://example.test", TokenFile: tokenPath, + }}) + if err == nil || !strings.Contains(err.Error(), "no group or other permissions") { + t.Fatalf("broad token mode error = %v", err) + } + + if err := os.Chmod(tokenPath, 0o600); err != nil { + t.Fatal(err) + } + symlinkPath := filepath.Join(dir, "token-link") + if err := os.Symlink(tokenPath, symlinkPath); err != nil { + t.Fatal(err) + } + _, err = NewClientRegistry([]*config.InstanceConfig{{ + ID: "symlink", BaseURL: "https://example.test", TokenFile: symlinkPath, + }}) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("symlink token error = %v", err) + } +} + +func TestClientLimitsSafeLineResponseBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("x", maxResponseBodySize+1))) + })) + defer server.Close() + + client, err := newAPIClient(&config.InstanceConfig{ + ID: "oversized", BaseURL: server.URL, TokenFile: writeTokenFile(t, "token"), + }) + if err != nil { + t.Fatal(err) + } + err = client.GetAttackEvents(context.Background(), nil, &Response[map[string]any]{}) + if err == nil || !strings.Contains(err.Error(), "4 MiB limit") { + t.Fatalf("oversized response error = %v", err) + } +} diff --git a/mcp_server/internal/api/types.go b/mcp_server/internal/api/types.go deleted file mode 100644 index 5113ce2a..00000000 --- a/mcp_server/internal/api/types.go +++ /dev/null @@ -1,50 +0,0 @@ -package api - -type PolicyRuleAction int - -const ( - PolicyRuleActionAllow PolicyRuleAction = iota - PolicyRuleActionDeny - PolicyRuleActionMax -) - -type Key = string - -const ( - KeySrcIP Key = "src_ip" - KeyURI Key = "uri" - KeyURINoQuery Key = "uri_no_query" - KeyHost Key = "host" - KeyMethod Key = "method" - KeyReqHeader Key = "req_header" - KeyReqBody Key = "req_body" - KeyGetParam Key = "get_param" - KeyPostParam Key = "post_param" -) - -type Op = string - -const ( - OpEq Op = "eq" // equal - OpNotEq Op = "not_eq" // not equal - OpMatch Op = "match" // match - OpCIDR Op = "cidr" // cidr - OpHas Op = "has" // has - OpNotHas Op = "not_has" // not has - OpPrefix Op = "prefix" // prefix - OpRe Op = "re" // regex - OpIn Op = "in" // in - OpNotIn Op = "not_in" // not in - OpNotCIDR Op = "not_cidr" // not cidr - OpExist Op = "exist" // exist - OpNotExist Op = "not_exist" // not exist - OpGeoEq Op = "geo_eq" // geo equal - OpGeoNotEq Op = "geo_not_eq" // geo not equal -) - -type Pattern struct { - K Key `json:"k"` - Op Op `json:"op"` - V []string `json:"v"` - SubK string `json:"sub_k"` -} diff --git a/mcp_server/internal/auth/auth.go b/mcp_server/internal/auth/auth.go new file mode 100644 index 00000000..ad9e0f07 --- /dev/null +++ b/mcp_server/internal/auth/auth.go @@ -0,0 +1,307 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + EnabledEnv = "MCP_AUTH_ENABLED" + DisabledEnv = "MCP_AUTH_DISABLED" + StateFileEnv = "MCP_AUTH_STATE_FILE" + + DefaultStateFile = "/var/lib/safeline-mcp/auth.json" + stateVersion = 1 + tokenBytes = 32 + maxStateFileSize = 16 << 10 +) + +var ErrInvalidState = errors.New("invalid MCP authentication state") + +type state struct { + Version int `json:"version"` + TokenHash string `json:"token_hash"` + CreatedAt time.Time `json:"created_at"` +} + +// Authenticator verifies the deployment-scoped bearer token without retaining +// the plaintext token after initialization. +type Authenticator struct { + tokenHash [sha256.Size]byte +} + +// EnabledFromEnv reports whether the MCP bearer gate must be active. +// +// Authentication is mandatory unless a deployment explicitly opts out: an +// unset environment means enabled, so exposing attack telemetry by simply +// forgetting one variable is not possible. MCP_AUTH_ENABLED=false and +// MCP_AUTH_DISABLED=true both opt out; the server then refuses to listen on a +// non-loopback address. +func EnabledFromEnv() (bool, error) { + if raw := strings.TrimSpace(os.Getenv(EnabledEnv)); raw != "" { + enabled, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("parse %s: %w", EnabledEnv, err) + } + return enabled, nil + } + + if raw := strings.TrimSpace(os.Getenv(DisabledEnv)); raw != "" { + disabled, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("parse %s: %w", DisabledEnv, err) + } + return !disabled, nil + } + + return true, nil +} + +func StateFileFromEnv() string { + if path := strings.TrimSpace(os.Getenv(StateFileEnv)); path != "" { + return path + } + return DefaultStateFile +} + +// Initialize creates the authentication state once. The returned plaintext +// token must be shown to the deployer exactly once and is never written to disk. +func Initialize(path string) (token string, created bool, err error) { + if strings.TrimSpace(path) == "" { + return "", false, errors.New("authentication state file path is required") + } + + if _, err := os.Lstat(path); err == nil { + if _, err := Load(path); err != nil { + return "", false, fmt.Errorf("existing authentication state is invalid: %w", err) + } + return "", false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", false, fmt.Errorf("check authentication state: %w", err) + } + + token, s, err := newState() + if err != nil { + return "", false, err + } + created, err = writeState(path, s, false) + if err != nil { + return "", false, err + } + if !created { + // Another initializer won the no-replace install race. Validate its + // fully synced state and never expose this process's discarded token. + if _, err := Load(path); err != nil { + return "", false, fmt.Errorf("concurrently created authentication state is invalid: %w", err) + } + return "", false, nil + } + return token, true, nil +} + +// Rotate replaces an existing, valid token state. Initialization is an +// explicit separate operation so a typo cannot overwrite an unrelated path. +func Rotate(path string) (string, error) { + if _, err := Load(path); err != nil { + return "", fmt.Errorf("load existing authentication state before rotation: %w", err) + } + token, s, err := newState() + if err != nil { + return "", err + } + if _, err := writeState(path, s, true); err != nil { + return "", err + } + return token, nil +} + +func newState() (token string, s state, err error) { + token, err = generateToken() + if err != nil { + return "", state{}, err + } + + digest := sha256.Sum256([]byte(token)) + now := time.Now().UTC() + s = state{ + Version: stateVersion, + TokenHash: hex.EncodeToString(digest[:]), + CreatedAt: now, + } + return token, s, nil +} + +func generateToken() (string, error) { + random := make([]byte, tokenBytes) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate bearer token: %w", err) + } + return "slmcp_" + base64.RawURLEncoding.EncodeToString(random), nil +} + +// writeState writes a fully synced temporary file before installation. When +// replace is false, a hard-link install provides atomic create-if-absent +// semantics: concurrent initializers cannot overwrite the winning state. +func writeState(path string, s state, replace bool) (bool, error) { + if strings.TrimSpace(path) == "" { + return false, errors.New("authentication state file path is required") + } + + dir := filepath.Dir(path) + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return false, fmt.Errorf("create authentication state directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return false, fmt.Errorf("secure authentication state directory: %w", err) + } + info, err = os.Lstat(dir) + } else if err != nil { + return false, fmt.Errorf("inspect authentication state directory: %w", err) + } + if err != nil { + return false, fmt.Errorf("inspect authentication state directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, errors.New("authentication state directory path must be a real directory") + } + if info.Mode().Perm()&0o077 != 0 { + return false, errors.New("authentication state directory must have no group or other permissions") + } + + tmp, err := os.CreateTemp(dir, ".auth-*.tmp") + if err != nil { + return false, fmt.Errorf("create authentication state: %w", err) + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + + if err := tmp.Chmod(0o600); err != nil { + return false, fmt.Errorf("secure authentication state: %w", err) + } + encoder := json.NewEncoder(tmp) + encoder.SetIndent("", " ") + if err := encoder.Encode(s); err != nil { + return false, fmt.Errorf("encode authentication state: %w", err) + } + if err := tmp.Sync(); err != nil { + return false, fmt.Errorf("sync authentication state: %w", err) + } + if err := tmp.Close(); err != nil { + return false, fmt.Errorf("close authentication state: %w", err) + } + + if replace { + if err := os.Rename(tmpPath, path); err != nil { + return false, fmt.Errorf("install authentication state: %w", err) + } + return true, nil + } + + if err := os.Link(tmpPath, path); err != nil { + if errors.Is(err, os.ErrExist) { + return false, nil + } + return false, fmt.Errorf("install authentication state without replacement: %w", err) + } + return true, nil +} + +func Load(path string) (*Authenticator, error) { + dirInfo, err := os.Lstat(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("inspect authentication state directory: %w", err) + } + if dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("%w: state directory must be a real directory with no group or other permissions", ErrInvalidState) + } + + pathInfo, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("inspect authentication state: %w", err) + } + if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || pathInfo.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("%w: state file must be a regular file with no group or other permissions", ErrInvalidState) + } + + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open authentication state: %w", err) + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("inspect opened authentication state: %w", err) + } + if !os.SameFile(pathInfo, openedInfo) || !openedInfo.Mode().IsRegular() || openedInfo.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("%w: authentication state changed while opening", ErrInvalidState) + } + contents, err := io.ReadAll(io.LimitReader(file, maxStateFileSize+1)) + if err != nil { + return nil, fmt.Errorf("read authentication state: %w", err) + } + if len(contents) > maxStateFileSize { + return nil, fmt.Errorf("%w: state file exceeds the 16 KiB limit", ErrInvalidState) + } + + var s state + if err := json.Unmarshal(contents, &s); err != nil { + return nil, fmt.Errorf("%w: decode state: %v", ErrInvalidState, err) + } + if s.Version != stateVersion || s.CreatedAt.IsZero() { + return nil, ErrInvalidState + } + digest, err := hex.DecodeString(s.TokenHash) + if err != nil || len(digest) != sha256.Size { + return nil, ErrInvalidState + } + + a := &Authenticator{} + copy(a.tokenHash[:], digest) + return a, nil +} + +func (a *Authenticator) Verify(token string) bool { + if a == nil || token == "" { + return false + } + digest := sha256.Sum256([]byte(token)) + return subtle.ConstantTimeCompare(a.tokenHash[:], digest[:]) == 1 +} + +func (a *Authenticator) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok || !a.Verify(token) { + w.Header().Set("WWW-Authenticate", `Bearer realm="safeline-mcp"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func bearerToken(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return "", false + } + return parts[1], true +} diff --git a/mcp_server/internal/auth/auth_test.go b/mcp_server/internal/auth/auth_test.go new file mode 100644 index 00000000..446b0966 --- /dev/null +++ b/mcp_server/internal/auth/auth_test.go @@ -0,0 +1,286 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestInitializePersistsOnlyHashAndKeepsTokenAcrossLoads(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + + token, created, err := Initialize(path) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + if !created || !strings.HasPrefix(token, "slmcp_") { + t.Fatalf("Initialize() = (%q, %v), want newly generated prefixed token", token, created) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), token) { + t.Fatal("authentication state contains the plaintext token") + } + var persisted state + if err := json.Unmarshal(contents, &persisted); err != nil { + t.Fatal(err) + } + if persisted.TokenHash == "" { + t.Fatal("authentication state does not contain a token hash") + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("state mode = %o, want 600", got) + } + + a, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !a.Verify(token) || a.Verify(token+"wrong") { + t.Fatal("loaded authenticator did not verify exactly the initialized token") + } + + second, secondCreated, err := Initialize(path) + if err != nil { + t.Fatalf("second Initialize() error = %v", err) + } + if second != "" || secondCreated { + t.Fatalf("second Initialize() = (%q, %v), want no rotation", second, secondCreated) + } +} + +func TestRotateInvalidatesPreviousToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + oldToken, _, err := Initialize(path) + if err != nil { + t.Fatal(err) + } + newToken, err := Rotate(path) + if err != nil { + t.Fatal(err) + } + if oldToken == newToken { + t.Fatal("Rotate() returned the previous token") + } + + a, err := Load(path) + if err != nil { + t.Fatal(err) + } + if a.Verify(oldToken) || !a.Verify(newToken) { + t.Fatal("rotation did not replace the accepted token") + } +} + +func TestInitializeIsAtomicAcrossConcurrentCallers(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + const callers = 64 + + type initializeResult struct { + token string + created bool + err error + } + start := make(chan struct{}) + results := make(chan initializeResult, callers) + var group sync.WaitGroup + for range callers { + group.Add(1) + go func() { + defer group.Done() + <-start + token, created, err := Initialize(path) + results <- initializeResult{token: token, created: created, err: err} + }() + } + close(start) + group.Wait() + close(results) + + var winningToken string + createdCount := 0 + for result := range results { + if result.err != nil { + t.Fatalf("Initialize() error = %v", result.err) + } + if result.created { + createdCount++ + winningToken = result.token + } else if result.token != "" { + t.Fatalf("losing initializer exposed token %q", result.token) + } + } + if createdCount != 1 { + t.Fatalf("created count = %d, want exactly 1", createdCount) + } + a, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !a.Verify(winningToken) { + t.Fatal("persisted state does not accept the one exposed token") + } +} + +func TestInitializeRejectsExistingInvalidState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if token, created, err := Initialize(path); err == nil { + t.Fatalf("Initialize() = (%q, %v, nil), want invalid-state error", token, created) + } +} + +func TestRotateRequiresExistingValidState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "missing.json") + if token, err := Rotate(path); err == nil { + t.Fatalf("Rotate() = (%q, nil), want missing-state error", token) + } + + if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + if token, err := Rotate(path); err == nil { + t.Fatalf("Rotate(invalid) = (%q, nil), want invalid-state error", token) + } +} + +func TestLoadRejectsOverlyBroadStatePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + if _, _, err := Initialize(path); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("Load() accepted authentication state readable by other users") + } +} + +func TestLoadRejectsOversizedStateFile(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(strings.Repeat("x", maxStateFileSize+1)), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "16 KiB limit") { + t.Fatalf("Load(oversized) error = %v", err) + } +} + +func TestInitializeRejectsOverlyBroadStateDirectoryPermissions(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.json") + if _, _, err := Initialize(path); err == nil { + t.Fatal("Initialize() accepted a state directory accessible by group or other users") + } +} + +func TestMiddlewareRequiresBearerToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "auth.json") + token, _, err := Initialize(path) + if err != nil { + t.Fatal(err) + } + a, err := Load(path) + if err != nil { + t.Fatal(err) + } + + handler := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + for name, authorization := range map[string]string{ + "missing": "", + "wrong scheme": "Basic " + token, + "wrong token": "Bearer wrong", + } { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", authorization) + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", response.Code) + } + }) + } + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", response.Code) + } +} + +func TestEnabledFromEnv(t *testing.T) { + // Authentication must be mandatory when the deployment says nothing at all, + // otherwise forgetting one variable would expose attack telemetry. + t.Setenv(EnabledEnv, "") + t.Setenv(DisabledEnv, "") + enabled, err := EnabledFromEnv() + if err != nil || !enabled { + t.Fatalf("EnabledFromEnv() with unset environment = (%v, %v), want true", enabled, err) + } + + t.Setenv(EnabledEnv, "true") + enabled, err = EnabledFromEnv() + if err != nil || !enabled { + t.Fatalf("EnabledFromEnv() = (%v, %v), want true", enabled, err) + } + + t.Setenv(EnabledEnv, "not-a-bool") + if _, err := EnabledFromEnv(); err == nil { + t.Fatal("EnabledFromEnv() accepted an invalid boolean") + } + + t.Setenv(EnabledEnv, "") + t.Setenv(DisabledEnv, "true") + if enabled, err := EnabledFromEnv(); err != nil || enabled { + t.Fatalf("EnabledFromEnv() with %s=true = (%v, %v), want false", DisabledEnv, enabled, err) + } + + t.Setenv(EnabledEnv, "false") + t.Setenv(DisabledEnv, "") + if enabled, err := EnabledFromEnv(); err != nil || enabled { + t.Fatalf("EnabledFromEnv() with %s=false = (%v, %v), want false", EnabledEnv, enabled, err) + } + + t.Setenv(EnabledEnv, "") + t.Setenv(DisabledEnv, "not-a-bool") + if _, err := EnabledFromEnv(); err == nil { + t.Fatal("EnabledFromEnv() accepted an invalid disable boolean") + } +} diff --git a/mcp_server/internal/config/config.go b/mcp_server/internal/config/config.go index 30a3dd7b..51c0aec4 100644 --- a/mcp_server/internal/config/config.go +++ b/mcp_server/internal/config/config.go @@ -1,44 +1,49 @@ package config import ( + "bytes" + "fmt" "os" + "path/filepath" "strconv" + "strings" "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "gopkg.in/yaml.v3" ) -// Config Global configuration structure +// Config is the complete MCP server configuration. type Config struct { - Server *ServerConfig `yaml:"server"` - Logger *LoggerConfig `yaml:"logger"` - API *APIConfig `yaml:"api"` + Server *ServerConfig `yaml:"server"` + Logger *LoggerConfig `yaml:"logger"` + Instances []*InstanceConfig `yaml:"instances"` } -// APIConfig API configuration -type APIConfig struct { - // API base URL - BaseURL string `yaml:"base_url"` - // API token - Token string `yaml:"token"` - // API timeout - Timeout int `yaml:"timeout"` - // API debug mode - Debug bool `yaml:"debug"` - // API insecure skip verify - InsecureSkipVerify bool `yaml:"insecure_skip_verify"` +// InstanceConfig identifies one independently authenticated SafeLine instance. +// The API token is deliberately referenced through a file instead of being +// embedded in the main configuration file. +type InstanceConfig struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + BaseURL string `yaml:"base_url"` + TokenFile string `yaml:"token_file"` + CAFile string `yaml:"ca_file"` + Timeout int `yaml:"timeout"` + Debug bool `yaml:"debug"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` } -// ServerConfig Server configuration +// ServerConfig is the MCP server listener configuration. type ServerConfig struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Port int `yaml:"port"` - Host string `yaml:"host"` - Secret string `yaml:"secret"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Port int `yaml:"port"` + Host string `yaml:"host"` + TLSCertFile string `yaml:"tls_cert_file"` + TLSKeyFile string `yaml:"tls_key_file"` } -// LoggerConfig Logger configuration +// LoggerConfig is the logging configuration. type LoggerConfig struct { Level string `yaml:"level"` FilePath string `yaml:"file_path"` @@ -49,7 +54,6 @@ type LoggerConfig struct { var config *Config -// getEnvString Get string value from environment variable, return default value if not exists func getEnvString(key, defaultValue string) string { if value, exists := os.LookupEnv(key); exists { return value @@ -57,7 +61,6 @@ func getEnvString(key, defaultValue string) string { return defaultValue } -// getEnvInt Get integer value from environment variable, return default value if not exists or cannot be parsed func getEnvInt(key string, defaultValue int) int { if value, exists := os.LookupEnv(key); exists { if intValue, err := strconv.Atoi(value); err == nil { @@ -67,33 +70,141 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } -// Load Load configuration file +// Load reads and validates the configuration file. Relative token_file paths +// are resolved from the directory containing the configuration file so that a +// deployment does not depend on the server process working directory. func Load(path string) error { data, err := os.ReadFile(path) if err != nil { return errors.Wrap(err, "read config file failed") } - config = &Config{} - if err := yaml.Unmarshal(data, config); err != nil { + next := &Config{} + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(next); err != nil { return errors.Wrap(err, "unmarshal config failed") } - // Override configuration from environment variables - if config.Server != nil { - config.Server.Host = getEnvString("LISTEN_ADDRESS", config.Server.Host) - config.Server.Port = getEnvInt("LISTEN_PORT", config.Server.Port) + if next.Server != nil { + next.Server.Host = getEnvString("LISTEN_ADDRESS", next.Server.Host) + next.Server.Port = getEnvInt("LISTEN_PORT", next.Server.Port) + // Empty compose values must not wipe yaml paths. + if cert := strings.TrimSpace(os.Getenv("MCP_TLS_CERT")); cert != "" { + next.Server.TLSCertFile = cert + } else { + next.Server.TLSCertFile = strings.TrimSpace(next.Server.TLSCertFile) + } + if key := strings.TrimSpace(os.Getenv("MCP_TLS_KEY")); key != "" { + next.Server.TLSKeyFile = key + } else { + next.Server.TLSKeyFile = strings.TrimSpace(next.Server.TLSKeyFile) + } + } + + if next.Server == nil { + return errors.New("server configuration is required") + } + if strings.TrimSpace(next.Server.Name) == "" { + return errors.New("server.name is required") + } + if strings.TrimSpace(next.Server.Version) == "" { + return errors.New("server.version is required") + } + if strings.TrimSpace(next.Server.Host) == "" { + return errors.New("server.host is required") + } + if next.Server.Port < 1 || next.Server.Port > 65535 { + return errors.New("server.port must be between 1 and 65535") + } + if next.Logger == nil { + return errors.New("logger configuration is required") + } + + if err := normalizeInstances(next, filepath.Dir(path)); err != nil { + return err + } + + config = next + return nil +} + +func normalizeInstances(cfg *Config, configDir string) error { + if len(cfg.Instances) == 0 { + return errors.New("at least one SafeLine instance is required") } - if config.API != nil { - config.API.BaseURL = getEnvString("SAFELINE_ADDRESS", config.API.BaseURL) - config.API.Token = getEnvString("SAFELINE_API_TOKEN", config.API.Token) + seen := make(map[string]struct{}, len(cfg.Instances)) + for index, instance := range cfg.Instances { + if instance == nil { + return errors.New(fmt.Sprintf("instances[%d] is required", index)) + } + + instance.ID = strings.TrimSpace(instance.ID) + instance.DisplayName = strings.TrimSpace(instance.DisplayName) + instance.BaseURL = strings.TrimSpace(instance.BaseURL) + instance.TokenFile = strings.TrimSpace(instance.TokenFile) + instance.CAFile = strings.TrimSpace(instance.CAFile) + if instance.ID == "" { + return errors.New(fmt.Sprintf("instances[%d].id is required", index)) + } + if _, ok := seen[instance.ID]; ok { + return errors.New(fmt.Sprintf("duplicate instance id %q", instance.ID)) + } + seen[instance.ID] = struct{}{} + + if instance.DisplayName == "" { + instance.DisplayName = instance.ID + } + if instance.BaseURL == "" { + return errors.New(fmt.Sprintf("instances[%d].base_url is required", index)) + } + if instance.TokenFile == "" { + return errors.New(fmt.Sprintf("instances[%d].token_file is required", index)) + } + if instance.TokenFile != "" && !filepath.IsAbs(instance.TokenFile) { + instance.TokenFile = filepath.Clean(filepath.Join(configDir, instance.TokenFile)) + } + if instance.CAFile != "" && !filepath.IsAbs(instance.CAFile) { + instance.CAFile = filepath.Clean(filepath.Join(configDir, instance.CAFile)) + } + + if instance.InsecureSkipVerify { + return errors.New(fmt.Sprintf( + "instances[%d].insecure_skip_verify is not supported; set ca_file to trust a self-signed instance certificate", + index, + )) + } + } + + for index, instance := range cfg.Instances { + for previousIndex := 0; previousIndex < index; previousIndex++ { + previous := cfg.Instances[previousIndex] + if strings.EqualFold(instance.DisplayName, previous.DisplayName) { + return errors.New(fmt.Sprintf( + "duplicate instance display_name %q for instances %q and %q", + instance.DisplayName, + previous.ID, + instance.ID, + )) + } + } + for otherIndex, other := range cfg.Instances { + if otherIndex != index && strings.EqualFold(instance.DisplayName, other.ID) { + return errors.New(fmt.Sprintf( + "instance display_name %q for %q conflicts with instance id %q", + instance.DisplayName, + instance.ID, + other.ID, + )) + } + } } return nil } -// GetServer Get server configuration +// GetServer returns the listener configuration. func GetServer() *ServerConfig { if config == nil { return nil @@ -101,7 +212,7 @@ func GetServer() *ServerConfig { return config.Server } -// GetLogger Get logger configuration +// GetLogger returns the logging configuration. func GetLogger() *LoggerConfig { if config == nil { return nil @@ -109,10 +220,10 @@ func GetLogger() *LoggerConfig { return config.Logger } -// GetAPI Get API configuration -func GetAPI() *APIConfig { +// GetInstances returns all configured SafeLine instances. +func GetInstances() []*InstanceConfig { if config == nil { return nil } - return config.API + return config.Instances } diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go new file mode 100644 index 00000000..53253e5b --- /dev/null +++ b/mcp_server/internal/config/config_test.go @@ -0,0 +1,284 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfigFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestLoadMultiInstanceConfiguration(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + display_name: Development 152 + base_url: https://dev152:9443 + token_file: secrets/dev152_token + timeout: 12 + insecure_skip_verify: false + - id: dev180 + base_url: https://dev180:9443 + token_file: /run/secrets/dev180_token +`) + + if err := Load(path); err != nil { + t.Fatalf("Load() error = %v", err) + } + instances := GetInstances() + if len(instances) != 2 { + t.Fatalf("len(GetInstances()) = %d, want 2", len(instances)) + } + if instances[0].ID != "dev152" || instances[0].DisplayName != "Development 152" { + t.Fatalf("first instance = %#v", instances[0]) + } + wantRelativePath := filepath.Join(filepath.Dir(path), "secrets", "dev152_token") + if instances[0].TokenFile != wantRelativePath { + t.Errorf("relative token_file = %q, want %q", instances[0].TokenFile, wantRelativePath) + } + if instances[1].DisplayName != "dev180" { + t.Errorf("default display_name = %q, want dev180", instances[1].DisplayName) + } + if instances[1].TokenFile != "/run/secrets/dev180_token" { + t.Errorf("absolute token_file = %q", instances[1].TokenFile) + } +} + +func TestLoadRejectsDuplicateInstanceIDs(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: duplicate + base_url: https://one.example.test + token_file: one_token + - id: duplicate + base_url: https://two.example.test + token_file: two_token +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "duplicate instance id") { + t.Fatalf("Load() error = %v, want duplicate instance id", err) + } +} + +func TestLoadRejectsDuplicateDisplayNames(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: production-a + display_name: Production + base_url: https://one.example.test + token_file: one_token + - id: production-b + display_name: " production " + base_url: https://two.example.test + token_file: two_token +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "duplicate instance display_name") { + t.Fatalf("Load() error = %v, want duplicate instance display_name", err) + } +} + +func TestLoadRejectsDisplayNameConflictingWithAnotherInstanceID(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: production-a + display_name: PRODUCTION-B + base_url: https://one.example.test + token_file: one_token + - id: production-b + display_name: Backup Production + base_url: https://two.example.test + token_file: two_token +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "conflicts with instance id") { + t.Fatalf("Load() error = %v, want display_name and instance id conflict", err) + } +} + +func TestLoadRejectsInlineInstanceToken(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +instances: + - id: unsafe + base_url: https://unsafe.example.test + token: must-not-be-accepted +`) + + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "field token not found") { + t.Fatalf("Load() error = %v, want unknown inline token field", err) + } +} + +func TestLoadRequiresAtLeastOneInstance(t *testing.T) { + path := writeConfigFile(t, ` +server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678} +logger: {level: info} +`) + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "at least one SafeLine instance") { + t.Fatalf("Load() error = %v, want missing instance error", err) + } +} + +func TestLoadResolvesCAFileRelativeToTheConfiguration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + contents := ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: secrets/dev152_token + ca_file: certs/instance.crt +` + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + if err := Load(path); err != nil { + t.Fatalf("load: %v", err) + } + + instances := GetInstances() + if len(instances) != 1 { + t.Fatalf("got %d instances, want 1", len(instances)) + } + want := filepath.Join(dir, "certs", "instance.crt") + if instances[0].CAFile != want { + t.Fatalf("ca_file is %q, want %q", instances[0].CAFile, want) + } +} + +func TestLoadRejectsCAFileTogetherWithSkippedVerification(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token + ca_file: /etc/safeline/instance.crt + insecure_skip_verify: true +`) + + err := Load(path) + if err == nil { + t.Fatal("a configuration that both trusts an authority and skips verification was accepted") + } + if !strings.Contains(err.Error(), "ca_file") { + t.Fatalf("the error does not name the setting: %v", err) + } +} + +func TestLoadRejectsInsecureSkipVerify(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token + insecure_skip_verify: true +`) + err := Load(path) + if err == nil || !strings.Contains(err.Error(), "insecure_skip_verify is not supported") { + t.Fatalf("Load() error = %v", err) + } +} + +func TestLoadTLSCertFromEnv(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 + tls_cert_file: /yaml/cert.pem + tls_key_file: /yaml/key.pem +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +`) + t.Setenv("MCP_TLS_CERT", "/env/cert.pem") + t.Setenv("MCP_TLS_KEY", "/env/key.pem") + if err := Load(path); err != nil { + t.Fatal(err) + } + server := GetServer() + if server.TLSCertFile != "/env/cert.pem" || server.TLSKeyFile != "/env/key.pem" { + t.Fatalf("tls paths = %q %q", server.TLSCertFile, server.TLSKeyFile) + } +} + +func TestLoadEmptyTLSEnvDoesNotWipeYAML(t *testing.T) { + path := writeConfigFile(t, ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 127.0.0.1 + port: 5678 + tls_cert_file: /yaml/cert.pem + tls_key_file: /yaml/key.pem +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +`) + t.Setenv("MCP_TLS_CERT", "") + t.Setenv("MCP_TLS_KEY", "") + if err := Load(path); err != nil { + t.Fatal(err) + } + server := GetServer() + if server.TLSCertFile != "/yaml/cert.pem" || server.TLSKeyFile != "/yaml/key.pem" { + t.Fatalf("tls paths = %q %q", server.TLSCertFile, server.TLSKeyFile) + } +} diff --git a/mcp_server/internal/tools/analyze/get_atttack_events.go b/mcp_server/internal/tools/analyze/get_atttack_events.go index a0ec1b30..20e0750c 100644 --- a/mcp_server/internal/tools/analyze/get_atttack_events.go +++ b/mcp_server/internal/tools/analyze/get_atttack_events.go @@ -2,17 +2,24 @@ package analyze import ( "context" + "fmt" + "strings" "github.com/chaitin/SafeLine/mcp_server/internal/api/analyze" + "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" + markmcp "github.com/mark3labs/mcp-go/mcp" ) type GetAttackEventsParams struct { - IP string `json:"ip" desc:"ip" required:"false"` - Page int `json:"page" desc:"page" required:"false" default:"1"` - PageSize int `json:"page_size" desc:"page size" required:"false" default:"10"` - Start int64 `json:"start" desc:"start unix timestamp in milliseconds" required:"false"` - End int64 `json:"end" desc:"end unix timestamp in milliseconds" required:"false"` + InstanceID string `json:"instance_id" jsonschema_description:"Configured SafeLine instance ID"` + Page int `json:"page,omitempty" jsonschema_description:"Page number, starting at 1" default:"1"` + PageSize int `json:"page_size,omitempty" jsonschema_description:"Events per page, from 1 through 100" default:"10"` + IP string `json:"ip,omitempty" jsonschema_description:"Source IP address, CIDR, or partial IP filter"` + Host string `json:"host,omitempty" jsonschema_description:"Protected host name filter"` + Port int `json:"port,omitempty" jsonschema_description:"Protected destination port filter, from 1 through 65535"` + Start int64 `json:"start,omitempty" jsonschema_description:"Start Unix timestamp in milliseconds"` + End int64 `json:"end,omitempty" jsonschema_description:"End Unix timestamp in milliseconds"` } type GetAttackEvents struct{} @@ -22,23 +29,82 @@ func (t *GetAttackEvents) Name() string { } func (t *GetAttackEvents) Description() string { - return "get attack events" + return "Query aggregated attack events from one configured SafeLine instance" +} + +func (t *GetAttackEvents) InputSchemaOptions() []markmcp.ToolOption { + return []markmcp.ToolOption{ + markmcp.WithString("instance_id", + markmcp.Required(), + markmcp.MinLength(1), + markmcp.Description("Configured SafeLine instance ID"), + ), + markmcp.WithInteger("page", + markmcp.Min(1), + markmcp.DefaultNumber(1), + markmcp.Description("Page number, starting at 1"), + ), + markmcp.WithInteger("page_size", + markmcp.Min(1), + markmcp.Max(100), + markmcp.DefaultNumber(10), + markmcp.Description("Events per page, from 1 through 100"), + ), + markmcp.WithString("ip", markmcp.Description("Source IP address, CIDR, or partial IP filter")), + markmcp.WithString("host", markmcp.Description("Protected host name filter")), + markmcp.WithInteger("port", + markmcp.Min(1), + markmcp.Max(65535), + markmcp.Description("Protected destination port filter, from 1 through 65535"), + ), + markmcp.WithInteger("start", + markmcp.Min(0), + markmcp.Description("Start Unix timestamp in milliseconds"), + ), + markmcp.WithInteger("end", + markmcp.Min(0), + markmcp.Description("End Unix timestamp in milliseconds"), + ), + markmcp.WithSchemaAdditionalProperties(false), + } } func (t *GetAttackEvents) Validate(params GetAttackEventsParams) error { + if strings.TrimSpace(params.InstanceID) == "" { + return errors.New("instance_id is required") + } + if params.Page < 1 { + return errors.New("page must be at least 1") + } + if params.PageSize < 1 || params.PageSize > 100 { + return errors.New("page_size must be between 1 and 100") + } + if params.Port < 0 || params.Port > 65535 { + return errors.New("port must be between 1 and 65535 when provided") + } + if params.Start < 0 || params.End < 0 { + return errors.New("start and end must be non-negative Unix timestamps") + } + if params.Start > 0 && params.End > 0 && params.Start > params.End { + return errors.New("start must be less than or equal to end") + } return nil } func (t *GetAttackEvents) Execute(ctx context.Context, params GetAttackEventsParams) (analyze.GetEventListResponse, error) { resp, err := analyze.GetEventList(ctx, &analyze.GetEventListRequest{ - IP: params.IP, - PageSize: params.PageSize, - Page: params.Page, - Start: params.Start, - End: params.End, + InstanceID: strings.TrimSpace(params.InstanceID), + Page: params.Page, + PageSize: params.PageSize, + IP: strings.TrimSpace(params.IP), + Host: strings.TrimSpace(params.Host), + Port: params.Port, + Start: params.Start, + End: params.End, }) if err != nil { - return analyze.GetEventListResponse{}, err + logger.With("instance_id", strings.TrimSpace(params.InstanceID)).With("error", err).Error("SafeLine event query failed") + return analyze.GetEventListResponse{}, fmt.Errorf("SafeLine query failed for instance %q", strings.TrimSpace(params.InstanceID)) } logger.With("total", resp.Total).Info("get attack events") return *resp, nil diff --git a/mcp_server/internal/tools/analyze/get_atttack_events_test.go b/mcp_server/internal/tools/analyze/get_atttack_events_test.go new file mode 100644 index 00000000..d825156d --- /dev/null +++ b/mcp_server/internal/tools/analyze/get_atttack_events_test.go @@ -0,0 +1,77 @@ +package analyze + +import ( + "encoding/json" + "testing" + + markmcp "github.com/mark3labs/mcp-go/mcp" +) + +func TestGetAttackEventsValidate(t *testing.T) { + tool := &GetAttackEvents{} + valid := GetAttackEventsParams{InstanceID: "dev152", Page: 1, PageSize: 10} + if err := tool.Validate(valid); err != nil { + t.Fatalf("Validate(valid) error = %v", err) + } + + tests := []struct { + name string + mutate func(*GetAttackEventsParams) + }{ + {name: "missing instance", mutate: func(p *GetAttackEventsParams) { p.InstanceID = " " }}, + {name: "page zero", mutate: func(p *GetAttackEventsParams) { p.Page = 0 }}, + {name: "page size zero", mutate: func(p *GetAttackEventsParams) { p.PageSize = 0 }}, + {name: "page size too large", mutate: func(p *GetAttackEventsParams) { p.PageSize = 101 }}, + {name: "negative port", mutate: func(p *GetAttackEventsParams) { p.Port = -1 }}, + {name: "port too large", mutate: func(p *GetAttackEventsParams) { p.Port = 65536 }}, + {name: "negative start", mutate: func(p *GetAttackEventsParams) { p.Start = -1 }}, + {name: "negative end", mutate: func(p *GetAttackEventsParams) { p.End = -1 }}, + {name: "reversed range", mutate: func(p *GetAttackEventsParams) { p.Start, p.End = 2, 1 }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + params := valid + test.mutate(¶ms) + if err := tool.Validate(params); err == nil { + t.Fatal("Validate() error = nil, want validation error") + } + }) + } +} + +func TestGetAttackEventsV1InputSchema(t *testing.T) { + implementation := &GetAttackEvents{} + tool := markmcp.NewTool("get_attack_events", implementation.InputSchemaOptions()...) + var schema struct { + Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` + AdditionalProperties bool `json:"additionalProperties"` + } + encoded, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("marshal input schema: %v", err) + } + if err := json.Unmarshal(encoded, &schema); err != nil { + t.Fatalf("unmarshal input schema: %v", err) + } + if len(schema.Required) != 1 || schema.Required[0] != "instance_id" { + t.Fatalf("required = %#v, want only instance_id", schema.Required) + } + for _, field := range []string{"page", "page_size", "port", "start", "end"} { + if got := schema.Properties[field]["type"]; got != "integer" { + t.Errorf("%s type = %v, want integer", field, got) + } + } + if got := schema.Properties["page"]["minimum"]; got != float64(1) { + t.Errorf("page minimum = %v, want 1", got) + } + if got := schema.Properties["page_size"]["maximum"]; got != float64(100) { + t.Errorf("page_size maximum = %v, want 100", got) + } + if got := schema.Properties["port"]["maximum"]; got != float64(65535) { + t.Errorf("port maximum = %v, want 65535", got) + } + if schema.AdditionalProperties { + t.Error("additionalProperties = true, want false") + } +} diff --git a/mcp_server/internal/tools/app/create_application.go b/mcp_server/internal/tools/app/create_application.go deleted file mode 100644 index 96809ab2..00000000 --- a/mcp_server/internal/tools/app/create_application.go +++ /dev/null @@ -1,41 +0,0 @@ -package app - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api/app" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CreateApp struct{} - -type CreateAppParams struct { - ServerNames []string `json:"server_names" desc:"domain list" required:"true"` - Ports []string `json:"ports" desc:"port list" required:"true"` - Upstreams []string `json:"upstreams" desc:"upstream list" required:"true"` -} - -func (t *CreateApp) Name() string { - return "create_http_application" -} - -func (t *CreateApp) Description() string { - return "create a new website or app" -} - -func (t *CreateApp) Validate(params CreateAppParams) error { - return nil -} - -func (t *CreateApp) Execute(ctx context.Context, params CreateAppParams) (int64, error) { - id, err := app.CreateApp(ctx, &app.CreateAppRequest{ - ServerNames: params.ServerNames, - Ports: params.Ports, - Upstreams: params.Upstreams, - }) - if err != nil { - return 0, err - } - logger.Info("create app success", logger.Int64("id", id)) - return id, nil -} diff --git a/mcp_server/internal/tools/example.go b/mcp_server/internal/tools/example.go deleted file mode 100644 index d482bc2c..00000000 --- a/mcp_server/internal/tools/example.go +++ /dev/null @@ -1,46 +0,0 @@ -package tools - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CalculateSum struct{} - -func (t *CalculateSum) Name() string { - return "calculate_sum" -} - -func (t *CalculateSum) Description() string { - return "Add two numbers together" -} - -type MyToolInput struct { - A int `json:"a" desc:"number a" required:"true"` - B int `json:"b" desc:"number b" required:"true"` -} - -type MyToolOutput struct { - C int `json:"c"` -} - -func (t *CalculateSum) Validate(params MyToolInput) error { - return nil -} - -func (t *CalculateSum) Execute(ctx context.Context, params MyToolInput) (MyToolOutput, error) { - logger.With("a", params.A). - With("b", params.B). - Debug("Executing calculation") - - result := MyToolOutput{ - C: params.A + params.B, - } - - logger.With("result", result.C). - Debug("Calculation completed") - - return result, errors.New("test error") -} diff --git a/mcp_server/internal/tools/init.go b/mcp_server/internal/tools/init.go index 03f850c9..a8a14825 100644 --- a/mcp_server/internal/tools/init.go +++ b/mcp_server/internal/tools/init.go @@ -2,18 +2,8 @@ package tools import ( "github.com/chaitin/SafeLine/mcp_server/internal/tools/analyze" - "github.com/chaitin/SafeLine/mcp_server/internal/tools/app" - "github.com/chaitin/SafeLine/mcp_server/internal/tools/rule" ) func init() { - // app - AppendTool(&app.CreateApp{}) - - // rule - AppendTool(&rule.CreateBlacklistRule{}) - AppendTool(&rule.CreateWhitelistRule{}) - - // analyze AppendTool(&analyze.GetAttackEvents{}) } diff --git a/mcp_server/internal/tools/init_test.go b/mcp_server/internal/tools/init_test.go new file mode 100644 index 00000000..e22e832b --- /dev/null +++ b/mcp_server/internal/tools/init_test.go @@ -0,0 +1,13 @@ +package tools + +import "testing" + +func TestOnlyReadOnlyAttackEventsToolIsRegistered(t *testing.T) { + registered := Tools() + if len(registered) != 1 { + t.Fatalf("registered tool count = %d, want 1", len(registered)) + } + if got := registered[0].Name(); got != "get_attack_events" { + t.Fatalf("registered tool = %q, want get_attack_events", got) + } +} diff --git a/mcp_server/internal/tools/rule/create_blacklist_rule.go b/mcp_server/internal/tools/rule/create_blacklist_rule.go deleted file mode 100644 index 3e7b81b6..00000000 --- a/mcp_server/internal/tools/rule/create_blacklist_rule.go +++ /dev/null @@ -1,66 +0,0 @@ -package rule - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/internal/api/rule" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CreateBlacklistRule struct{} - -type CreateBlacklistRuleParams struct { - Name string `json:"name" desc:"name" required:"true"` - IP []string `json:"ip" desc:"ip" required:"false"` - URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"` -} - -func (t *CreateBlacklistRule) Name() string { - return "create_blacklist_rule" -} - -func (t *CreateBlacklistRule) Description() string { - return "create a new blacklist rule" -} - -func (t *CreateBlacklistRule) Validate(params CreateBlacklistRuleParams) error { - return nil -} - -func (t *CreateBlacklistRule) Execute(ctx context.Context, params CreateBlacklistRuleParams) (int64, error) { - var pattern [][]api.Pattern - if len(params.IP) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeySrcIP, - Op: api.OpEq, - V: params.IP, - SubK: "", - }, - }) - } - if len(params.URINoQuery) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeyURINoQuery, - Op: api.OpEq, - V: params.URINoQuery, - SubK: "", - }, - }) - } - - id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{ - Name: params.Name, - IP: params.IP, - IsEnabled: true, - Action: int(api.PolicyRuleActionDeny), - Pattern: pattern, - }) - if err != nil { - return 0, err - } - logger.With("id", id).Info("create blacklist rule success") - return id, nil -} diff --git a/mcp_server/internal/tools/rule/create_whitelist_rule.go b/mcp_server/internal/tools/rule/create_whitelist_rule.go deleted file mode 100644 index 4b83f842..00000000 --- a/mcp_server/internal/tools/rule/create_whitelist_rule.go +++ /dev/null @@ -1,66 +0,0 @@ -package rule - -import ( - "context" - - "github.com/chaitin/SafeLine/mcp_server/internal/api" - "github.com/chaitin/SafeLine/mcp_server/internal/api/rule" - "github.com/chaitin/SafeLine/mcp_server/pkg/logger" -) - -type CreateWhitelistRule struct{} - -type CreateWhitelistRuleParams struct { - Name string `json:"name" desc:"name" required:"true"` - IP []string `json:"ip" desc:"ip" required:"false"` - URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"` -} - -func (t *CreateWhitelistRule) Name() string { - return "create_whitelist_rule" -} - -func (t *CreateWhitelistRule) Description() string { - return "create a new whitelist rule" -} - -func (t *CreateWhitelistRule) Validate(params CreateWhitelistRuleParams) error { - return nil -} - -func (t *CreateWhitelistRule) Execute(ctx context.Context, params CreateWhitelistRuleParams) (int64, error) { - var pattern [][]api.Pattern - if len(params.IP) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeySrcIP, - Op: api.OpEq, - V: params.IP, - SubK: "", - }, - }) - } - if len(params.URINoQuery) > 0 { - pattern = append(pattern, []api.Pattern{ - { - K: api.KeyURINoQuery, - Op: api.OpEq, - V: params.URINoQuery, - SubK: "", - }, - }) - } - - id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{ - Name: params.Name, - IP: params.IP, - IsEnabled: true, - Action: int(api.PolicyRuleActionAllow), - Pattern: pattern, - }) - if err != nil { - return 0, err - } - logger.With("id", id).Info("create whitelist rule success") - return id, nil -} diff --git a/mcp_server/internal/tools/tool.go b/mcp_server/internal/tools/tool.go index 6a57c420..ad88eada 100644 --- a/mcp_server/internal/tools/tool.go +++ b/mcp_server/internal/tools/tool.go @@ -11,6 +11,7 @@ import ( // Each Tool is wrapped in a toolWrapper that knows its concrete type, // allowing correct passing of generic parameters during registration. type ToolWrapper interface { + Name() string Register(s *mcp.MCPServer) error } @@ -32,6 +33,10 @@ type toolWrapper[T any, R any] struct { tool mcp.Tool[T, R] } +func (w *toolWrapper[T, R]) Name() string { + return w.tool.Name() +} + func (w *toolWrapper[T, R]) Register(s *mcp.MCPServer) error { logger.Info("Registering tool", logger.String("name", w.tool.Name()), diff --git a/mcp_server/main.go b/mcp_server/main.go index 9a8dcb09..28cac8f4 100644 --- a/mcp_server/main.go +++ b/mcp_server/main.go @@ -3,20 +3,121 @@ package main import ( "flag" "fmt" + "io" + "net" + "os" + "strings" "github.com/chaitin/SafeLine/mcp_server/internal/api" + "github.com/chaitin/SafeLine/mcp_server/internal/auth" "github.com/chaitin/SafeLine/mcp_server/internal/config" "github.com/chaitin/SafeLine/mcp_server/internal/tools" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" - "github.com/chaitin/SafeLine/mcp_server/pkg/mcp" + mcpserver "github.com/chaitin/SafeLine/mcp_server/pkg/mcp" ) func main() { - configPath := flag.String("config", "config.yaml", "path to config file") - flag.Parse() + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if len(args) > 0 && args[0] == "auth" { + return runAuth(args[1:], stdout, stderr) + } + return runServer(args, stderr) +} + +func runAuth(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("usage: mcp-server auth [--state-file path]") + } + + command := args[0] + flags := flag.NewFlagSet("auth "+command, flag.ContinueOnError) + flags.SetOutput(stderr) + stateFile := flags.String("state-file", auth.StateFileFromEnv(), "authentication state file") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected auth arguments: %s", strings.Join(flags.Args(), " ")) + } + + switch command { + case "init": + token, created, err := auth.Initialize(*stateFile) + if err != nil { + return err + } + if !created { + _, _ = fmt.Fprintf(stdout, "MCP authentication is already initialized at %s; token was not changed.\n", *stateFile) + return nil + } + printNewToken(stdout, token) + return nil + case "rotate": + token, err := auth.Rotate(*stateFile) + if err != nil { + return err + } + printNewToken(stdout, token) + return nil + default: + return fmt.Errorf("unknown auth command %q; expected init or rotate", command) + } +} + +func printNewToken(output io.Writer, token string) { + _, _ = fmt.Fprintln(output, "MCP bearer token (shown once; save it now):") + _, _ = fmt.Fprintln(output, token) +} + +func buildServerInstructions(instances []*config.InstanceConfig) string { + var instructions strings.Builder + instructions.WriteString("This server exposes read-only SafeLine data. The get_attack_events tool requires an instance_id.\n") + instructions.WriteString("Configured SafeLine instance mappings:\n") + for _, instance := range instances { + instructions.WriteString(fmt.Sprintf( + "- display_name %q -> instance_id %q\n", + instance.DisplayName, + instance.ID, + )) + } + instructions.WriteString("When the user refers to an instance by display_name, use exactly the mapped instance_id. Never guess an instance_id or substitute another instance.") + return instructions.String() +} + +// isLoopbackHost reports whether host only accepts connections from the local +// machine. Anything else (an interface address, 0.0.0.0, a hostname) is +// reachable from the network. +func isLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + +func runServer(args []string, stderr io.Writer) error { + flags := flag.NewFlagSet("mcp-server", flag.ContinueOnError) + flags.SetOutput(stderr) + configPath := flags.String("config", "config.yaml", "path to config file") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } if err := config.Load(*configPath); err != nil { - panic(fmt.Errorf("failed to load config: %v", err)) + return fmt.Errorf("load config: %w", err) } logConfig := config.GetLogger() @@ -27,36 +128,60 @@ func main() { Caller: logConfig.Caller, Development: logConfig.Development, }); err != nil { - panic(fmt.Errorf("failed to init logger: %v", err)) + return fmt.Errorf("initialize logger: %w", err) } - logger.With("base_url", config.GetAPI().BaseURL).Info("Initializing API service...") - if err := api.Init(config.GetAPI()); err != nil { - panic(fmt.Errorf("failed to init API service: %v", err)) + serverConfig := config.GetServer() + authEnabled, err := auth.EnabledFromEnv() + if err != nil { + return err + } + if !authEnabled && !isLoopbackHost(serverConfig.Host) { + return fmt.Errorf( + "MCP authentication is disabled but the listener is not loopback-only (host %q); keep authentication enabled or bind a loopback address", + serverConfig.Host, + ) + } + certFile := strings.TrimSpace(serverConfig.TLSCertFile) + keyFile := strings.TrimSpace(serverConfig.TLSKeyFile) + if (certFile == "") != (keyFile == "") { + return fmt.Errorf("MCP_TLS_CERT and MCP_TLS_KEY (or tls_cert_file and tls_key_file) must be set together") + } + if !isLoopbackHost(serverConfig.Host) && certFile == "" { + return fmt.Errorf( + "non-loopback MCP listener %q requires TLS; set MCP_TLS_CERT and MCP_TLS_KEY", + serverConfig.Host, + ) } - logger.Info("Starting MCP Server...") - serverConfig := config.GetServer() - s := mcp.NewMCPServer( - serverConfig.Name, - serverConfig.Version, - serverConfig.Secret, - ) + instances := config.GetInstances() + logger.With("instances", len(instances)).Info("initializing SafeLine API clients") + if err := api.InitInstances(instances); err != nil { + return fmt.Errorf("initialize SafeLine API clients: %w", err) + } + + var middleware []mcpserver.Middleware + if authEnabled { + stateFile := auth.StateFileFromEnv() + authenticator, err := auth.Load(stateFile) + if err != nil { + return fmt.Errorf("MCP authentication is enabled but not initialized; run `mcp-server auth init --state-file %s`: %w", stateFile, err) + } + middleware = append(middleware, mcpserver.Middleware(authenticator.Middleware)) + } + logger.With("enabled", authEnabled).Info("MCP bearer authentication configured") - logger.Info("Registering tools...") + s := mcpserver.NewMCPServer(serverConfig.Name, serverConfig.Version, buildServerInstructions(instances)) for _, tool := range tools.Tools() { if err := tool.Register(s); err != nil { - logger.With("error", err). - Error("Failed to register tool") - panic(err) + return fmt.Errorf("register tool: %w", err) } } addr := fmt.Sprintf("%s:%d", serverConfig.Host, serverConfig.Port) - logger.With("addr", addr).Info("Starting server") - if err := s.Start(addr); err != nil { - logger.With("error", err). - Error("Server failed to start") - panic(err) + logger.With("addr", addr).With("endpoint", mcpserver.EndpointPath).Info("starting MCP server") + if certFile != "" { + return s.StartTLS(addr, certFile, keyFile, middleware...) } + return s.Start(addr, middleware...) } diff --git a/mcp_server/main_test.go b/mcp_server/main_test.go new file mode 100644 index 00000000..bbdc889c --- /dev/null +++ b/mcp_server/main_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/internal/auth" + "github.com/chaitin/SafeLine/mcp_server/internal/config" +) + +func TestAuthInitIsOneTimeAndRotateIsExplicit(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "state", "auth.json") + var first bytes.Buffer + if err := run([]string{"auth", "init", "--state-file", stateFile}, &first, &bytes.Buffer{}); err != nil { + t.Fatalf("auth init error = %v", err) + } + firstToken := lastLine(first.String()) + if !strings.HasPrefix(firstToken, "slmcp_") { + t.Fatalf("init output did not contain generated token: %q", first.String()) + } + + contents, err := os.ReadFile(stateFile) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), firstToken) { + t.Fatal("state file contains plaintext token") + } + + var second bytes.Buffer + if err := run([]string{"auth", "init", "--state-file", stateFile}, &second, &bytes.Buffer{}); err != nil { + t.Fatalf("second auth init error = %v", err) + } + if strings.Contains(second.String(), "slmcp_") { + t.Fatalf("second init exposed or generated a token: %q", second.String()) + } + + var rotated bytes.Buffer + if err := run([]string{"auth", "rotate", "--state-file", stateFile}, &rotated, &bytes.Buffer{}); err != nil { + t.Fatalf("auth rotate error = %v", err) + } + rotatedToken := lastLine(rotated.String()) + if rotatedToken == firstToken || !strings.HasPrefix(rotatedToken, "slmcp_") { + t.Fatalf("rotate output token = %q", rotatedToken) + } + a, err := auth.Load(stateFile) + if err != nil { + t.Fatal(err) + } + if a.Verify(firstToken) || !a.Verify(rotatedToken) { + t.Fatal("explicit rotation did not replace the accepted token") + } +} + +func lastLine(output string) string { + lines := strings.Split(strings.TrimSpace(output), "\n") + return lines[len(lines)-1] +} + +func TestBuildServerInstructionsIncludesFriendlyInstanceMappings(t *testing.T) { + instructions := buildServerInstructions([]*config.InstanceConfig{ + { + ID: "dev152", + DisplayName: "Development 152", + BaseURL: "https://secret.internal", + TokenFile: "/run/secrets/dev152.token", + }, + { + ID: "dev180", + DisplayName: "Development 180", + BaseURL: "https://other.internal", + TokenFile: "/run/secrets/dev180.token", + }, + }) + + for _, expected := range []string{ + `display_name "Development 152" -> instance_id "dev152"`, + `display_name "Development 180" -> instance_id "dev180"`, + "Never guess an instance_id", + } { + if !strings.Contains(instructions, expected) { + t.Errorf("instructions = %q, want substring %q", instructions, expected) + } + } + for _, sensitive := range []string{ + "secret.internal", + "other.internal", + "/run/secrets/dev152.token", + "/run/secrets/dev180.token", + } { + if strings.Contains(instructions, sensitive) { + t.Fatalf("instructions exposed instance configuration %q: %q", sensitive, instructions) + } + } +} + +func TestAuthDisabledRefusesNonLoopbackListener(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + contents := ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 0.0.0.0 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +` + if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv(auth.DisabledEnv, "true") + err := run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("run() error = %v, want refusal of a non-loopback listener", err) + } +} + +func TestIsLoopbackHost(t *testing.T) { + for _, host := range []string{"127.0.0.1", "::1", "[::1]", "localhost", "LocalHost"} { + if !isLoopbackHost(host) { + t.Errorf("isLoopbackHost(%q) = false, want true", host) + } + } + for _, host := range []string{"", "0.0.0.0", "::", "10.0.0.10", "example.com"} { + if isLoopbackHost(host) { + t.Errorf("isLoopbackHost(%q) = true, want false", host) + } + } +} + +func TestNonLoopbackListenerRequiresTLS(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.Mkdir(stateDir, 0o700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(dir, "config.yaml") + contents := ` +server: + name: SafeLine MCP + version: 1.0.0 + host: 0.0.0.0 + port: 5678 +logger: + level: info +instances: + - id: dev152 + base_url: https://dev152:9443 + token_file: /run/secrets/dev152_token +` + if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + stateFile := filepath.Join(stateDir, "auth.json") + if _, _, err := auth.Initialize(stateFile); err != nil { + t.Fatal(err) + } + t.Setenv(auth.StateFileEnv, stateFile) + err := run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "requires TLS") { + t.Fatalf("run() error = %v, want TLS requirement", err) + } +} diff --git a/mcp_server/pkg/config/config.go b/mcp_server/pkg/config/config.go deleted file mode 100644 index a4d93f13..00000000 --- a/mcp_server/pkg/config/config.go +++ /dev/null @@ -1,72 +0,0 @@ -package config - -import ( - "os" - - "gopkg.in/yaml.v3" -) - -// Config Global configuration structure -type Config struct { - Server ServerConfig `yaml:"server"` - Logger LoggerConfig `yaml:"logger"` -} - -// ServerConfig Server configuration -type ServerConfig struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Port int `yaml:"port"` - Host string `yaml:"host"` - Secret string `yaml:"secret"` -} - -// LoggerConfig Logger configuration -type LoggerConfig struct { - Level string `yaml:"level"` - FilePath string `yaml:"file_path"` - Console bool `yaml:"console"` - Caller bool `yaml:"caller"` - Development bool `yaml:"development"` -} - -var ( - globalConfig *Config -) - -// Load Load configuration from file -func Load(filename string) error { - data, err := os.ReadFile(filename) - if err != nil { - return err - } - - config := &Config{} - if err := yaml.Unmarshal(data, config); err != nil { - return err - } - - globalConfig = config - return nil -} - -// Get Get global configuration -func Get() *Config { - return globalConfig -} - -// GetServer Get server configuration -func GetServer() ServerConfig { - if globalConfig == nil { - return ServerConfig{} - } - return globalConfig.Server -} - -// GetLogger Get logger configuration -func GetLogger() LoggerConfig { - if globalConfig == nil { - return LoggerConfig{} - } - return globalConfig.Logger -} diff --git a/mcp_server/pkg/errors/errors.go b/mcp_server/pkg/errors/errors.go index ee6ebae5..64f42a1d 100644 --- a/mcp_server/pkg/errors/errors.go +++ b/mcp_server/pkg/errors/errors.go @@ -28,11 +28,16 @@ type Error struct { } // Error Implement error interface +// +// The message never carries the source location: it is what the MCP client +// receives as the text of a failed tool call, and the location is an absolute +// path of the machine the helper runs on. WrapL puts it into the server side +// log, where it belongs, and Location exposes it to callers that need it. func (e *Error) Error() string { if e.msg != "" { - return fmt.Sprintf("%s: %v (at %s)", e.msg, e.err, e.location) + return fmt.Sprintf("%s: %v", e.msg, e.err) } - return fmt.Sprintf("%v (at %s)", e.err, e.location) + return fmt.Sprintf("%v", e.err) } // Unwrap Return original error diff --git a/mcp_server/pkg/logger/logger.go b/mcp_server/pkg/logger/logger.go index e52ad250..5a6e4b30 100644 --- a/mcp_server/pkg/logger/logger.go +++ b/mcp_server/pkg/logger/logger.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sync" + "syscall" "time" "go.uber.org/zap" @@ -34,6 +35,11 @@ type Config struct { Development bool `json:"development" yaml:"development"` } +// logFileMode keeps the log file readable for its owner and its group only. +// The tools that run behind this logger pass request arguments through it, and +// those are none of the business of every other account on the host. +const logFileMode os.FileMode = 0640 + // 默认配置 var defaultConfig = Config{ Level: "info", @@ -87,8 +93,13 @@ func Init(cfg *Config) error { // 文件输出 if cfg.FilePath != "" { - fileWriter, err := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { + // Do not shadow err: the old code declared a new variable here, so + // a failure to open the log file returned a nil error and left + // defaultLogger unset, which turned every later log call into a nil + // pointer dereference. + fileWriter, openErr := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW, logFileMode) + if openErr != nil { + err = openErr return } cores = append(cores, zapcore.NewCore( @@ -130,8 +141,18 @@ func Init(cfg *Config) error { // GetLogger 获取日志实例 func GetLogger() *Logger { if defaultLogger == nil { - Init(nil) + _ = Init(nil) } + + if defaultLogger == nil { + // Init only runs once: when it failed (or when it was called with a + // configuration that could not be opened) it never installs a logger, + // and the call above cannot repair that. Handing out nil here crashed + // the caller on the next log call, so a no-op logger is returned + // instead. + return &Logger{zl: zap.NewNop()} + } + return defaultLogger } diff --git a/mcp_server/pkg/mcp/mcp.go b/mcp_server/pkg/mcp/mcp.go index 55be4e4b..a64d04e4 100644 --- a/mcp_server/pkg/mcp/mcp.go +++ b/mcp_server/pkg/mcp/mcp.go @@ -2,18 +2,22 @@ package mcp import ( "context" - "encoding/json" "fmt" "net/http" - "strconv" + "strings" + "time" - "github.com/chaitin/SafeLine/mcp_server/pkg/errors" "github.com/chaitin/SafeLine/mcp_server/pkg/logger" - "github.com/mark3labs/mcp-go/mcp" + markmcp "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/mcuadros/go-defaults" ) +const ( + EndpointPath = "/mcp" + maxRequestBodySize = 1 << 20 +) + type Tool[T any, R any] interface { Name() string @@ -24,125 +28,129 @@ type Tool[T any, R any] interface { Validate(params T) error } -type SSEServer struct { - sse *server.SSEServer - secret string +// InputSchemaProvider lets a tool declare constraints that cannot be inferred +// from Go types alone, such as numeric ranges and documented defaults. +type InputSchemaProvider interface { + InputSchemaOptions() []markmcp.ToolOption } -func (s *SSEServer) Start(addr string) error { - srv := &http.Server{ - Addr: addr, - Handler: s, - } - return srv.ListenAndServe() -} - -func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if s.secret == "" { - s.sse.ServeHTTP(w, r) - return - } - messagePath := s.sse.CompleteMessagePath() - if messagePath != "" && r.URL.Path == messagePath { - secret := r.Header.Get("Secret") - if secret != s.secret { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - } - s.sse.ServeHTTP(w, r) -} +type Middleware func(http.Handler) http.Handler type MCPServer struct { - server *server.MCPServer - sse *SSEServer + server *server.MCPServer + transport *server.StreamableHTTPServer } -func NewMCPServer(name, version string, secret string) *MCPServer { +func NewMCPServer(name, version, instructions string) *MCPServer { s := server.NewMCPServer( name, version, - server.WithLogging(), + server.WithInstructions(instructions), + server.WithToolCapabilities(false), + server.WithInputSchemaValidation(), + server.WithStrictInputSchemaDefault(), + server.WithOutputSchemaValidation(), ) - return &MCPServer{ - server: s, - sse: &SSEServer{sse: server.NewSSEServer(s), secret: secret}, + transport := server.NewStreamableHTTPServer( + s, + server.WithEndpointPath(EndpointPath), + server.WithStateLess(true), + ) + return &MCPServer{server: s, transport: transport} +} + +// Handler exposes exactly one Streamable HTTP endpoint. Legacy /sse and +// /message routes are intentionally not mounted. +func (s *MCPServer) Handler() http.Handler { + mux := http.NewServeMux() + mux.Handle(EndpointPath, http.MaxBytesHandler(s.transport, maxRequestBodySize)) + return mux +} + +func (s *MCPServer) Start(addr string, middleware ...Middleware) error { + return s.listen(addr, "", "", middleware...) +} + +// StartTLS serves the same handler over HTTPS. +func (s *MCPServer) StartTLS(addr, certFile, keyFile string, middleware ...Middleware) error { + if strings.TrimSpace(certFile) == "" || strings.TrimSpace(keyFile) == "" { + return fmt.Errorf("tls cert and key are required") } + return s.listen(addr, certFile, keyFile, middleware...) } -func (s *MCPServer) Start(addr string) error { - return s.sse.Start(addr) +func (s *MCPServer) listen(addr, certFile, keyFile string, middleware ...Middleware) error { + var handler http.Handler = s.Handler() + for i := len(middleware) - 1; i >= 0; i-- { + handler = middleware[i](handler) + } + + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + if certFile != "" { + return srv.ListenAndServeTLS(certFile, keyFile) + } + return srv.ListenAndServe() } -func handleToolCall[T any, R any](ctx context.Context, request mcp.CallToolRequest, tool Tool[T, R]) (result *mcp.CallToolResult, err error) { +func handleToolCall[T any, R any](ctx context.Context, request markmcp.CallToolRequest, tool Tool[T, R]) (result *markmcp.CallToolResult, err error) { defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic recovered: %v", r) + if recovered := recover(); recovered != nil { + logger.With("tool", tool.Name()).Error("tool execution panicked") + result = markmcp.NewToolResultError("tool execution failed") + err = nil } }() - var raw []byte - raw, err = json.Marshal(request.Params.Arguments) - if err != nil { - return nil, errors.Wrap(err, "marshal arguments failed") - } var params T defaults.SetDefaults(¶ms) - if err = json.Unmarshal(raw, ¶ms); err != nil { - return nil, errors.Wrap(err, "unmarshal parameters failed") + if err := request.BindArguments(¶ms); err != nil { + return markmcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil } - - if err = tool.Validate(params); err != nil { - return nil, err + if err := tool.Validate(params); err != nil { + return markmcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil } - var execResult R - execResult, err = tool.Execute(ctx, params) + execResult, err := tool.Execute(ctx, params) if err != nil { - return nil, err + logger.With("tool", tool.Name()).With("error", err).Error("tool execution failed") + return markmcp.NewToolResultError(err.Error()), nil } - v := any(execResult) - switch v := v.(type) { - case string: - return mcp.NewToolResultText(v), nil - case []byte: - return mcp.NewToolResultText(string(v)), nil - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: - return mcp.NewToolResultText(json.Number(fmt.Sprint(v)).String()), nil - case bool: - return mcp.NewToolResultText(strconv.FormatBool(v)), nil - default: - bytes, err := json.Marshal(v) - if err != nil { - return nil, errors.New("invalid result type") - } - return mcp.NewToolResultText(string(bytes)), nil + result, err = markmcp.NewToolResultJSON(execResult) + if err != nil { + logger.With("tool", tool.Name()).With("error", err).Error("tool result serialization failed") + return markmcp.NewToolResultError("tool result serialization failed"), nil } + return result, nil } func RegisterTool[T any, R any](s *MCPServer, tool Tool[T, R]) error { - var v T - opts, err := SchemaToOptions(v) - if err != nil { - return err + options := []markmcp.ToolOption{ + markmcp.WithDescription(tool.Description()), + } + if provider, ok := any(tool).(InputSchemaProvider); ok { + options = append(options, provider.InputSchemaOptions()...) + } else { + options = append(options, markmcp.WithInputSchema[T]()) } - opts = append(opts, mcp.WithDescription(tool.Description())) - t := mcp.NewTool(tool.Name(), - opts..., + options = append(options, + markmcp.WithOutputSchema[R](), + markmcp.WithReadOnlyHintAnnotation(true), + markmcp.WithDestructiveHintAnnotation(false), + markmcp.WithIdempotentHintAnnotation(true), + markmcp.WithOpenWorldHintAnnotation(true), ) + t := markmcp.NewTool(tool.Name(), options...) - s.server.AddTool(t, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - result, err := handleToolCall(ctx, request, tool) - if err != nil { - logger.With("error", err).Error("handle tool call failed") - if wrapped, ok := err.(*errors.Error); ok { - return nil, wrapped.Unwrap() - } - return nil, err - } - return result, nil + s.server.AddTool(t, func(ctx context.Context, request markmcp.CallToolRequest) (*markmcp.CallToolResult, error) { + return handleToolCall(ctx, request, tool) }) - return nil } diff --git a/mcp_server/pkg/mcp/mcp_test.go b/mcp_server/pkg/mcp/mcp_test.go new file mode 100644 index 00000000..7ff219a7 --- /dev/null +++ b/mcp_server/pkg/mcp/mcp_test.go @@ -0,0 +1,239 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chaitin/SafeLine/mcp_server/pkg/logger" + markmcp "github.com/mark3labs/mcp-go/mcp" +) + +type probeParams struct { + Count int `json:"count" jsonschema:"Number of results to return"` +} + +type probeResult struct { + Count int `json:"count" jsonschema:"Returned result count"` +} + +type probeTool struct{} + +func (probeTool) Name() string { return "probe" } +func (probeTool) Description() string { return "Protocol test tool" } +func (probeTool) Validate(params probeParams) error { + if params.Count < 1 { + return errors.New("count must be at least 1") + } + return nil +} +func (probeTool) Execute(_ context.Context, params probeParams) (probeResult, error) { + if params.Count == 13 { + return probeResult{}, errors.New("downstream failed") + } + return probeResult{Count: params.Count}, nil +} + +func TestModernStreamableHTTPAndToolErrorSemantics(t *testing.T) { + if err := logger.Init(&logger.Config{Level: "error"}); err != nil { + t.Fatal(err) + } + const instructions = "Use the configured display-name to instance-ID mappings." + s := NewMCPServer("test", "1.0.0", instructions) + if err := RegisterTool[probeParams, probeResult](s, probeTool{}); err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + + discover := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodServerDiscover, map[string]any{}) + result := requireRPCResult(t, discover) + if result["instructions"] != instructions { + t.Fatalf("instructions = %#v, want %q", result["instructions"], instructions) + } + versions, ok := result["supportedVersions"].([]any) + if !ok || len(versions) == 0 || versions[0] != markmcp.ProtocolVersion20260728 { + t.Fatalf("supportedVersions = %#v, want %s first", result["supportedVersions"], markmcp.ProtocolVersion20260728) + } + + valid := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodToolsCall, map[string]any{ + "name": "probe", + "arguments": map[string]any{"count": 2}, + }) + validResult := requireRPCResult(t, valid) + if validResult["isError"] == true { + t.Fatalf("valid tool result = %#v", validResult) + } + structured := validResult["structuredContent"].(map[string]any) + if structured["count"] != float64(2) { + t.Fatalf("structuredContent = %#v", structured) + } + + for name, arguments := range map[string]map[string]any{ + "decimal integer": {"count": 1.5}, + "unknown field": {"count": 1, "unexpected": true}, + "validation": {"count": 0}, + "downstream": {"count": 13}, + } { + t.Run(name, func(t *testing.T) { + response := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodToolsCall, map[string]any{ + "name": "probe", + "arguments": arguments, + }) + toolResult := requireRPCResult(t, response) + if toolResult["isError"] != true { + t.Fatalf("tool result = %#v, want isError=true", toolResult) + } + }) + } + + for _, path := range []string{"/sse", "/message"} { + response, err := http.Get(httpServer.URL + path) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("GET %s status = %d, want 404", path, response.StatusCode) + } + } +} + +func TestHandlerRejectsOversizedRequestBody(t *testing.T) { + s := NewMCPServer("test", "1.0.0", "") + body := strings.NewReader(strings.Repeat("x", maxRequestBodySize+1)) + req := httptest.NewRequest(http.MethodPost, EndpointPath, body) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + + s.Handler().ServeHTTP(response, req) + if !strings.Contains(response.Body.String(), "request body too large") { + t.Fatalf("oversized response = status %d body %q", response.Code, response.Body.String()) + } +} + +func TestLegacyInitializeReturnsInstructions(t *testing.T) { + const instructions = "Use the configured display-name to instance-ID mappings." + s := NewMCPServer("test", "1.0.0", instructions) + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": markmcp.MethodInitialize, + "params": map[string]any{ + "protocolVersion": markmcp.ProtocolVersion20251125, + "clientInfo": map[string]any{ + "name": "safeline-mcp-legacy-test", + "version": "1.0.0", + }, + "capabilities": map[string]any{}, + }, + }) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + httpServer.URL+EndpointPath, + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("initialize status = %d, want 200", response.StatusCode) + } + + var rpc map[string]any + if err := json.NewDecoder(response.Body).Decode(&rpc); err != nil { + t.Fatal(err) + } + result, ok := rpc["result"].(map[string]any) + if !ok { + t.Fatalf("initialize response = %#v, want result", rpc) + } + if result["instructions"] != instructions { + t.Fatalf("instructions = %#v, want %q", result["instructions"], instructions) + } +} + +func postModern(t *testing.T, url string, method markmcp.MCPMethod, params map[string]any) *http.Response { + t.Helper() + params["_meta"] = map[string]any{ + markmcp.MetaKeyProtocolVersion: markmcp.ProtocolVersion20260728, + markmcp.MetaKeyClientInfo: map[string]any{ + "name": "safeline-mcp-test", + "version": "1.0.0", + }, + markmcp.MetaKeyClientCapabilities: map[string]any{}, + } + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(markmcp.HeaderProtocolVersion, markmcp.ProtocolVersion20260728) + req.Header.Set(markmcp.HeaderMethod, string(method)) + if name, ok := params["name"].(string); ok { + encoded, valid := markmcp.EncodeHeaderValue(name) + if !valid { + t.Fatalf("invalid MCP name %q", name) + } + req.Header.Set(markmcp.HeaderName, encoded) + } + + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = response.Body.Close() }) + if response.StatusCode != http.StatusOK { + var payload any + _ = json.NewDecoder(response.Body).Decode(&payload) + t.Fatalf("POST %s status = %d, payload = %#v", method, response.StatusCode, payload) + } + return response +} + +func requireRPCResult(t *testing.T, response *http.Response) map[string]any { + t.Helper() + var rpc map[string]any + if err := json.NewDecoder(response.Body).Decode(&rpc); err != nil { + t.Fatal(err) + } + result, ok := rpc["result"].(map[string]any) + if !ok { + t.Fatalf("JSON-RPC response = %#v", rpc) + } + if result["resultType"] != string(markmcp.ResultTypeComplete) { + t.Fatalf("resultType = %#v, want %q", result["resultType"], markmcp.ResultTypeComplete) + } + return result +} diff --git a/mcp_server/pkg/mcp/schema.go b/mcp_server/pkg/mcp/schema.go deleted file mode 100644 index 7461563b..00000000 --- a/mcp_server/pkg/mcp/schema.go +++ /dev/null @@ -1,169 +0,0 @@ -package mcp - -import ( - "encoding/json" - "reflect" - "strconv" - "strings" - - "github.com/mark3labs/mcp-go/mcp" -) - -// SchemaToOptions Convert struct to MCP ToolOption list -func SchemaToOptions(schema any) ([]mcp.ToolOption, error) { - t := reflect.TypeOf(schema) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - var options []mcp.ToolOption - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - jsonTag := field.Tag.Get("json") - if jsonTag == "" { - continue - } - - desc := field.Tag.Get("desc") - required := field.Tag.Get("required") == "true" - enumTag := field.Tag.Get("enum") - defaultTag := field.Tag.Get("default") - minTag := field.Tag.Get("min") - maxTag := field.Tag.Get("max") - opts := []mcp.PropertyOption{} - - if desc != "" { - opts = append(opts, mcp.Description(desc)) - } - if required { - opts = append(opts, mcp.Required()) - } - if enumTag != "" && field.Type.Kind() == reflect.String { - enumValues := strings.Split(enumTag, ",") - opts = append(opts, mcp.Enum(enumValues...)) - } - - switch field.Type.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: - if defaultTag != "" { - if defaultValue, err := strconv.Atoi(defaultTag); err == nil { - opts = append(opts, mcp.DefaultNumber(float64(defaultValue))) - } - } - if minTag != "" { - if minValue, err := strconv.Atoi(minTag); err == nil { - opts = append(opts, mcp.Min(float64(minValue))) - } - } - if maxTag != "" { - if maxValue, err := strconv.Atoi(maxTag); err == nil { - opts = append(opts, mcp.Max(float64(maxValue))) - } - } - options = append(options, mcp.WithNumber(jsonTag, opts...)) - case reflect.Bool: - if defaultTag != "" { - if defaultValue, err := strconv.ParseBool(defaultTag); err == nil { - opts = append(opts, mcp.DefaultBool(defaultValue)) - } - } - options = append(options, mcp.WithBoolean(jsonTag, opts...)) - case reflect.String: - if defaultTag != "" { - opts = append(opts, mcp.DefaultString(defaultTag)) - } - options = append(options, mcp.WithString(jsonTag, opts...)) - case reflect.Struct: - subSchema := reflect.New(field.Type).Interface() - subOptions, err := SchemaToOptions(subSchema) - if err != nil { - return nil, err - } - - // Create a temporary Tool to get JSON Schema of sub-struct - tempTool := mcp.NewTool("temp", subOptions...) - tempJSON, _ := tempTool.MarshalJSON() - var tempMap map[string]any - if err := json.Unmarshal(tempJSON, &tempMap); err != nil { - continue - } - - // Extract properties from temporary Tool - if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok { - if properties, ok := inputSchema["properties"].(map[string]any); ok { - // Check if there are required fields - if required, ok := inputSchema["required"].([]any); ok { - // Add required field information to corresponding properties - for _, req := range required { - if reqStr, ok := req.(string); ok { - if prop, ok := properties[reqStr].(map[string]any); ok { - prop["required"] = true - } - } - } - } - opts = append(opts, mcp.Properties(properties)) - } - } - options = append(options, mcp.WithObject(jsonTag, opts...)) - - case reflect.Slice: - elemType := field.Type.Elem() - var items map[string]any - - switch elemType.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: - items = map[string]any{ - "type": "number", - } - case reflect.Bool: - items = map[string]any{ - "type": "boolean", - } - case reflect.String: - items = map[string]any{ - "type": "string", - } - case reflect.Struct: - subSchema := reflect.New(elemType).Interface() - subOptions, err := SchemaToOptions(subSchema) - if err != nil { - return nil, err - } - - // Create a temporary Tool to get JSON Schema of sub-struct - tempTool := mcp.NewTool("temp", subOptions...) - tempJSON, _ := tempTool.MarshalJSON() - var tempMap map[string]any - if err := json.Unmarshal(tempJSON, &tempMap); err != nil { - continue - } - - // Extract properties from temporary Tool - if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok { - if properties, ok := inputSchema["properties"].(map[string]any); ok { - // Check if there are required fields - if required, ok := inputSchema["required"].([]any); ok { - // Add required field information to corresponding properties - for _, req := range required { - if reqStr, ok := req.(string); ok { - if prop, ok := properties[reqStr].(map[string]any); ok { - prop["required"] = true - } - } - } - } - items = map[string]any{ - "type": "object", - "properties": properties, - } - } - } - } - opts = append(opts, mcp.Items(items)) - options = append(options, mcp.WithArray(jsonTag, opts...)) - } - } - - return options, nil -} diff --git a/mcp_server/pkg/mcp/schema_test.go b/mcp_server/pkg/mcp/schema_test.go deleted file mode 100644 index 6025f74f..00000000 --- a/mcp_server/pkg/mcp/schema_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package mcp - -import ( - "reflect" - "testing" - - "github.com/mark3labs/mcp-go/mcp" -) - -func TestSchemaToOptions(t *testing.T) { - tests := []struct { - name string - args any - want mcp.Tool - }{ - { - name: "test number", - args: struct { - A int `json:"a" desc:"number a" required:"true"` - }{}, - want: mcp.NewTool("test number", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")), - ), - }, - { - name: "test number int64", - args: struct { - A int64 `json:"a" desc:"number a" required:"true"` - }{}, - want: mcp.NewTool("test number int64", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")), - ), - }, - { - name: "test number float64", - args: struct { - A float64 `json:"a" desc:"number a" required:"true"` - }{}, - want: mcp.NewTool("test number float64", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")), - ), - }, - { - name: "test number default", - args: struct { - A int `json:"a" desc:"number a" required:"true" default:"10"` - }{}, - want: mcp.NewTool("test number default", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.DefaultNumber(10)), - ), - }, - { - name: "test number min max", - args: struct { - A int `json:"a" desc:"number a" required:"true" min:"10" max:"20"` - }{}, - want: mcp.NewTool("test number min max", - mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.Min(10), mcp.Max(20)), - ), - }, - { - name: "test number optional", - args: struct { - A int `json:"a" desc:"number a"` - }{}, - want: mcp.NewTool("test number optional", - mcp.WithNumber("a", mcp.Description("number a")), - ), - }, - { - name: "test boolean", - args: struct { - A bool `json:"a" desc:"boolean a" required:"true"` - }{}, - want: mcp.NewTool("test boolean", - mcp.WithBoolean("a", mcp.Required(), mcp.Description("boolean a")), - ), - }, - { - name: "test string", - args: struct { - A string `json:"a" desc:"string a" required:"true"` - }{}, - want: mcp.NewTool("test string", - mcp.WithString("a", mcp.Required(), mcp.Description("string a")), - ), - }, - { - name: "test string default", - args: struct { - A string `json:"a" desc:"string a" required:"true" default:"hello"` - }{}, - want: mcp.NewTool("test string default", - mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.DefaultString("hello")), - ), - }, - { - name: "test string enum", - args: struct { - A string `json:"a" desc:"string a" required:"true" enum:"1,2,3"` - }{}, - want: mcp.NewTool("test string enum", - mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.Enum("1", "2", "3")), - ), - }, - { - name: "test object", - args: struct { - A struct { - B int `json:"b" desc:"number b" required:"true"` - } `json:"a" desc:"object a" required:"true"` - }{}, - want: mcp.NewTool("test object", - mcp.WithObject("a", mcp.Required(), mcp.Description("object a"), - mcp.Properties(map[string]any{ - "b": map[string]any{ - "type": "number", - "description": "number b", - "required": true, - }, - }), - ), - ), - }, - { - name: "test object optional", - args: struct { - A struct { - B int `json:"b" desc:"number b" required:"true"` - C int `json:"c" desc:"number c"` - } `json:"a" desc:"object a" required:"true"` - }{}, - want: mcp.NewTool("test object optional", - mcp.WithObject("a", mcp.Required(), mcp.Description("object a"), - mcp.Properties(map[string]any{ - "b": map[string]any{ - "type": "number", - "description": "number b", - "required": true, - }, - "c": map[string]any{ - "type": "number", - "description": "number c", - }, - }), - ), - ), - }, - { - name: "test nested object", - args: struct { - A struct { - B struct { - C int `json:"c" desc:"number c" required:"true"` - } `json:"b" desc:"object b" required:"true"` - } `json:"a" desc:"object a" required:"true"` - }{}, - want: mcp.NewTool("test nested object", - mcp.WithObject("a", mcp.Required(), mcp.Description("object a"), - mcp.Properties(map[string]any{ - "b": map[string]any{ - "type": "object", - "description": "object b", - "required": true, - "properties": map[string]any{ - "c": map[string]any{ - "type": "number", - "description": "number c", - "required": true, - }, - }, - }, - }), - ), - ), - }, - { - name: "test array", - args: struct { - A []int `json:"a" desc:"array a" required:"true"` - }{}, - want: mcp.NewTool("test array", - mcp.WithArray("a", mcp.Required(), mcp.Description("array a"), - mcp.Items(map[string]any{ - "type": "number", - }), - ), - ), - }, - { - name: "test array of object", - args: struct { - A []struct { - B int `json:"b" desc:"number b" required:"true"` - } `json:"a" desc:"array of object a" required:"true"` - }{}, - want: mcp.NewTool("test array of object", - mcp.WithArray("a", mcp.Required(), mcp.Description("array of object a"), - mcp.Items(map[string]any{ - "type": "object", - "properties": map[string]any{ - "b": map[string]any{ - "type": "number", - "description": "number b", - "required": true, - }, - }, - }), - ), - ), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := SchemaToOptions(tt.args) - if err != nil { - t.Errorf("SchemaToOptions() error = %v", err) - return - } - s1, _ := mcp.NewTool(tt.name, got...).MarshalJSON() - s2, _ := tt.want.MarshalJSON() - if !reflect.DeepEqual(s1, s2) { - t.Errorf("\n got %v\n want %v", string(s1), string(s2)) - } - }) - } -} diff --git a/scripts/manage.py b/scripts/manage.py index 7bcaa2f7..142b255a 100644 --- a/scripts/manage.py +++ b/scripts/manage.py @@ -497,8 +497,6 @@ def init_global_config(): global REQUEST_CTX, LANG, DOMAIN, PRODUCT, DEBUG REQUEST_CTX = ssl.create_default_context() - REQUEST_CTX.check_hostname = False - REQUEST_CTX.verify_mode = ssl.CERT_NONE parser = argparse.ArgumentParser( prog='installer-management', diff --git a/sdk/ingress-nginx/lib/safeline/main.lua b/sdk/ingress-nginx/lib/safeline/main.lua index b4741813..db895178 100644 --- a/sdk/ingress-nginx/lib/safeline/main.lua +++ b/sdk/ingress-nginx/lib/safeline/main.lua @@ -3,6 +3,7 @@ local t1k_constants = require "resty.t1k.constants" local ngx = ngx local fmt = string.format +local lower = string.lower local blocked_message = [[{"code": %s, "success":false, ]] .. [["message": "blocked by Chaitin SafeLine Web Application Firewall", "event_id": "%s"}]] @@ -36,26 +37,56 @@ local function get_conf() return t end +local function is_block_mode(t) + -- resty.t1k lower cases the mode before comparing it, so do the same here: + -- a mode of "BLOCK" has to fail closed exactly like "block". + return type(t.mode) == "string" and lower(t.mode) == t1k_constants.MODE_BLOCK +end + +-- fail_closed answers a request that could not be checked. +-- +-- The detector was not asked (or the plugin is not configured at all), so +-- nothing is known about this request. Answering it while the plugin runs in +-- block mode would turn a detector outage into a complete bypass of the WAF, +-- which is the one outcome an inline protection must not have. monitor mode +-- keeps passing traffic, and off mode never reaches this point. +local function fail_closed(t) + if not is_block_mode(t) then + return + end + + local status = ngx.HTTP_INTERNAL_SERVER_ERROR + local msg = fmt(blocked_message, status, "") + ngx.status = status + ngx.header.content_type = t1k_constants.BLOCK_CONTENT_TYPE + ngx.say(msg) + return ngx.exit(status) +end + function _M.rewrite() local t = get_conf() if not t.host then ngx.log(ngx.ERR, "safeline host is required") - return + -- resty.t1k refuses to run without a host, so this is the same + -- "cannot be checked" situation as a detector that does not answer. + return fail_closed(t) end local ok, err, result = t1k.do_access(t, false) if not ok then ngx.log(ngx.ERR, "failed to detector req: ", err) - return + return fail_closed(t) end if result then if result.action == t1k_constants.ACTION_BLOCKED then - local msg = fmt(blocked_message, result.status, result.event_id) - ngx.log(ngx.ERR, "blocked by safeline waf: ",msg) - ngx.status = tonumber(result.status,10) + local status = tonumber(result.status, 10) or ngx.HTTP_FORBIDDEN + local msg = fmt(blocked_message, status, result.event_id) + ngx.log(ngx.ERR, "blocked by safeline waf: ", msg) + ngx.status = status + ngx.header.content_type = t1k_constants.BLOCK_CONTENT_TYPE ngx.say(msg) - return ngx.exit(ngx.HTTP_OK) + return ngx.exit(status) end end end -return _M \ No newline at end of file +return _M diff --git a/sdk/kong/kong/plugins/safeline/handler.lua b/sdk/kong/kong/plugins/safeline/handler.lua index 06da7bf0..af6fcda6 100644 --- a/sdk/kong/kong/plugins/safeline/handler.lua +++ b/sdk/kong/kong/plugins/safeline/handler.lua @@ -28,11 +28,18 @@ local function get_conf(conf) end function SafelineHandler:access(conf) - -- your custom code here local t = get_conf(conf) local ok, err, result = t1k.do_access(t, false) if not ok then + -- The detector could not be asked, so nothing is known about this + -- request. Letting it through in block mode would turn a detector + -- outage into a complete bypass of the WAF, which is the one outcome an + -- inline protection must not have. monitor mode keeps passing traffic. kong.log.err("failed to detector req: ", err) + if conf.mode == t1k_constants.MODE_BLOCK then + return kong.response.exit(500, fmt(blocked_message, 500, "")) + end + return end if result and result.status then if result.action == t1k_constants.ACTION_BLOCKED then diff --git a/version.json b/version.json new file mode 100644 index 00000000..387ca1f9 --- /dev/null +++ b/version.json @@ -0,0 +1,5 @@ +{ + "latest_version": "v9.2.7", + "rec_version": "v9.2.7", + "lts_version": "v9.1.0-lts" +} diff --git a/yanshi/src/common.cc b/yanshi/src/common.cc index 2ea555bb..b301c6c6 100644 --- a/yanshi/src/common.cc +++ b/yanshi/src/common.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +76,57 @@ void err_msg(const char *format, ...) } #define err_msg_g(...) ({err_msg(__VA_ARGS__); goto quit;}) +// MAX_BACKTRACE_FRAMES bounds the addresses that are collected and resolved. +// backtrace() writes as many entries as the stack holds, so the count handed +// to it has to be the size of the buffer itself: asking for more than the +// array holds writes past the end of it. +#define MAX_BACKTRACE_FRAMES 99 + +// run_addr2line resolves the collected addresses and writes the result to +// stderr. +// +// The addresses are passed as an argument vector rather than as a shell +// command line: the program name is argv[0], which whoever spawns this binary +// chooses, so a value carrying shell syntax would otherwise be able to run a +// command of its own. +static void run_addr2line(void** bt, int nptrs) +{ + if (nptrs > MAX_BACKTRACE_FRAMES) + nptrs = MAX_BACKTRACE_FRAMES; + + char addrs[MAX_BACKTRACE_FRAMES][32]; + // three fixed entries (the program, its options and the object file), one + // address per frame, and the terminating NULL + char* argv[MAX_BACKTRACE_FRAMES + 4]; + int n = 0; + argv[n++] = (char*)"addr2line"; + argv[n++] = (char*)"-Cfipe"; + // glibc leaves program_invocation_name empty when it cannot name the + // program; addr2line then falls back to its own default. + argv[n++] = (char*)(program_invocation_name ? program_invocation_name : ""); + REP(i, nptrs) { + snprintf(addrs[i], sizeof addrs[i], "%p", bt[i]); + argv[n++] = addrs[i]; + } + argv[n] = NULL; + + pid_t pid = fork(); + if (pid < 0) + return; + + if (pid == 0) { + // the backtrace belongs next to the message that is being written + dup2(STDERR_FILENO, STDOUT_FILENO); + execvp(argv[0], argv); + dprintf(STDERR_FILENO, "addr2line: %s\n", strerror(errno)); + _exit(EXIT_FAILURE); + } + + int status; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) + ; +} + void err_exit(int exitno, const char *format, ...) { va_list ap; @@ -84,16 +136,10 @@ void err_exit(int exitno, const char *format, ...) errno = saved; va_end(ap); - void *bt[99]; - char buf[1024]; - int nptrs = backtrace(bt, LEN(buf)); - int i = sprintf(buf, "addr2line -Cfipe %s", program_invocation_name), j = 0; - while (j < nptrs && i+30 < sizeof buf) - i += sprintf(buf+i, " %p", bt[j++]); - strcat(buf, ">&2"); + void *bt[MAX_BACKTRACE_FRAMES]; + int nptrs = backtrace(bt, LEN(bt)); fputs("\n", stderr); - system(buf); - //backtrace_symbols_fd(buf, nptrs, STDERR_FILENO); + run_addr2line(bt, nptrs); exit(exitno); } diff --git a/yanshi/src/common.hh b/yanshi/src/common.hh index 1d1051bb..b31314f5 100644 --- a/yanshi/src/common.hh +++ b/yanshi/src/common.hh @@ -44,6 +44,18 @@ extern char*** _NSGetArgv(void); #define CYAN "\x1b[1;36m" #define NORMAL_YELLOW "\x1b[33m" const long MAX_CODEPOINT = 0x10ffff; +// Upper bound for a single repetition count. gen_repeat() rejects larger +// bounds while parsing and FsaAnno::repeat() enforces the same limit as +// defense in depth, because the automaton expansion performed per iteration +// makes the cost of a large count grow without bound (CWE-770). +const long MAX_REPEAT = 10000; +// Upper bound for the nesting depth of an expression tree. A line of adjacent +// concatenations is reduced one factor at a time into a left leaning chain, so +// the depth of the tree follows the length of the line the compiler is given. +// Every later pass over that tree recurses along its edges, and so does the +// code that frees it, which is why the depth is bounded while the tree is built +// (CWE-674). The bound is far above what a hand written rule reaches. +const long MAX_EXPR_DEPTH = 4096; extern long action_label_base, action_label, call_label_base, call_label, collapse_label_base, collapse_label; void bold(long fd = 1); diff --git a/yanshi/src/compiler.cc b/yanshi/src/compiler.cc index 809961c6..22004cca 100644 --- a/yanshi/src/compiler.cc +++ b/yanshi/src/compiler.cc @@ -278,7 +278,11 @@ void generate_transitions(DefineStmt* stmt) fprintf(output, ")\n" "{\n" -" long v = -1;\n" +" long v = -1;\n"); + if (opt_gen_c) + fprintf(output, +" if (!ret_stack || !ret_stack_len) return -1;\n"); + fprintf(output, "again:\n" " switch (u) {\n"); REP(u, anno.fsa.n()) { @@ -288,8 +292,11 @@ fprintf(output, " u = %ld;\n" , u, call_addr[u].first); if (opt_gen_c) + // C ABI takes a signed length from the caller. Rejecting only the + // upper bound lets a negative *ret_stack_len pass, then + // ret_stack[(*ret_stack_len)++] writes before the array. fprintf(output, -" if (*ret_stack_len >= %ld) return -1;\n" +" if (*ret_stack_len < 0 || *ret_stack_len >= %ld) return -1;\n" " ret_stack[(*ret_stack_len)++] = %ld;\n" , opt_max_return_stack, call_addr[u].second); else @@ -389,7 +396,7 @@ fprintf(output, fprintf(output, "default:\n"); indent(output, 3); fprintf(output, opt_gen_c ? -"if (*ret_stack_len) { u = ret_stack[--*ret_stack_len]; goto again; }\n" +"if (*ret_stack_len > 0) { u = ret_stack[--*ret_stack_len]; goto again; }\n" : "if (ret_stack.size()) { u = ret_stack.back(); ret_stack.pop_back(); goto again; }\n"); indent(output, 3); @@ -423,9 +430,13 @@ bool compile_export(DefineStmt* stmt) unordered_map start2stmt; vector starts; vector sub_final; - function allocate = [&](DefineStmt* stmt) { + function allocate = [&](DefineStmt* stmt, long depth) { if (stmt2offset.count(stmt)) return; + // stmt2offset stops cycles. It does not stop a deep acyclic embed graph, + // which still recurses once per statement. + if (depth > MAX_EXPR_DEPTH) + err_exit(EX_DATAERR, "define graph deeper than %ld", MAX_EXPR_DEPTH); DP(4, "Allocate %ld to %s", allo, stmt->lhs.c_str()); FsaAnno& anno = compiled[stmt]; long base = stmt2offset[stmt] = allo; @@ -448,10 +459,10 @@ bool compile_export(DefineStmt* stmt) if (has_start(aa.second)) { if (auto* e = dynamic_cast(aa.first)) { DefineStmt* v = e->define_stmt; - allocate(v); + allocate(v, depth+1); } else if (auto* e = dynamic_cast(aa.first)) { DefineStmt* v = e->define_stmt; - allocate(v); + allocate(v, depth+1); // (i@{CollapseExpr,...}, special, _) -> ({CollapseExpr,...}, epsilon, CollapseExpr.define_stmt.start) sorted_emplace(adj[i], epsilon, stmt2offset[v]+compiled[v].fsa.start); } @@ -467,7 +478,7 @@ bool compile_export(DefineStmt* stmt) for (auto aa: assoc[v]) if (has_final(aa.second) && (e = dynamic_cast(aa.first))) { DefineStmt* w = e->define_stmt; - allocate(w); + allocate(w, depth+1); // (_, special, v@{CollapseExpr,...}) -> (CollapseExpr.define_stmt.final, epsilon, v) for (long f: compiled[w].fsa.finals) { long g = stmt2offset[w]+f; @@ -481,7 +492,7 @@ bool compile_export(DefineStmt* stmt) adj[i].resize(j); } }; - allocate(stmt); + allocate(stmt, 1); anno.fsa.adj = move(adj); anno.assoc = move(assoc); anno.deterministic = false; @@ -732,6 +743,8 @@ static void generate_cxx_export(DefineStmt* stmt) generate_final("", final); generate_final("sub_", stmt2final[stmt]); fprintf(output, opt_gen_c ? +" if (ret_stack_len < 0) return false;\n" +" if (ret_stack_len > 0 && !ret_stack) return false;\n" " for (long i = ret_stack_len; i; u = ret_stack[--i])\n" : " for (auto i = ret_stack.size(); i; u = ret_stack[--i])\n" @@ -811,6 +824,7 @@ void generate_cxx(Module* mo) " utf8 = argv[1];\n" " else {\n" " FILE* f = argc == 1 ? stdin : fopen(argv[1], \"r\");\n" +" if (!f) return 1;\n" " while ((c = fgetc(f)) != EOF)\n" " utf8 += c;\n" " fclose(f);\n" diff --git a/yanshi/src/fsa.cc b/yanshi/src/fsa.cc index 42c3e0d1..2a415ab5 100644 --- a/yanshi/src/fsa.cc +++ b/yanshi/src/fsa.cc @@ -375,8 +375,13 @@ Fsa Fsa::determinize(const vector* starts, function= 0) live.insert(ev.second); - else - live.erase(live.find(~ ev.second)); + else { + // A reversed interval (signed overflow of end = start+1) can close an + // event that was never opened. erase(end()) is undefined. + auto it = live.find(~ ev.second); + if (it != live.end()) + live.erase(it); + } } } sort(ALL(r.finals)); diff --git a/yanshi/src/fsa_anno.cc b/yanshi/src/fsa_anno.cc index 3e6c6738..c0fcd4af 100644 --- a/yanshi/src/fsa_anno.cc +++ b/yanshi/src/fsa_anno.cc @@ -7,6 +7,7 @@ #include #include #include +#include #include #include using namespace std; @@ -103,10 +104,36 @@ void FsaAnno::co_accessible(const vector* final, vector& mapping) { } void FsaAnno::complement(ComplementExpr* expr) { - if (! deterministic) - fsa = fsa.determinize(NULL, [&](long, const vector&){}); + if (! deterministic) { + // Rebuild the association for the determinized automaton before + // complementing it: Fsa::determinize merges states, so the entries of + // assoc would otherwise keep talking about the numbering of the + // non-deterministic automaton. difference() and intersect() do the same. + decltype(assoc) new_assoc; + auto relate = [&](long id, const vector& xs) { + if (id+1 > new_assoc.size()) + new_assoc.resize(id+1); + auto& as = new_assoc[id]; + for (long x: xs) + as.insert(as.end(), ALL(assoc[x])); + sort_assoc(as); + }; + fsa = fsa.determinize(NULL, relate); + assoc = move(new_assoc); + } fsa = ~ fsa; - assoc.assign(fsa.n(), {}); + // Fsa::operator~ keeps every state at its index and only appends the new + // accepting state, so the association survives. Replacing the vector with + // empty entries (assoc.assign(fsa.n(), {}), as this used to) dropped every + // action that was attached underneath the operator, and generate_transitions + // derives all action bodies from assoc: a rule like + // export foo = ~([a-z] > { puts("entered"); }) + // compiled without the handler the author wrote, silently (CWE-684). The + // same held for the CallExpr/CollapseExpr bookkeeping, which resolves its + // targets through assoc. + assoc.resize(fsa.n()); + if (expr) + add_assoc(*expr); deterministic = true; } @@ -294,6 +321,12 @@ void FsaAnno::question(QuestionExpr* expr) { } void FsaAnno::repeat(RepeatExpr& expr) { + // gen_repeat() already rejects out-of-range bounds at parse time; check + // again here so a programmatically built RepeatExpr can never drive the + // unbounded copy/concat loops below (CWE-770). + if (expr.low < 0 || expr.low > MAX_REPEAT || + expr.high < expr.low || (expr.high != LONG_MAX && expr.high > MAX_REPEAT)) + err_exit(EX_USAGE, "repeat count exceeds limit (max %ld)", MAX_REPEAT); FsaAnno r = epsilon_fsa(NULL); REP(i, expr.low) { FsaAnno t = *this; @@ -414,6 +447,9 @@ FsaAnno FsaAnno::embed(EmbedExpr& expr) { r.fsa.start = 0; r.fsa.finals = {1}; r.fsa.adj.resize(2); + // macro_value+1 overflows at LONG_MAX and inverts the interval. + if (expr.macro_value == LONG_MAX) + err_exit(EX_DATAERR, "macro value %ld cannot be used as a label", expr.macro_value); r.fsa.adj[0].emplace_back(make_pair(expr.macro_value, expr.macro_value+1), 1); r.assoc.resize(2); r.add_assoc(expr); diff --git a/yanshi/src/lexer.l b/yanshi/src/lexer.l index 44464e2c..0958f7ea 100644 --- a/yanshi/src/lexer.l +++ b/yanshi/src/lexer.l @@ -5,6 +5,7 @@ #include "syntax.hh" #include +#include #include #include #include @@ -28,6 +29,20 @@ static long invalid_escape(YYSTYPE* yylval, const char* text) return INVALID_CHARACTER; } +// atol() cannot report an overflow, so a count beyond LONG_MAX silently +// saturated and could drive an unbounded repeat expansion. Convert with +// strtoll() and saturate explicitly; the parser then rejects the value +// through gen_repeat()'s MAX_REPEAT check. +static long parse_integer_token(const char* text) +{ + errno = 0; + char* end = NULL; + long long value = strtoll(text, &end, 10); + if (errno == ERANGE || value < 0 || value > (long long)LONG_MAX) + return LONG_MAX; + return (long)value; +} + static int invalid_escape_octonary(YYSTYPE* yylval, const char* text) { yylval->errmsg = aprintf("invalid number after \\-escape: %s", text); @@ -116,7 +131,7 @@ L [a-zA-Z_\x80-\xff] "semicolon" semicolon = true; "nosemicolon" semicolon = false; {L}({L}|{D})* yylval->str = new string(yytext); return IDENT; -{D}+ yylval->integer = atol(yytext); return INTEGER; +{D}+ yylval->integer = parse_integer_token(yytext); return INTEGER; "#define" return PREPROCESS_DEFINE; "#" yy_push_state(IN_LINE_COMMENT, yyscanner); diff --git a/yanshi/src/loader.cc b/yanshi/src/loader.cc index 602c6602..ceb1c99a 100644 --- a/yanshi/src/loader.cc +++ b/yanshi/src/loader.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -223,7 +224,11 @@ struct ModuleUse : PrePostActionExprStmtVisitor { // enlarge alphabet expr.define_stmt = NULL; expr.macro_value = d->value; - AB = max(AB, d->value+1); + if (d->value == LONG_MAX) { + n_errors++; + mo.locfile.error(expr.loc, "macro value %ld cannot be used as a label", d->value); + } else + AB = max(AB, d->value+1); } else if (auto d = dynamic_cast(r)) { depended_by[d].push_back(stmt); used_as_embed[d].push_back(&expr); @@ -317,7 +322,12 @@ static vector topo_define_stmts(long& n_errors) vector st; unordered_map vis; // 0: unvisited; 1: in stack; 2: visited; 3: in a cycle unordered_map cnt; - function dfs = [&](DefineStmt* u) { + function dfs = [&](DefineStmt* u, long depth) { + if (depth > MAX_EXPR_DEPTH) { + u->module->locfile.error_context(u->loc, "'%s': embed graph deeper than %ld", u->lhs.c_str(), MAX_EXPR_DEPTH); + n_errors++; + return true; + } if (vis[u] == 2) return false; if (vis[u] == 3) @@ -341,7 +351,7 @@ static vector topo_define_stmts(long& n_errors) st.push_back(u); bool cycle = false; for (auto v: depended_by[u]) - if (dfs(v)) + if (dfs(v, depth+1)) cycle = true; else cnt[u] += cnt[v]; @@ -351,7 +361,7 @@ static vector topo_define_stmts(long& n_errors) return cycle; }; for (auto& d: depended_by) - if (! vis[d.first] && dfs(d.first)) // detected cycle + if (! vis[d.first] && dfs(d.first, 1)) // detected cycle or an embed graph that is too deep n_errors++; reverse(ALL(topo)); if (opt_dump_embed) { diff --git a/yanshi/src/main.cc b/yanshi/src/main.cc index bac75850..bdb2b17c 100644 --- a/yanshi/src/main.cc +++ b/yanshi/src/main.cc @@ -135,6 +135,10 @@ int main(int argc, char *argv[]) case 1005: opt_dump_tree = true; break; case 1006: opt_max_return_stack = get_long(optarg); + // The value is the bound of a generated C array. Reject non-positive and + // absurd sizes before they are written into the output. + if (opt_max_return_stack <= 0 || opt_max_return_stack > 1000000) + err_exit(EX_USAGE, "--max-return-stack must be in (0, 1000000]"); break; case 1007: opt_gen_extern_c = true; break; case '?': diff --git a/yanshi/src/parser.y b/yanshi/src/parser.y index b2ea352e..52831cb5 100644 --- a/yanshi/src/parser.y +++ b/yanshi/src/parser.y @@ -87,6 +87,26 @@ int yylex(YYSTYPE* yylval, YYLTYPE* loc, Stmt*& res, long& errors, const Locatio return token; } +// The tree is walked, and freed, by code that recurses along its edges, so its +// depth is bounded while it is built. The node that would exceed the bound is +// reported and replaced by a leaf: the spine that was built so far is dropped +// with it instead of being carried on, which keeps the work and the memory the +// reduction needs proportional to the input. The report is made once per +// parse, because every crossing of the bound would otherwise print the line +// again. +#define BOUND_EXPR_DEPTH(x) \ + do { \ + if ((x)->nest > MAX_EXPR_DEPTH) { \ + if (!expr_depth_exceeded) { \ + expr_depth_exceeded = true; \ + FAIL(yyloc, "expression nesting depth exceeds the limit"); \ + } \ + delete (x); \ + (x) = new DotExpr; \ + (x)->loc = yyloc; \ + } \ + } while (0) + #define gen_repeat(x, inner, low, high) \ if (low < 0) { \ FAIL(yyloc, "negative"); \ @@ -94,7 +114,13 @@ int yylex(YYSTYPE* yylval, YYLTYPE* loc, Stmt*& res, long& errors, const Locatio if (low > high) { \ FAIL(yyloc, "low > high"); \ } \ - x = new RepeatExpr(inner, low, high) + if (low > MAX_REPEAT || (high != LONG_MAX && high > MAX_REPEAT)) { \ + FAIL(yyloc, "repeat count exceeds limit"); \ + } \ + x = new RepeatExpr(inner, low, high); \ + BOUND_EXPR_DEPTH(x) + +bool expr_depth_exceeded = false; %} %% @@ -134,28 +160,28 @@ define_stmt: union_expr: intersect_expr { $$ = $1; } - | union_expr '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; } + | union_expr '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } union_expr2: intersect_expr { $$ = $1; } - | union_expr2 '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; } - | union_expr2 '\n' '|' intersect_expr { $$ = new UnionExpr($1, $4); $$->loc = yyloc; } + | union_expr2 '|' intersect_expr { $$ = new UnionExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } + | union_expr2 '\n' '|' intersect_expr { $$ = new UnionExpr($1, $4); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } intersect_expr: difference_expr { $$ = $1; } - | intersect_expr AMPERAMPER difference_expr { $$ = new IntersectExpr($1, $3); $$->loc = yyloc; } + | intersect_expr AMPERAMPER difference_expr { $$ = new IntersectExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } difference_expr: concat_expr { $$ = $1; } - | difference_expr '-' concat_expr { $$ = new DifferenceExpr($1, $3); $$->loc = yyloc; } + | difference_expr '-' concat_expr { $$ = new DifferenceExpr($1, $3); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } concat_expr: unop_expr { $$ = $1; } - | concat_expr unop_expr { $$ = new ConcatExpr($1, $2); $$->loc = yyloc; } + | concat_expr unop_expr { $$ = new ConcatExpr($1, $2); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } unop_expr: factor { $$ = $1; } - | '~' unop_expr { $$ = new ComplementExpr($2); $$->loc = yyloc; } + | '~' unop_expr { $$ = new ComplementExpr($2); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } factor: EPSILON { $$ = new EpsilonExpr; $$->loc = yyloc; } @@ -215,9 +241,9 @@ factor: | factor '%' INTEGER action { $$ = $1; $$->leaving.emplace_back($4, $3); } | factor '$' action { $$ = $1; $$->transiting.emplace_back($3, 0L); } | factor '$' INTEGER action { $$ = $1; $$->transiting.emplace_back($4, $3); } - | factor '+' { $$ = new PlusExpr($1); $$->loc = yyloc; } - | factor '?' { $$ = new QuestionExpr($1); $$->loc = yyloc; } - | factor '*' { $$ = new StarExpr($1); $$->loc = yyloc; } + | factor '+' { $$ = new PlusExpr($1); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } + | factor '?' { $$ = new QuestionExpr($1); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } + | factor '*' { $$ = new StarExpr($1); $$->loc = yyloc; BOUND_EXPR_DEPTH($$); } repeat: factor '{' INTEGER ',' INTEGER '}' { gen_repeat($$, $1, $3, $5); $$->loc = yyloc; } @@ -259,6 +285,7 @@ int parse(const LocationFile& locfile, Stmt*& res) raw_yylex_init_extra(0, &lexer); YY_BUFFER_STATE buf = raw_yy_scan_bytes(locfile.data.c_str(), locfile.data.size(), lexer); long errors = 0; + expr_depth_exceeded = false; yyparse(res, errors, locfile, &lexer); raw_yy_delete_buffer(buf, lexer); raw_yylex_destroy(lexer); diff --git a/yanshi/src/repl.cc b/yanshi/src/repl.cc index 2f9bfd5c..860de7af 100644 --- a/yanshi/src/repl.cc +++ b/yanshi/src/repl.cc @@ -159,7 +159,7 @@ char* readline(const char* prompt) static void run_command(char* line) { size_t p = 1; - while (line[p] && isalnum(line[p])) + while (line[p] && isalnum((unsigned char)line[p])) p++; Command* com = NULL; REP(i, LEN(commands)) @@ -172,10 +172,10 @@ static void run_command(char* line) else if (com == (Command*)1) printf("Ambiguous command '%s'\n", line); else { - while (line[p] && isspace(line[p])) + while (line[p] && isspace((unsigned char)line[p])) p++; size_t len = strlen(line); - while (len && isspace(line[len-1])) + while (len && isspace((unsigned char)line[len-1])) line[--len] = '\0'; com->fn(line+p); } diff --git a/yanshi/src/syntax.hh b/yanshi/src/syntax.hh index 99bf0099..3236d7f0 100644 --- a/yanshi/src/syntax.hh +++ b/yanshi/src/syntax.hh @@ -120,6 +120,7 @@ struct RefAction : Visitable { struct Expr : VisitableBase { Location loc; long pre, post, depth; // set by Compiler + long nest = 1; // set by the parser: number of nodes on the longest path down from here vector anc; // set by Compiler vector> entering, finishing, leaving, transiting; DefineStmt* stmt = NULL; // set by ModuleImportDef @@ -148,6 +149,21 @@ struct Expr : VisitableBase { } }; +// nest_of is the nesting depth of a subtree; a leaf is one node deep. +inline long nest_of(const Expr* expr) +{ + return expr->nest; +} + +// nest_above is the depth of the node that holds the given subtrees. +inline long nest_above(const Expr* lhs, const Expr* rhs = NULL) +{ + long n = nest_of(lhs); + if (rhs && nest_of(rhs) > n) + n = nest_of(rhs); + return n+1; +} + struct BracketExpr : Visitable { DisjointIntervals intervals; BracketExpr(DisjointIntervals* intervals) : intervals(std::move(*intervals)) { delete intervals; } @@ -167,7 +183,7 @@ struct CollapseExpr : Visitable { struct ComplementExpr : Visitable { Expr* inner; - ComplementExpr(Expr* inner) : inner(inner) {} + ComplementExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~ComplementExpr() { delete inner; } @@ -175,7 +191,7 @@ struct ComplementExpr : Visitable { struct ConcatExpr : Visitable { Expr *lhs, *rhs; - ConcatExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + ConcatExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~ConcatExpr() { delete lhs; delete rhs; @@ -184,7 +200,7 @@ struct ConcatExpr : Visitable { struct DifferenceExpr : Visitable { Expr *lhs, *rhs; - DifferenceExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + DifferenceExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~DifferenceExpr() { delete lhs; delete rhs; @@ -204,7 +220,7 @@ struct EpsilonExpr : Visitable {}; struct IntersectExpr : Visitable { Expr *lhs, *rhs; - IntersectExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + IntersectExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~IntersectExpr() { delete lhs; delete rhs; @@ -218,7 +234,7 @@ struct LiteralExpr : Visitable { struct PlusExpr : Visitable { Expr* inner; - PlusExpr(Expr* inner) : inner(inner) {} + PlusExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~PlusExpr() { delete inner; } @@ -227,7 +243,7 @@ struct PlusExpr : Visitable { struct RepeatExpr : Visitable { Expr* inner; long low, high; - RepeatExpr(Expr* inner, long low, long high) : inner(inner), low(low), high(high) {} + RepeatExpr(Expr* inner, long low, long high) : inner(inner), low(low), high(high) { nest = nest_above(inner); } ~RepeatExpr() { delete inner; } @@ -235,7 +251,7 @@ struct RepeatExpr : Visitable { struct QuestionExpr : Visitable { Expr* inner; - QuestionExpr(Expr* inner) : inner(inner) {} + QuestionExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~QuestionExpr() { delete inner; } @@ -243,7 +259,7 @@ struct QuestionExpr : Visitable { struct StarExpr : Visitable { Expr* inner; - StarExpr(Expr* inner) : inner(inner) {} + StarExpr(Expr* inner) : inner(inner) { nest = nest_above(inner); } ~StarExpr() { delete inner; } @@ -251,7 +267,7 @@ struct StarExpr : Visitable { struct UnionExpr : Visitable { Expr *lhs, *rhs; - UnionExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) {} + UnionExpr(Expr* lhs, Expr* rhs) : lhs(lhs), rhs(rhs) { nest = nest_above(lhs, rhs); } ~UnionExpr() { delete lhs; delete rhs;