diff --git a/PROJECT.md b/PROJECT.md index 83ed98f..9dd72c7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -14,7 +14,8 @@ ## 配置(启动层) -- 来源优先级:内置默认 → 显式配置文件(`--config `,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。 +- 来源优先级:内置默认 → 显式配置文件(`--config `,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK/INSTANCE/INSECURE_LISTEN/ALLOWED_HOSTS`)→ 显式 CLI(未传入的 flag 不覆盖)。 +- 监听守卫:非回环地址且未开认证时拒绝启动(在打开数据库之前)。出路是改用 `127.0.0.1`、`--auth on`,或显式 `--insecure-listen`(每次启动警告)。`config explain` 会打印 `listen guard`。 - 只读诊断:`agent-system config explain [--config …] [-- …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。 - 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。 - 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。 diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 13d8aa7..8d9b1b3 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -42,13 +42,15 @@ func main() { artifacts := fs.String("artifacts", "", "工件对象目录(默认 /artifacts;PG 档多实例时指向共享卷)") auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off") instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)") + insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)") + allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验") fs.Parse(os.Args[1:]) - cliSet := map[string]bool{} - fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true }) + cliSet := visited(fs) resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance, + InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, }, cliSet) if err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) @@ -57,13 +59,29 @@ func main() { if err := app.Run(app.Options{ Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock, StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode, - InstanceID: resolved.Instance, + InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(), }); err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) os.Exit(1) } } +// visited 把连字符 flag 收成配置字段名,供 Load 的 cliSet 使用。 +func visited(fs *flag.FlagSet) map[string]bool { + set := map[string]bool{} + fs.Visit(func(f *flag.Flag) { + name := f.Name + switch name { + case "insecure-listen": + name = "insecureListen" + case "allowed-hosts": + name = "allowedHosts" + } + set[name] = true + }) + return set +} + // runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。 func runConfig(args []string) int { if len(args) == 0 || args[0] != "explain" { @@ -80,13 +98,14 @@ func runConfig(args []string) int { artifacts := fs.String("artifacts", "", "") auth := fs.String("auth", "auto", "") instance := fs.String("instance", "", "") + insecure := fs.Bool("insecure-listen", false, "") + allowedHosts := fs.String("allowed-hosts", "", "") fs.Parse(args[1:]) - cliSet := map[string]bool{} - fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true }) resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance, - }, cliSet) + InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, + }, visited(fs)) if err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) return 1 diff --git a/internal/app/app.go b/internal/app/app.go index 9598eb9..bea839e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -33,6 +33,7 @@ import ( "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/config" "agent-system-backend/internal/hostapi" "agent-system-backend/internal/intentgate" "agent-system-backend/internal/pgbus" @@ -54,14 +55,16 @@ import ( ) type Options struct { - Addr string - DataDir string - PluginsDir string - ForceMock bool - StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例) - ArtifactsDir string // PG 档工件对象目录(空 = /artifacts) - AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off - InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例 + Addr string + DataDir string + PluginsDir string + ForceMock bool + StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例) + ArtifactsDir string // PG 档工件对象目录(空 = /artifacts) + AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off + InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例 + InsecureListen bool + AllowedHosts []string } // Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。 @@ -71,6 +74,15 @@ func Run(opt Options) error { Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}}, }) logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir) + resolved := config.Resolved{Config: config.Config{ + Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen, + }} + if msg := resolved.ListenRefusal(); msg != "" { + return fmt.Errorf("listen guard: %s", msg) + } + if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() { + log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr) + } traceFn := telemetry.StartFunc(logger) var st *store.Store var err error @@ -370,7 +382,7 @@ func Run(opt Options) error { return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0)) }) // 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。 - authOn := opt.AuthMode == "on" || (opt.AuthMode != "off" && opt.StorageDSN != "") + authOn := resolved.AuthEnabled() srv.Auth = server.NewAuthenticator(st, authOn) if authOn { log.Printf("auth: API key required (create with: agent-system apikey create --owner )") @@ -379,7 +391,9 @@ func Run(opt Options) error { return b.Subscribe(conversationID) } - httpSrv := &http.Server{Addr: opt.Addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} + httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{ + AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(), + }} go func() { log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir) diff --git a/internal/config/config.go b/internal/config/config.go index d1f2160..ba40f89 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,20 +9,23 @@ import ( "encoding/json" "fmt" "io" + "net" "os" "strings" ) // Config 启动部署连接类字段(与 app.Options 一一对应)。 type Config struct { - Addr string `json:"addr"` - DataDir string `json:"data"` - PluginsDir string `json:"plugins"` - StorageDSN string `json:"dsn"` - ArtifactsDir string `json:"artifacts"` - AuthMode string `json:"auth"` - Mock bool `json:"mock"` - Instance string `json:"instance"` + Addr string `json:"addr"` + DataDir string `json:"data"` + PluginsDir string `json:"plugins"` + StorageDSN string `json:"dsn"` + ArtifactsDir string `json:"artifacts"` + AuthMode string `json:"auth"` + Mock bool `json:"mock"` + Instance string `json:"instance"` + InsecureListen bool `json:"insecureListen"` + AllowedHostsRaw string `json:"allowedHosts"` } var defaults = Config{ @@ -34,14 +37,16 @@ var defaults = Config{ // envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。 var envKey = map[string]string{ - "addr": "AGENT_SYSTEM_ADDR", - "data": "AGENT_SYSTEM_DATA", - "plugins": "AGENT_SYSTEM_PLUGINS", - "dsn": "AGENT_SYSTEM_DSN", - "artifacts": "AGENT_SYSTEM_ARTIFACTS", - "auth": "AGENT_SYSTEM_AUTH", - "mock": "AGENT_SYSTEM_MOCK", - "instance": "AGENT_SYSTEM_INSTANCE", + "addr": "AGENT_SYSTEM_ADDR", + "data": "AGENT_SYSTEM_DATA", + "plugins": "AGENT_SYSTEM_PLUGINS", + "dsn": "AGENT_SYSTEM_DSN", + "artifacts": "AGENT_SYSTEM_ARTIFACTS", + "auth": "AGENT_SYSTEM_AUTH", + "mock": "AGENT_SYSTEM_MOCK", + "instance": "AGENT_SYSTEM_INSTANCE", + "insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN", + "allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS", } // Source 配置来源层级(数值越大优先级越高)。 @@ -91,8 +96,8 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet } // 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。 for f := range present { - if f == "mock" { - cfg.Mock = fc.Mock + if isBoolField(f) { + setBool(&cfg, f, boolField(fc, f)) } else { setString(&cfg, f, stringField(fc, f)) } @@ -102,12 +107,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet // 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。 for f, key := range envKey { if v, ok := envLookup(key); ok { - if f == "mock" { + if isBoolField(f) { b, err := parseBool(key, v) if err != nil { return Resolved{}, err } - cfg.Mock = b + setBool(&cfg, f, b) } else { setString(&cfg, f, v) } @@ -117,8 +122,8 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet // CLI 层:仅显式传入。 for f := range cliSet { src[f] = SrcCLI - if f == "mock" { - cfg.Mock = cli.Mock + if isBoolField(f) { + setBool(&cfg, f, boolField(cli, f)) } else { setString(&cfg, f, stringField(cli, f)) } @@ -156,6 +161,10 @@ func loadFile(path string) (Config, map[string]bool, error) { dst = &c.Mock case "instance": dst = &c.Instance + case "insecureListen": + dst = &c.InsecureListen + case "allowedHosts": + dst = &c.AllowedHostsRaw default: return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k) } @@ -183,10 +192,34 @@ func stringField(c Config, f string) string { return c.AuthMode case "instance": return c.Instance + case "allowedHosts": + return c.AllowedHostsRaw } return "" } +func isBoolField(f string) bool { + return f == "mock" || f == "insecureListen" +} + +func boolField(c Config, f string) bool { + switch f { + case "insecureListen": + return c.InsecureListen + default: + return c.Mock + } +} + +func setBool(c *Config, f string, v bool) { + switch f { + case "insecureListen": + c.InsecureListen = v + default: + c.Mock = v + } +} + func setString(c *Config, f, v string) { switch f { case "addr": @@ -203,6 +236,8 @@ func setString(c *Config, f, v string) { c.AuthMode = v case "instance": c.Instance = v + case "allowedHosts": + c.AllowedHostsRaw = v } } @@ -223,17 +258,66 @@ func (r Resolved) Explain(w io.Writer, file string) { } else { fmt.Fprintln(w, "config file: (none)") } - fmt.Fprintln(w, "field value source") - for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance"} { + fmt.Fprintln(w, "field value source") + for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts"} { v := stringField(r.Config, f) - if f == "mock" { - v = fmt.Sprintf("%t", r.Mock) + if isBoolField(f) { + v = fmt.Sprintf("%t", boolField(r.Config, f)) } if f == "dsn" { v = redact(v) } - fmt.Fprintf(w, "%-10s %-56s %s\n", f, v, r.sources[f]) + fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f]) } + if msg := r.ListenRefusal(); msg != "" { + fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg) + } else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() { + fmt.Fprintln(w, "listen guard: 放行(已显式 insecureListen,无认证监听非回环地址)") + } else { + fmt.Fprintln(w, "listen guard: 放行") + } +} + +// AuthEnabled 是认证开关的唯一判定:显式 on,或 auto 且配置了 PostgreSQL。 +func (r Resolved) AuthEnabled() bool { + return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "") +} + +// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。 +func (r Resolved) AllowedHosts() []string { + var out []string + for _, part := range strings.Split(r.AllowedHostsRaw, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +// ListenRefusal 在非回环、未开认证、且没有显式放行时返回拒绝原因。空串表示可以启动。 +func (r Resolved) ListenRefusal() string { + if r.AuthEnabled() || r.InsecureListen || IsLoopbackListen(r.Addr) { + return "" + } + return "非回环且未开认证。改用 127.0.0.1、加上 --auth on,或显式 --insecure-listen" +} + +// IsLoopbackListen 判断监听地址是否只绑回环。空主机、0.0.0.0、:: 视为所有网卡。 +func IsLoopbackListen(addr string) bool { + host := addr + if h, _, err := net.SplitHostPort(addr); err == nil { + host = h + } + host = strings.Trim(strings.ToLower(host), "[]") + switch host { + case "", "0.0.0.0", "::": + return false + case "localhost": + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() } // redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32b3b57..d9acda1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -148,3 +148,43 @@ func TestExplainRedactsDSN(t *testing.T) { t.Fatalf("source column missing: %s", out) } } + +func TestListenGuard(t *testing.T) { + cases := []struct { + addr string + ok bool + }{ + {"127.0.0.1:8787", true}, + {"localhost:8787", true}, + {"[::1]:8787", true}, + {":8787", false}, + {"0.0.0.0:8787", false}, + {"[::]:8787", false}, + {"192.168.1.9:8787", false}, + } + for _, tc := range cases { + if got := IsLoopbackListen(tc.addr); got != tc.ok { + t.Errorf("%s loopback=%v want %v", tc.addr, got, tc.ok) + } + } + refused, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788"}, map[string]bool{"addr": true}) + if err != nil { + t.Fatal(err) + } + if refused.ListenRefusal() == "" { + t.Fatal("non-loopback without auth must refuse") + } + var b bytes.Buffer + refused.Explain(&b, "") + if !bytes.Contains(b.Bytes(), []byte("拒绝启动")) { + t.Fatalf("explain missing refusal: %s", b.String()) + } + allowed, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788", InsecureListen: true}, map[string]bool{"addr": true, "insecureListen": true}) + if err != nil || allowed.ListenRefusal() != "" { + t.Fatalf("explicit insecure listen should pass: %v %q", err, allowed.ListenRefusal()) + } + authed, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788", AuthMode: "on"}, map[string]bool{"addr": true, "auth": true}) + if err != nil || !authed.AuthEnabled() || authed.ListenRefusal() != "" { + t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal()) + } +} diff --git a/internal/server/guard.go b/internal/server/guard.go new file mode 100644 index 0000000..1ae05f6 --- /dev/null +++ b/internal/server/guard.go @@ -0,0 +1,122 @@ +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") +} diff --git a/internal/server/guard_test.go b/internal/server/guard_test.go new file mode 100644 index 0000000..1211630 --- /dev/null +++ b/internal/server/guard_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func guard(auth bool, hosts ...string) RequestGuard { + return RequestGuard{ + AuthOn: auth, AllowedHosts: hosts, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + } +} + +func TestRequestGuardWrites(t *testing.T) { + cases := []struct { + name string + auth bool + host string + origin string + site string + want int + }{ + {"same origin", false, "127.0.0.1:8788", "http://127.0.0.1:8788", "", http.StatusNoContent}, + {"cross origin", false, "127.0.0.1:8788", "https://evil.example", "", http.StatusForbidden}, + {"null origin", false, "127.0.0.1:8788", "null", "", http.StatusForbidden}, + {"sec-fetch cross", false, "127.0.0.1:8788", "", "cross-site", http.StatusForbidden}, + {"sec-fetch same", false, "127.0.0.1:8788", "", "same-origin", http.StatusNoContent}, + {"no browser headers", false, "127.0.0.1:8788", "", "", http.StatusNoContent}, + {"allowed host origin", false, "127.0.0.1:8788", "https://app.example", "", http.StatusNoContent}, + {"evil host without auth", false, "evil.example", "", "", http.StatusForbidden}, + {"evil host with auth", true, "evil.example", "", "", http.StatusNoContent}, + {"ip host", false, "192.168.1.9:8788", "", "", http.StatusNoContent}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/memory", strings.NewReader(`{}`)) + req.Host = tc.host + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.site != "" { + req.Header.Set("Sec-Fetch-Site", tc.site) + } + rec := httptest.NewRecorder() + hosts := []string{} + if tc.name == "allowed host origin" { + hosts = []string{"app.example"} + } + guard(tc.auth, hosts...).ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("status %d want %d body %s", rec.Code, tc.want, rec.Body.String()) + } + if rec.Code == http.StatusForbidden && !strings.Contains(rec.Body.String(), "allowedHosts") { + t.Fatalf("refusal should mention allowedHosts: %s", rec.Body.String()) + } + }) + } +} + +func TestPageAndArtifactHeaders(t *testing.T) { + rec := httptest.NewRecorder() + setPageHeaders(rec) + if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Content-Type-Options") != "nosniff" || rec.Header().Get("Referrer-Policy") != "same-origin" { + t.Fatalf("page headers: %v", rec.Header()) + } + art := httptest.NewRecorder() + setArtifactHeaders(art, "text/html; charset=utf-8") + if !strings.Contains(art.Header().Get("Content-Security-Policy"), "sandbox") || art.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("artifact headers: %v", art.Header()) + } + plain := httptest.NewRecorder() + setArtifactHeaders(plain, "text/plain") + if plain.Header().Get("Content-Security-Policy") != "" { + t.Fatalf("plain artifact should not be sandboxed: %v", plain.Header()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 616fc90..72c793f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -379,9 +379,11 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { } path = "index.html" } + w.Header().Set("X-Content-Type-Options", "nosniff") switch { case strings.HasSuffix(path, ".html"): w.Header().Set("Content-Type", "text/html; charset=utf-8") + setPageHeaders(w) case strings.HasSuffix(path, ".js"): w.Header().Set("Content-Type", "text/javascript; charset=utf-8") case strings.HasSuffix(path, ".css"): @@ -1042,7 +1044,9 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - w.Header().Set("Content-Type", contentTypeOf(a)) + ct := contentTypeOf(a) + w.Header().Set("Content-Type", ct) + setArtifactHeaders(w, ct) w.Header().Set("Content-Disposition", contentDisposition(a.Name, r.URL.Query().Get("download") == "1")) w.Write(content) }