mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-18 13:30:55 +00:00
2
modules/markup/external/openapi.go
vendored
2
modules/markup/external/openapi.go
vendored
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';` +
|
||||
`">`)
|
||||
}
|
||||
|
||||
@@ -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,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}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"><script></script></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"><script></script></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"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user