Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/markup/external/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 1 addition & 31 deletions modules/templates/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ package templates

import (
"fmt"
"html"
"html/template"
"net/url"
"strconv"
"strings"
"sync"
"time"

"code.gitea.io/gitea/modules/base"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
36 changes: 34 additions & 2 deletions modules/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Comment thread
wxiaoguang marked this conversation as resolved.
}
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.
Expand Down
2 changes: 0 additions & 2 deletions services/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions services/context/context_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';` +
Comment thread
wxiaoguang marked this conversation as resolved.
`">`)
}
2 changes: 1 addition & 1 deletion templates/base/footer.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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>
1 change: 1 addition & 0 deletions templates/base/head.tmpl
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
<head>
{{ctx.HeadMetaContentSecurityPolicy}}
Comment thread
wxiaoguang marked this conversation as resolved.
<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}}
Expand Down
4 changes: 2 additions & 2 deletions templates/base/head_script.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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);});
Expand Down Expand Up @@ -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"}}
4 changes: 2 additions & 2 deletions templates/repo/diff/box.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}}
Expand Down
2 changes: 1 addition & 1 deletion templates/repo/issue/view_content/pull_merge_box.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}};
Expand Down
2 changes: 1 addition & 1 deletion templates/shared/combomarkdowneditor.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
3 changes: 2 additions & 1 deletion templates/status/500.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion templates/swagger/openapi-viewer.tmpl
Original file line number Diff line number Diff line change
@@ -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}}">
Expand All @@ -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>
6 changes: 3 additions & 3 deletions templates/user/auth/captcha.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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}}
2 changes: 1 addition & 1 deletion templates/user/dashboard/repolist.tmpl
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 2 additions & 2 deletions tests/integration/markup_external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
})

Expand All @@ -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"))
})
})
Expand Down
Loading