Files
Agent-System-Backend/internal/server/guard.go
T
weichenandCursor 8eda35f365 Refuse open listeners and cross-site writes without auth
Why: The embedded tier starts without auth, and nothing checked Origin or Host, so a public bind or a foreign page could call write APIs.
Evidence: go test ./internal/config ./internal/server covering listen policy, origin rejection, and response headers.
Impact: Non-loopback without auth exits before opening the database. HTML artifacts get a sandbox CSP. Reverse proxies must list their public host in allowedHosts.
Refs: feature/sillytavern-review
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 17:28:46 +00:00

123 lines
3.6 KiB
Go

package server
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// RequestGuard 包在组合根的 Handler 外面。
// Handler 本身不改,测试里的 httptest 请求仍直接打 Handler。
type RequestGuard struct {
AuthOn bool
AllowedHosts []string
Next http.Handler
}
func (g RequestGuard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if isWriteMethod(r.Method) {
if msg := rejectWriteOrigin(r, g.AllowedHosts); msg != "" {
http.Error(w, msg, http.StatusForbidden)
return
}
}
if !g.AuthOn {
if msg := rejectHost(r, g.AllowedHosts); msg != "" {
http.Error(w, msg, http.StatusForbidden)
return
}
}
g.Next.ServeHTTP(w, r)
}
func isWriteMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func rejectWriteOrigin(r *http.Request, allowed []string) string {
origin := r.Header.Get("Origin")
if origin == "null" {
return "拒绝来源为 null 的写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
if origin != "" {
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" {
return "拒绝无法识别来源的写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
if hostsMatch(parsed.Host, r.Host) || hostListed(parsed.Hostname(), allowed) {
return ""
}
return "写请求的来源与访问地址不一致。经反向代理访问时,把对外域名加入 allowedHosts。"
}
site := r.Header.Get("Sec-Fetch-Site")
if site != "" && site != "same-origin" && site != "none" {
return "拒绝跨站写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
return ""
}
func rejectHost(r *http.Request, allowed []string) string {
host := requestHostname(r.Host)
if host == "" {
return "拒绝没有 Host 的请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
if strings.EqualFold(host, "localhost") || net.ParseIP(host) != nil || hostListed(host, allowed) {
return ""
}
return fmt.Sprintf("拒绝主机名 %s。经反向代理访问时,把对外域名加入 allowedHosts。", host)
}
func requestHostname(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(hostport, "[]")
}
func hostsMatch(a, b string) bool {
return strings.EqualFold(requestHostname(a), requestHostname(b))
}
func hostListed(host string, allowed []string) bool {
for _, item := range allowed {
if strings.EqualFold(requestHostname(item), host) {
return true
}
}
return false
}
const pageCSP = "script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; " +
"img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https:"
const activeCSP = "sandbox allow-scripts allow-popups"
func setPageHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Security-Policy", pageCSP)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
}
func setArtifactHeaders(w http.ResponseWriter, contentType string) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if isActiveContent(contentType) {
w.Header().Set("Content-Security-Policy", activeCSP)
}
}
func isActiveContent(contentType string) bool {
ct := strings.ToLower(contentType)
return strings.Contains(ct, "text/html") ||
strings.Contains(ct, "application/xhtml") ||
strings.Contains(ct, "image/svg") ||
strings.Contains(ct, "xml")
}