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>
123 lines
3.6 KiB
Go
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")
|
|
}
|