Use Content-Security-Policy: script nonce (#37232)

Fix #305
This commit is contained in:
wxiaoguang
2026-04-16 04:07:57 +08:00
committed by GitHub
parent 2644bb8490
commit 82bfde2a37
18 changed files with 134 additions and 52 deletions

View File

@@ -72,7 +72,7 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out
</head>
<body>
<div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div>
<script type="module" src="%s"></script>
<script nonce type="module" src="%s"></script>
</body>
</html>`,
public.AssetURI("css/swagger.css"),

View File

@@ -248,7 +248,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
extraHeadHTML = htmlutil.HTMLFormat(`<script crossorigin src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraLinkHref)
extraHeadHTML = htmlutil.HTMLFormat(`<script nonce crossorigin src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraLinkHref)
}
ctx.usedByRender = true

View File

@@ -6,12 +6,10 @@ package templates
import (
"fmt"
"html"
"html/template"
"net/url"
"strconv"
"strings"
"sync"
"time"
"code.gitea.io/gitea/modules/base"
@@ -69,8 +67,7 @@ func newFuncMapWebPage() template.FuncMap {
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
},
"AssetURI": public.AssetURI,
"ScriptImport": scriptImport,
"AssetURI": public.AssetURI,
// -----------------------------------------------------------------
// setting
@@ -290,30 +287,3 @@ func QueryBuild(a ...any) template.URL {
}
return template.URL(s)
}
var globalVars = sync.OnceValue(func() (ret struct {
scriptImportRemainingPart string
},
) {
// add onerror handler to alert users when the script fails to load:
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
// the message will be directly put in the onerror JS code's string
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
if !setting.IsProd {
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
}
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
return ret
})
func scriptImport(path string, typ ...string) template.HTML {
if len(typ) > 0 {
if typ[0] == "module" {
return template.HTML(`<script type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}
panic("unsupported script type: " + typ[0])
}
return template.HTML(`<script src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}

View File

@@ -6,11 +6,14 @@ package util
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
rand2 "math/rand/v2"
"slices"
"strconv"
"strings"
"sync"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -85,10 +88,39 @@ func CryptoRandomString(length int64) (string, error) {
// CryptoRandomBytes generates `length` crypto bytes
// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range
// This function generates totally random bytes, each byte is generated by [0,255] range
// TODO: it never fails, remove the "error" in the future
func CryptoRandomBytes(length int64) ([]byte, error) {
buf := make([]byte, length)
_, err := rand.Read(buf)
return buf, err
if _, err := rand.Read(buf); err != nil {
panic(err) // this should never happen, "rand.Read" never fails
}
return buf, nil
}
var chaCha8RandPool = sync.OnceValue(func() *sync.Pool {
return &sync.Pool{
New: func() any {
var buf [32]byte
_, _ = rand.Read(buf[:])
return rand2.NewChaCha8(buf)
},
}
})
func FastCryptoRandomBytes(length int) []byte {
// ChaCha8 is about 20x times faster than system's crypto/rand.
// It is suitable for UUIDs, session IDs, etc
pool := chaCha8RandPool()
chaCha8Rand := pool.Get().(*rand2.ChaCha8)
defer pool.Put(chaCha8Rand)
buf := make([]byte, length)
_, _ = chaCha8Rand.Read(buf)
return buf
}
func FastCryptoRandomHex(length int) string {
buf := FastCryptoRandomBytes(length / 2)
return hex.EncodeToString(buf)
}
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.

View File

@@ -63,8 +63,6 @@ type Context struct {
Package *Package
}
type TemplateContext map[string]any
func init() {
web.RegisterResponseStatusProvider[*Base](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(BaseContextKey).(*Base)

View File

@@ -5,18 +5,25 @@ package context
import (
"context"
"fmt"
"html"
"html/template"
"net/http"
"strconv"
"strings"
"sync"
"time"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/webtheme"
)
type TemplateContext map[string]any
var _ context.Context = TemplateContext(nil)
func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext {
@@ -83,3 +90,72 @@ func (c TemplateContext) AppFullLink(link ...string) template.URL {
}
return template.URL(s + strings.TrimPrefix(link[0], "/"))
}
var globalVars = sync.OnceValue(func() (ret struct {
scriptImportRemainingPart string
},
) {
// add onerror handler to alert users when the script fails to load:
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
// the message will be directly put in the onerror JS code's string
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
if !setting.IsProd {
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
}
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
return ret
})
func (c TemplateContext) ScriptImport(path string, typ ...string) template.HTML {
if len(typ) > 0 {
if typ[0] == "module" {
return template.HTML(`<script nonce="` + c.CspScriptNonce() + `" type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}
panic("unsupported script type: " + typ[0])
}
return template.HTML(`<script nonce="` + c.CspScriptNonce() + `" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}
func (c TemplateContext) CspScriptNonce() (ret string) {
// Generate a random nonce for each request and cache it in the context to make it usable during the whole rendering process.
//
// Some "<script>" tags are not in the CSP context, so they don't need nonce,
// these tags are written as "<script nonce>" to help developers to know that "no script nonce attribute is missing"
// (e.g.: when they grep the codebase for "script" tags)
ret, _ = c["_cspScriptNonce"].(string)
if ret == "" {
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
c["_cspScriptNonce"] = ret
}
return ret
}
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
// The CSP problem is more complicated than it looks.
// Gitea was designed to support various "customizations", including:
// * custom themes (custom CSS and JS)
// * custom assets URL (CDN)
// * custom plugins and external renders (e.g.: PlantUML render, and the renders might also load some JS/CSS assets)
// There is no easy way for end users to make the CSP "source" completely right.
//
// There can be 2 approaches in the future:
// A. Let end users to configure their reverse proxy to add CSP header
// * Browsers will merge and use the stricter rules between Gitea and reverse proxy
// B. Introduce some config options in "app.ini"
// * Maybe this approach should be avoided, don't make the config system too complex, just let users use A
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` +
// allow all by default (the same as old releases with no CSP)
// "data:" is used to load the manifest in head (maybe also need to be refactored in the future)
// maybe some images are also loaded by "data:", need to investigate
`default-src * data:;` +
// enforce nonce for all scripts, disallow inline scripts
`script-src * 'nonce-` + c.CspScriptNonce() + `';` +
// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
`style-src * 'unsafe-inline';` +
`">`)
}

View File

@@ -9,7 +9,7 @@
</div>
{{template "custom/body_outer_post" .}}
{{template "base/footer_content" .}}
{{ScriptImport "js/index.js" "module"}}
{{ctx.ScriptImport "js/index.js" "module"}}
{{template "custom/footer" .}}
</body>
</html>

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
<head>
{{ctx.HeadMetaContentSecurityPolicy}}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} - {{end}}{{.PageTitleCommon}}</title>
{{if .ManifestData}}<link rel="manifest" href="data:{{.ManifestData}}">{{end}}

View File

@@ -2,7 +2,7 @@
If you are customizing Gitea, please do not change this file.
If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
*/}}
<script>
<script nonce="{{ctx.CspScriptNonce}}">
{{/* before our JS code gets loaded, use arrays to store errors, then the arrays will be switched to our error handler later */}}
window.addEventListener('error', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
window.addEventListener('unhandledrejection', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
@@ -31,4 +31,4 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
window.config.pageData = window.config.pageData || {};
</script>
{{ScriptImport "js/iife.js"}}
{{ctx.ScriptImport "js/iife.js"}}

View File

@@ -8,7 +8,7 @@
{{svg "octicon-sidebar-collapse" 20 "icon tw-hidden"}}
{{svg "octicon-sidebar-expand" 20 "icon tw-hidden"}}
</button>
<script>
<script nonce="{{ctx.CspScriptNonce}}">
// Default to true if unset
const diffTreeVisible = window.localUserSettings.getBoolean('diff_file_tree_visible', true);
const diffTreeBtn = document.querySelector('.diff-toggle-file-tree-button');
@@ -62,7 +62,7 @@
{{if $showFileTree}}
{{$.FileIconPoolHTML}}
<div id="diff-file-tree" class="tw-hidden not-mobile"></div>
<script>
<script nonce="{{ctx.CspScriptNonce}}">
if (diffTreeVisible) document.getElementById('diff-file-tree').classList.remove('tw-hidden');
</script>
{{end}}

View File

@@ -220,7 +220,7 @@
{{$hasPendingPullRequestMergeTip = ctx.Locale.Tr "repo.pulls.auto_merge_has_pending_schedule" .PendingPullRequestMerge.Doer.Name $createdPRMergeStr}}
{{end}}
<div class="divider"></div>
<script type="module">
<script nonce="{{ctx.CspScriptNonce}}" type="module">
(() => {
const defaultMergeTitle = {{.DefaultMergeMessage}};
const defaultSquashMergeTitle = {{.DefaultSquashMergeMessage}};

View File

@@ -75,7 +75,7 @@
{{if .DisableAutosize}}data-disable-autosize="{{.DisableAutosize}}"{{end}}
>{{.TextareaContent}}</textarea>
</text-expander>
<script>
<script nonce="{{ctx.CspScriptNonce}}">
if (window.localUserSettings.getBoolean('markdown-editor-monospace')) {
document.querySelector('.markdown-text-editor').classList.add('tw-font-mono');
}

View File

@@ -8,6 +8,7 @@
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
<head>
{{ctx.HeadMetaContentSecurityPolicy}}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Internal Server Error - {{AppName}}</title>
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
@@ -52,7 +53,7 @@
{{/* When a sub-template triggers an 500 error, its parent template has been partially rendered, then the 500 page
will be rendered after that partially rendered page, the HTML/JS are totally broken. Use this inline script to try to move it to main viewport.
And this page shouldn't include any other JS file, avoid duplicate JS execution (still due to the partial rendering).*/}}
<script type="module">
<script nonce="{{ctx.CspScriptNonce}}" type="module">
const embedded = document.querySelector('.page-content .page-content.status-page-500');
if (embedded) {
// move the 500 error page content to main view

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{ctx.HeadMetaContentSecurityPolicy}}
<title>Gitea API</title>
{{/* HINT: SWAGGER-OPENAPI-VIEWER: another place is "modules/markup/external/openapi.go" */}}
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">
@@ -11,6 +12,6 @@
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.v1.json"></div>
<footer class="page-footer"></footer>
{{ScriptImport "js/swagger.js" "module"}}
{{ctx.ScriptImport "js/swagger.js" "module"}}
</body>
</html>

View File

@@ -10,12 +10,12 @@
<div class="inline field tw-text-center required">
<div id="captcha" data-captcha-type="g-recaptcha" class="g-recaptcha-style" data-sitekey="{{.RecaptchaSitekey}}"></div>
</div>
<script defer src='{{.RecaptchaAPIScriptURL}}'></script>
<script nonce="{{ctx.CspScriptNonce}}" defer src='{{.RecaptchaAPIScriptURL}}'></script>
{{else if eq .CaptchaType "hcaptcha"}}
<div class="inline field tw-text-center required">
<div id="captcha" data-captcha-type="h-captcha" class="h-captcha-style" data-sitekey="{{.HcaptchaSitekey}}"></div>
</div>
<script defer src='https://hcaptcha.com/1/api.js'></script>
<script nonce="{{ctx.CspScriptNonce}}" defer src='https://hcaptcha.com/1/api.js'></script>
{{else if eq .CaptchaType "mcaptcha"}}
<div class="inline field tw-text-center">
<div class="m-captcha-style" id="mcaptcha__widget-container"></div>
@@ -25,5 +25,5 @@
<div class="inline field tw-text-center">
<div id="captcha" data-captcha-type="cf-turnstile" data-sitekey="{{.CfTurnstileSitekey}}"></div>
</div>
<script defer src='https://challenges.cloudflare.com/turnstile/v0/api.js'></script>
<script nonce="{{ctx.CspScriptNonce}}" defer src='https://challenges.cloudflare.com/turnstile/v0/api.js'></script>
{{end}}{{end}}

View File

@@ -1,4 +1,4 @@
<script type="module">
<script nonce="{{ctx.CspScriptNonce}}" type="module">
const data = {
...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid

View File

@@ -108,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
// default sandbox in sub page response
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
assert.Equal(t, `<script crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`"><div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`, respSub.Body.String())
assert.Equal(t, `<script nonce crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`"><div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`, respSub.Body.String())
})
})
@@ -131,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer")
respSub := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, `<script crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`"><script>foo("raw")</script>`, respSub.Body.String())
assert.Equal(t, `<script nonce crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`"><script>foo("raw")</script>`, respSub.Body.String())
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
})
})

View File

@@ -69,6 +69,8 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) {
}
function executeScripts(elem: HTMLElement) {
// find any existing nonce value from the current page and apply it to the new script
const scriptNonce = document.querySelector('script[nonce]')!.getAttribute('nonce')!;
for (const oldScript of elem.querySelectorAll('script')) {
// TODO: that's the only way to load the data for the merge form. In the future
// we need to completely decouple the page data and embedded script
@@ -78,6 +80,7 @@ function executeScripts(elem: HTMLElement) {
if (attr.name === 'type' && attr.value === 'module') continue;
newScript.setAttribute(attr.name, attr.value);
}
newScript.setAttribute('nonce', scriptNonce);
newScript.text = oldScript.text;
document.body.append(newScript);
}