From 5a592cf25047147e50661ebbc0415fa57e19ecb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scout=E5=BC=80=E5=8F=91=E8=80=85?= Date: Mon, 31 Aug 2026 00:13:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=9F=E4=BA=A7=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E6=A3=80=E6=9F=A5=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/Caddyfile | 8 ++++++++ deploy/nginx.conf | 5 +++++ internal/httpapi/security.go | 14 ++++++++++++++ internal/httpapi/security_test.go | 14 ++++++++++++++ 使用说明.md | 4 +++- 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/deploy/Caddyfile b/deploy/Caddyfile index dc5dd56..291aac4 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,6 +1,14 @@ http://packages.example.com { encode gzip + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'" + } + handle /api/v1/updates/stream { reverse_proxy scout:8080 { flush_interval -1 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 19b8c0e..4f33f80 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -10,6 +10,11 @@ server { index index.html; client_max_body_size 512m; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'" always; location /api/v1/updates/stream { proxy_pass http://scout:8080; diff --git a/internal/httpapi/security.go b/internal/httpapi/security.go index cccca96..8d78505 100644 --- a/internal/httpapi/security.go +++ b/internal/httpapi/security.go @@ -24,6 +24,10 @@ func (s *Server) hostGuard(next http.Handler) http.Handler { return next } return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/healthz" && isLocalRequest(request) { + next.ServeHTTP(writer, request) + return + } if len(s.AllowedHosts) > 0 && !hostAllowed(request.Host, s.AllowedHosts) { writeError(writer, http.StatusMisdirectedRequest, "请求域名不在允许列表中") return @@ -36,6 +40,16 @@ func (s *Server) hostGuard(next http.Handler) http.Handler { }) } +func isLocalRequest(request *http.Request) bool { + value := strings.TrimSpace(request.RemoteAddr) + host, _, err := net.SplitHostPort(value) + if err != nil { + host = value + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + func (s *Server) rateLimit(next http.Handler) http.Handler { if s.RateLimitPerMinute <= 0 { return next diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index 4cab86c..64e504a 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -33,6 +33,20 @@ func TestHostGuardRejectsUnknownHost(t *testing.T) { } } +func TestLocalHealthCheckBypassesProxyGuards(t *testing.T) { + server := (&Server{ + AllowedHosts: []string{"packages.example.com"}, + ForceHTTPS: true, + }).Handler() + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/healthz", nil) + request.RemoteAddr = "127.0.0.1:34567" + response := httptest.NewRecorder() + server.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("本机健康检查状态错误: %d", response.Code) + } +} + func TestRateLimit(t *testing.T) { server := (&Server{RateLimitPerMinute: 1}).Handler() first := httptest.NewRequest(http.MethodGet, "/healthz", nil) diff --git a/使用说明.md b/使用说明.md index 4ebcb8e..512289a 100644 --- a/使用说明.md +++ b/使用说明.md @@ -287,7 +287,7 @@ chmod 600 /etc/scout/scout.env ```bash cp deploy/scout.env.example deploy/scout.env # 编辑 deploy/scout.env,替换所有生产占位符 - docker compose -f deploy/docker-compose.yml up -d --build +docker compose -f deploy/docker-compose.yml up -d --build ``` Compose 会启动 Scout API 和前端静态站点: @@ -339,6 +339,8 @@ sudo journalctl -u scout.service -f 外部网关终止 HTTPS 后,必须将 `X-Forwarded-Proto: https` 传给 Scout;否则生产配置中的 `SCOUT_FORCE_HTTPS=true` 会拒绝请求。这是协议边界检查,不是 Scout 的证书管理功能。 +`deploy/Caddyfile` 仅是 Caddy 反向代理的参考配置,其中的示例主机名需要替换为实际访问地址。它不会申请或配置 SSL 证书,也不会执行 DNS 解析、域名绑定或服务商 API 操作;如果 SSL 和 DNS 已由其他服务处理,请继续让该服务负责 TLS 终止,并把请求转发到 Scout 的入口。 + ### 健康检查、备份和升级 健康检查接口: