From 68263215704222d83eaaf1349736762a9614e3ad Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Fri, 24 Apr 2026 04:21:34 -0700 Subject: [PATCH] feat(security): set X-Content-Type-Options: nosniff by default (#37354) Fixes #37316. --------- Signed-off-by: SAY-5 Co-authored-by: SAY-5 Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) Co-authored-by: wxiaoguang --- custom/conf/app.example.ini | 5 ++++- modules/setting/security.go | 8 ++++++-- routers/api/v1/api.go | 12 ------------ routers/common/errpage.go | 4 ---- routers/common/middleware.go | 16 ++++++++++++++++ services/context/context.go | 4 ---- tests/integration/view_test.go | 22 +++++++++++++++++++--- 7 files changed, 45 insertions(+), 26 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 428ad34aea..97af5fa5fb 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -525,8 +525,11 @@ INTERNAL_TOKEN = ;; Set to "enforced", to force users to enroll into Two-Factor Authentication, users without 2FA have no access to repositories via API or web. ;TWO_FACTOR_AUTH = ;; -;; The value of the X-Frame-Options HTTP header for HTML responses. Use "unset" to remove the header. +;; The value of the X-Frame-Options HTTP header for all responses. Use "unset" to remove the header. ;X_FRAME_OPTIONS = SAMEORIGIN +;; +;; The value of the X-Content-Type-Options HTTP header for all responses. Use "unset" to remove the header. +;X_CONTENT_TYPE_OPTIONS = nosniff ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/modules/setting/security.go b/modules/setting/security.go index 152bcffd9f..8b7664baba 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -16,9 +16,11 @@ import ( // Security settings var Security = struct { // TODO: move more settings to this struct in future - XFrameOptions string + XFrameOptions string + XContentTypeOptions string }{ - XFrameOptions: "SAMEORIGIN", + XFrameOptions: "SAMEORIGIN", + XContentTypeOptions: "nosniff", } var ( @@ -154,6 +156,8 @@ func loadSecurityFrom(rootCfg ConfigProvider) { Security.XFrameOptions = rootCfg.Section("cors").Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions) } + Security.XContentTypeOptions = sec.Key("X_CONTENT_TYPE_OPTIONS").MustString(Security.XContentTypeOptions) + twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String() switch twoFactorAuth { case "": diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 68849b8771..e13bbedd29 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -865,7 +865,6 @@ func checkDeprecatedAuthMethods(ctx *context.APIContext) { func Routes() *web.Router { m := web.NewRouter() - m.BeforeRouting(securityHeaders()) if setting.CORSConfig.Enabled { m.BeforeRouting(cors.Handler(cors.Options{ AllowedOrigins: setting.CORSConfig.AllowDomain, @@ -1749,14 +1748,3 @@ func Routes() *web.Router { return m } - -func securityHeaders() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - // CORB: https://www.chromium.org/Home/chromium-security/corb-for-developers - // http://stackoverflow.com/a/3146618/244009 - resp.Header().Set("x-content-type-options", "nosniff") - next.ServeHTTP(resp, req) - }) - } -} diff --git a/routers/common/errpage.go b/routers/common/errpage.go index 07760bcd18..9baf7915e1 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -33,10 +33,6 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in } httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) - if setting.Security.XFrameOptions != "unset" { - w.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions) - } - tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req)) w.WriteHeader(respCode) diff --git a/routers/common/middleware.go b/routers/common/middleware.go index 39911e2548..3932a84b6d 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -28,6 +28,7 @@ func ProtocolMiddlewares() (handlers []any) { // the order is important handlers = append(handlers, ChiRoutePathHandler()) // make sure chi has correct paths handlers = append(handlers, RequestContextHandler()) // prepare the context and panic recovery + handlers = append(handlers, SecurityHeadersHandler()) if setting.ReverseProxyLimit > 0 && len(setting.ReverseProxyTrustedProxies) > 0 { handlers = append(handlers, ForwardedHeadersHandler(setting.ReverseProxyLimit, setting.ReverseProxyTrustedProxies)) @@ -48,6 +49,21 @@ func ProtocolMiddlewares() (handlers []any) { return handlers } +// SecurityHeadersHandler sets headers globally for every response that leaves Gitea. +func SecurityHeadersHandler() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + if setting.Security.XContentTypeOptions != "unset" { + resp.Header().Set("X-Content-Type-Options", setting.Security.XContentTypeOptions) + } + if setting.Security.XFrameOptions != "unset" { + resp.Header().Set("X-Frame-Options", setting.Security.XFrameOptions) + } + next.ServeHTTP(resp, req) + }) + } +} + func RequestContextHandler() func(h http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) { diff --git a/services/context/context.go b/services/context/context.go index d6030808d8..e4c2c1d6fb 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -196,10 +196,6 @@ func Contexter() func(next http.Handler) http.Handler { httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{NoTransform: true}) - if setting.Security.XFrameOptions != "unset" { - ctx.Resp.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions) - } - ctx.Data["SystemConfig"] = setting.Config() ctx.Data["ShowTwoFactorRequiredMessage"] = ctx.DoerNeedTwoFactorAuth() diff --git a/tests/integration/view_test.go b/tests/integration/view_test.go index 9ed3e30857..e91f8ef953 100644 --- a/tests/integration/view_test.go +++ b/tests/integration/view_test.go @@ -12,9 +12,14 @@ import ( "github.com/stretchr/testify/assert" ) -func TestRenderFileSVGIsInImgTag(t *testing.T) { +func TestView(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("RenderFileSVGIsInImgTag", testRenderFileSVGIsInImgTag) + t.Run("CommitListActions", testCommitListActions) + t.Run("SecurityHeadersDefaults", testSecurityHeadersDefaults) +} +func testRenderFileSVGIsInImgTag(t *testing.T) { session := loginUser(t, "user2") req := NewRequest(t, "GET", "/user2/repo2/src/branch/master/line.svg") @@ -26,8 +31,7 @@ func TestRenderFileSVGIsInImgTag(t *testing.T) { assert.Equal(t, "/user2/repo2/raw/branch/master/line.svg", src) } -func TestCommitListActions(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testCommitListActions(t *testing.T) { session := loginUser(t, "user2") t.Run("WikiRevisionList", func(t *testing.T) { @@ -65,3 +69,15 @@ func TestCommitListActions(t *testing.T) { AssertHTMLElement(t, htmlDoc, `.commit-list .view-commit-path`, true) }) } + +func testSecurityHeadersDefaults(t *testing.T) { + assertSecurityHeaders := func(t *testing.T, uri string) { + req := NewRequest(t, "GET", uri) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) + assert.Equal(t, "SAMEORIGIN", resp.Header().Get("X-Frame-Options")) + } + assertSecurityHeaders(t, "/") + assertSecurityHeaders(t, "/api/v1/version") + assertSecurityHeaders(t, "/assets/img/favicon.png") +}