diff --git a/modules/markup/common/common.go b/modules/markup/common/common.go new file mode 100644 index 0000000000..58f0dc2946 --- /dev/null +++ b/modules/markup/common/common.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "regexp" + "slices" + "strings" + "sync" + + "gitea.dev/modules/container" + "gitea.dev/modules/log" + "gitea.dev/modules/util" + + "mvdan.cc/xurls/v2" +) + +type globalVarsType struct { + schemeRegexp *regexp.Regexp // extract the scheme part from a link + + wwwURLRegexp *regexp.Regexp // matching "www.{any-site}/{any-path}" pattern + LinkifyRegex *regexp.Regexp // fast matching a URL link (powered by "xurls" package with custom schemes), no any extra validation. + + allowedSchemes []string // nil means "allow all" (but disable the unsafe ones) + DisallowedSchemes []string +} + +const regexpScheme = `[a-zA-Z][-+.a-zA-Z0-9]*` + +var GlobalVars = sync.OnceValue(func() *globalVarsType { + v := &globalVarsType{} + v.schemeRegexp = regexp.MustCompile(`^` + regexpScheme + `:`) + v.wwwURLRegexp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) + v.LinkifyRegex, _ = xurls.StrictMatchingScheme("https?://") + v.allowedSchemes = []string{"http", "https"} + v.DisallowedSchemes = []string{"data", "javascript", "vbscript"} + return v +}) + +type CheckLinkURLSchemeResult struct { + HasScheme, AllowToLinkify bool +} + +func CheckLinkURLScheme(link string) CheckLinkURLSchemeResult { + vars := GlobalVars() + m := vars.schemeRegexp.FindStringSubmatch(link) + if m == nil { + return CheckLinkURLSchemeResult{AllowToLinkify: true} // relative link is always valid + } + urlScheme := strings.ToLower(m[0]) + urlScheme = urlScheme[0 : len(urlScheme)-1] // remove the trailing ":" + allowed := len(vars.allowedSchemes) == 0 || slices.Contains(vars.allowedSchemes, urlScheme) + disabled := slices.Contains(vars.DisallowedSchemes, urlScheme) + return CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: allowed && !disabled} +} + +func InitLinkURLSchemes(customSchemes []string) { + validScheme := regexp.MustCompile(`^` + regexpScheme + `$`) + schemes := container.Set[string]{} + for _, scheme := range customSchemes { + schemeLower := strings.ToLower(scheme) + if !validScheme.MatchString(schemeLower) { + log.Error("Invalid custom URL scheme %q", scheme) + continue + } + schemes.Add(schemeLower) + } + + // HINT: CUSTOM-URL-SCHEMES-ALLOW: setting custom means also allow them besides http/https, no custom means "allow all" + if len(schemes) > 0 { + schemes.AddMultiple("http", "https") + linkifyRegexps := make([]string, 0, len(schemes)) + for _, s := range schemes.Values() { + s += util.Iif(slices.Contains(xurls.SchemesNoAuthority, s), ":", "://") + linkifyRegexps = append(linkifyRegexps, regexp.QuoteMeta(s)) + } + GlobalVars().LinkifyRegex, _ = xurls.StrictMatchingScheme(strings.Join(linkifyRegexps, "|")) + GlobalVars().allowedSchemes = schemes.Values() + } else { + GlobalVars().LinkifyRegex, _ = xurls.StrictMatchingScheme("https?://") // only auto-linkify http and https + GlobalVars().allowedSchemes = nil + } +} diff --git a/modules/markup/common/common_test.go b/modules/markup/common/common_test.go new file mode 100644 index 0000000000..c9494fa3e3 --- /dev/null +++ b/modules/markup/common/common_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestURLSchemes(t *testing.T) { + t.Run("NoCustomSchemes", func(t *testing.T) { + InitLinkURLSchemes(nil) + assert.True(t, GlobalVars().LinkifyRegex.MatchString("http://example.com")) + assert.True(t, GlobalVars().LinkifyRegex.MatchString("https://example.com")) + assert.False(t, GlobalVars().LinkifyRegex.MatchString("some-other://example.com")) + + assert.Equal(t, CheckLinkURLSchemeResult{AllowToLinkify: true}, CheckLinkURLScheme("foo/:")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("HTTP://example.com")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("https://example.com")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("some-other:foo")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("any-other:bar")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: false}, CheckLinkURLScheme("javascript:void")) + }) + + t.Run("WithCustomSchemes", func(t *testing.T) { + InitLinkURLSchemes([]string{"Some-Other"}) + assert.True(t, GlobalVars().LinkifyRegex.MatchString("http://example.com")) + assert.True(t, GlobalVars().LinkifyRegex.MatchString("https://example.com")) + assert.True(t, GlobalVars().LinkifyRegex.MatchString("some-other://example.com")) + + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("http://example.com")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("HTTPS://example.com")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: true}, CheckLinkURLScheme("some-other:foo")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: false}, CheckLinkURLScheme("any-other:bar")) + assert.Equal(t, CheckLinkURLSchemeResult{HasScheme: true, AllowToLinkify: false}, CheckLinkURLScheme("JavaScript:void")) + }) +} diff --git a/modules/markup/common/linkify.go b/modules/markup/common/linkify.go index 54e40a02d6..33dccb7903 100644 --- a/modules/markup/common/linkify.go +++ b/modules/markup/common/linkify.go @@ -8,29 +8,14 @@ package common import ( "bytes" - "regexp" - "sync" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" - "mvdan.cc/xurls/v2" ) -type GlobalVarsType struct { - wwwURLRegexp *regexp.Regexp - LinkRegex *regexp.Regexp // fast matching a URL link, no any extra validation. -} - -var GlobalVars = sync.OnceValue(func() *GlobalVarsType { - v := &GlobalVarsType{} - v.wwwURLRegexp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) - v.LinkRegex, _ = xurls.StrictMatchingScheme("https?://") - return v -}) - type linkifyParser struct{} var defaultLinkifyParser = &linkifyParser{} @@ -72,7 +57,7 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont var protocol []byte typ := ast.AutoLinkURL if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) { - m = GlobalVars().LinkRegex.FindSubmatchIndex(line) + m = GlobalVars().LinkifyRegex.FindSubmatchIndex(line) } if m == nil && bytes.HasPrefix(line, domainWWW) { m = GlobalVars().wwwURLRegexp.FindSubmatchIndex(line) diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 3f7c26c575..2a5d652a75 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -21,19 +21,12 @@ type frontendRenderer struct { patterns []string } -var ( - _ markup.PostProcessRenderer = (*frontendRenderer)(nil) - _ markup.ExternalRenderer = (*frontendRenderer)(nil) -) +var _ markup.ExternalRenderer = (*frontendRenderer)(nil) func (p *frontendRenderer) Name() string { return p.name } -func (p *frontendRenderer) NeedPostProcess() bool { - return false -} - func (p *frontendRenderer) FileNamePatterns() []string { // TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model // There are some approaches to make it more accurate, but they are all complicated: diff --git a/modules/markup/html.go b/modules/markup/html.go index 8dafb742d7..4687244668 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -9,7 +9,6 @@ import ( "html/template" "io" "regexp" - "slices" "strings" "sync" @@ -21,7 +20,6 @@ import ( "golang.org/x/net/html" "golang.org/x/net/html/atom" - "mvdan.cc/xurls/v2" ) // Issue name styles @@ -36,7 +34,6 @@ type globalVarsType struct { shortLinkPattern *regexp.Regexp anyHashPattern *regexp.Regexp comparePattern *regexp.Regexp - fullURLPattern *regexp.Regexp emailRegex *regexp.Regexp emojiShortCodeRegex *regexp.Regexp issueFullPattern *regexp.Regexp @@ -70,9 +67,6 @@ var globalVars = sync.OnceValue(func() *globalVarsType { // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) - // fullURLPattern matches full URL like "mailto:...", "https://..." and "ssh+git://..." - v.fullURLPattern = regexp.MustCompile(`^[a-z][-+\w]+:`) - // emailRegex is definitely not perfect with edge cases, // it is still accepted by the CommonMark specification, as well as the HTML5 spec: // http://spec.commonmark.org/0.28/#email-address @@ -98,34 +92,6 @@ var globalVars = sync.OnceValue(func() *globalVarsType { return v }) -func IsFullURLString(link string) bool { - return globalVars().fullURLPattern.MatchString(link) -} - -func IsNonEmptyRelativePath(link string) bool { - return link != "" && !IsFullURLString(link) && link[0] != '?' && link[0] != '#' -} - -// CustomLinkURLSchemes allows for additional schemes to be detected when parsing links within text -func CustomLinkURLSchemes(schemes []string) { - schemes = append(schemes, "http", "https") - withAuth := make([]string, 0, len(schemes)) - validScheme := regexp.MustCompile(`^[a-z]+$`) - for _, s := range schemes { - if !validScheme.MatchString(s) { - continue - } - without := slices.Contains(xurls.SchemesNoAuthority, s) - if without { - s += ":" - } else { - s += "://" - } - withAuth = append(withAuth, s) - } - common.GlobalVars().LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) -} - type processor func(ctx *RenderContext, node *html.Node) // PostProcessDefault does the final required transformations to the passed raw HTML @@ -175,21 +141,10 @@ var emojiProcessors = []processor{ emojiProcessor, } -// isBareURLSubject reports whether the (HTML-escaped) commit subject content -// is entirely a single URL, ignoring leading/trailing whitespace. -func isBareURLSubject(content string) bool { - s := strings.TrimSpace(html.UnescapeString(content)) - if s == "" { - return false - } - m := common.GlobalVars().LinkRegex.FindStringIndex(s) - return m != nil && m[0] == 0 && m[1] == len(s) -} - // PostProcessCommitMessageSubject will use the same logic as PostProcess and // PostProcessCommitMessage, but will disable the shortLinkProcessor and // emailAddressProcessor, and wraps the whole subject in defaultLink. -func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, content template.HTML) template.HTML { +func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) template.HTML { procs := []processor{ fullIssuePatternProcessor, comparePatternProcessor, @@ -200,16 +155,19 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, con hashCurrentPatternProcessor, emojiShortCodeProcessor, emojiProcessor, + linkProcessor, } - // When the whole subject is a bare URL, linkProcessor would turn it into - // a competing anchor and hijack the surrounding defaultLink wrapper, leaving - // the subject visually unclickable. Match GitHub: render such subjects as - // plain text inside defaultLink. Partial URLs inside larger text still become - // their own links (nested anchors aren't legal HTML, so the outer defaultLink - // naturally breaks on that span, same as on GitHub). - if !isBareURLSubject(string(content)) { - procs = append(procs, linkProcessor) + + content = strings.TrimSpace(content) + m := common.GlobalVars().LinkifyRegex.FindStringSubmatch(content) + contentIsFullLink := m != nil && m[0] == content + // Only call post-processers when the content is not a full link + // If the content is a full link, just render it as its text and add our real link to wrap it + // Otherwise: if the content full link gets its "A" element by "linkProcessor", the outer link (our real link) won't work + if contentIsFullLink { + procs = nil } + procs = append(procs, func(ctx *RenderContext, node *html.Node) { ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data} node.Type = html.ElementNode @@ -218,7 +176,7 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, con node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}} node.FirstChild, node.LastChild = ch, ch }) - rendered := postProcessHTML(ctx, procs, content) + rendered := postProcessHTML(ctx, procs, htmlutil.EscapeString(content)) return htmlutil.HTMLFormat(`%s`, rendered) } diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index 1e0e12da94..731c0c7d36 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -33,10 +33,11 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { // Of text and link contents sl := strings.SplitSeq(content, "|") for v := range sl { - if found := strings.Contains(v, "="); !found { + before, after, hasKeyValue := strings.Cut(v, "=") + if !hasKeyValue { // There is no equal in this argument; this is a mandatory arg if props["name"] == "" { - if IsFullURLString(v) { + if checkLink := common.CheckLinkURLScheme(v); checkLink.HasScheme { // If we clearly see it is a link, we save it so // But first we need to ensure, that if both mandatory args provided @@ -53,9 +54,6 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { props["link"] = strings.TrimSpace(v) } } else { - // There is an equal; optional argument. - - before, after, _ := strings.Cut(v, "=") key, val := before, html.UnescapeString(after) // When parsing HTML, x/net/html will change all quotes which are @@ -103,6 +101,11 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { image = true } + checkLink := common.CheckLinkURLScheme(link) + if !checkLink.AllowToLinkify { + return + } + childNode := &html.Node{} linkNode := &html.Node{ FirstChild: childNode, @@ -112,10 +115,9 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { DataAtom: atom.A, } childNode.Parent = linkNode - absoluteLink := IsFullURLString(link) // FIXME: it should be fully refactored in the future, it uses various hacky approaches to guess how to encode a path for wiki // When a link contains "/", then we assume that the user has provided a well-encoded link. - if !absoluteLink && !strings.Contains(link, "/") { + if !checkLink.HasScheme && !strings.Contains(link, "/") { // So only guess for links without "/". if image { link = strings.ReplaceAll(link, " ", "+") @@ -165,7 +167,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { func linkProcessor(ctx *RenderContext, node *html.Node) { next := node.NextSibling for node != nil && node != next { - m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data) + m := common.GlobalVars().LinkifyRegex.FindStringIndex(node.Data) if m == nil { return } @@ -184,7 +186,7 @@ func linkProcessor(ctx *RenderContext, node *html.Node) { func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) { next := node.NextSibling for node != nil && node != next { - m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data) + m := common.GlobalVars().LinkifyRegex.FindStringIndex(node.Data) if m == nil { return } diff --git a/modules/markup/html_link_test.go b/modules/markup/html_link_test.go new file mode 100644 index 0000000000..6742a0f6da --- /dev/null +++ b/modules/markup/html_link_test.go @@ -0,0 +1,24 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "strings" + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestShortLinkProcessor(t *testing.T) { + test := func(input, expected string) { + sb := new(strings.Builder) + err := postProcess(NewTestRenderContext("/base"), []processor{shortLinkProcessor}, strings.NewReader(input), sb) + assert.NoError(t, err) + assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(sb.String())) + } + test("[[name=foo|link=./link]]", `foo`) + test("[[name=foo|link=javascript:bar]]", `[[name=foo|link=javascript:bar]]`) +} diff --git a/modules/markup/html_node.go b/modules/markup/html_node.go index 33d402231c..0447b6d8a3 100644 --- a/modules/markup/html_node.go +++ b/modules/markup/html_node.go @@ -179,9 +179,7 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) { if attr.Key != "src" { continue } - if IsNonEmptyRelativePath(attr.Val) { - attr.Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeMedia) - } + attr.Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeMedia) attr.Val = camoHandleLink(attr.Val) node.Attr[i] = attr } diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index f3aec68b6a..0c9a32ce32 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -10,6 +10,7 @@ import ( "gitea.dev/modules/emoji" "gitea.dev/modules/markup" + "gitea.dev/modules/markup/common" "gitea.dev/modules/markup/markdown" "gitea.dev/modules/setting" testModule "gitea.dev/modules/test" @@ -135,10 +136,10 @@ func TestRender_links(t *testing.T) { defer func() { setting.Markdown.CustomURLSchemes = oldCustomURLSchemes markup.ResetDefaultSanitizerForTesting() - markup.CustomLinkURLSchemes(oldCustomURLSchemes) + common.InitLinkURLSchemes(oldCustomURLSchemes) }() setting.Markdown.CustomURLSchemes = []string{"ftp", "magnet"} - markup.CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes) + common.InitLinkURLSchemes(setting.Markdown.CustomURLSchemes) // Text that should be turned into URL test( @@ -402,7 +403,6 @@ func TestRender_ShortLinks(t *testing.T) { renderableFileURL := tree + "/markdown_file.md" unrenderableFileURL := tree + "/file.zip" favicon := "http://google.com/favicon.ico" - test( "[[Link]]", `

Link

`, @@ -597,10 +597,3 @@ func TestIssue18471(t *testing.T) { assert.NoError(t, err) assert.Equal(t, `783b039...da951ce`, res.String()) } - -func TestIsFullURL(t *testing.T) { - assert.True(t, markup.IsFullURLString("https://example.com")) - assert.True(t, markup.IsFullURLString("mailto:test@example.com")) - assert.True(t, markup.IsFullURLString("data:image/11111")) - assert.False(t, markup.IsFullURLString("/foo:bar")) -} diff --git a/modules/markup/jupyter/jupyter.go b/modules/markup/jupyter/jupyter.go index 40109f7358..a8b56250f5 100644 --- a/modules/markup/jupyter/jupyter.go +++ b/modules/markup/jupyter/jupyter.go @@ -29,9 +29,8 @@ func init() { type renderer struct{} var ( - _ markup.Renderer = (*renderer)(nil) - _ markup.PostProcessRenderer = (*renderer)(nil) - _ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future + _ markup.Renderer = (*renderer)(nil) + _ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future ) type mimeHandler struct { @@ -96,8 +95,6 @@ func (renderer) Name() string { return "jupyter-render" } -func (renderer) NeedPostProcess() bool { return true } - func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions { return markup.ExternalRendererOptions{ // HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes. diff --git a/modules/markup/jupyter/jupyter_test.go b/modules/markup/jupyter/jupyter_test.go index 673cc4309f..69b69a54e9 100644 --- a/modules/markup/jupyter/jupyter_test.go +++ b/modules/markup/jupyter/jupyter_test.go @@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) { "execution_count": 1, "data": { "text/html": [ - "
Safe Content
" + "
[[name=no-post-process|link=/link]]
" ] }, "metadata": {} @@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
Out [1]:
-
Safe Content
+
[[name=no-post-process|link=/link]]
diff --git a/modules/markup/main_test.go b/modules/markup/main_test.go index f8a77ad86e..8924e43de3 100644 --- a/modules/markup/main_test.go +++ b/modules/markup/main_test.go @@ -14,7 +14,6 @@ import ( func TestMain(m *testing.M) { setting.IsInTesting = true markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true - setting.Markdown.FileNamePatterns = []string{"*.md"} markup.RefreshFileNamePatterns() os.Exit(m.Run()) } diff --git a/modules/markup/render.go b/modules/markup/render.go index af6f2c70c3..ecf6f63b67 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -16,6 +16,7 @@ import ( "time" "gitea.dev/modules/htmlutil" + "gitea.dev/modules/markup/common" "gitea.dev/modules/markup/internal" "gitea.dev/modules/public" "gitea.dev/modules/setting" @@ -303,9 +304,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, // Init initializes the render global variables func Init(renderHelpFuncs *RenderHelperFuncs) { DefaultRenderHelperFuncs = renderHelpFuncs - if len(setting.Markdown.CustomURLSchemes) > 0 { - CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes) - } + common.InitLinkURLSchemes(setting.Markdown.CustomURLSchemes) // since setting maybe changed extensions, this will reload all renderer extensions mapping fileNameRenderers = make(map[string]Renderer) diff --git a/modules/markup/sanitizer_default.go b/modules/markup/sanitizer_default.go index 25a7e1532d..de47ef6d4e 100644 --- a/modules/markup/sanitizer_default.go +++ b/modules/markup/sanitizer_default.go @@ -9,6 +9,7 @@ import ( "net/url" "regexp" + "gitea.dev/modules/markup/common" "gitea.dev/modules/setting" "github.com/microcosm-cc/bluemonday" @@ -33,19 +34,18 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy { // Line numbers on codepreview policy.AllowAttrs("data-line-number").OnElements("span") - // Custom URL-Schemes + // HINT: CUSTOM-URL-SCHEMES-ALLOW: setting custom means also allow them besides http/https, no custom means "allow all" if len(setting.Markdown.CustomURLSchemes) > 0 { policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...) } else { policy.AllowURLSchemesMatching(st.allowAllRegex) - // Even if every scheme is allowed, these three are blocked for security reasons disallowScheme := func(*url.URL) bool { return false } - policy.AllowURLSchemeWithCustomPolicy("javascript", disallowScheme) - policy.AllowURLSchemeWithCustomPolicy("vbscript", disallowScheme) - policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme) + for _, scheme := range common.GlobalVars().DisallowedSchemes { + policy.AllowURLSchemeWithCustomPolicy(scheme, disallowScheme) + } } // Allow classes for org mode list item status. @@ -135,8 +135,8 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy { } // Sanitize use default sanitizer policy to sanitize a string -func Sanitize(s string) template.HTML { - return template.HTML(GetDefaultSanitizer().defaultPolicy.Sanitize(s)) +func Sanitize[T string | template.HTML](s T) template.HTML { + return template.HTML(GetDefaultSanitizer().defaultPolicy.Sanitize(string(s))) } // SanitizeReader sanitizes a Reader diff --git a/modules/setting/markup.go b/modules/setting/markup.go index 39c59025de..fa2d9367fd 100644 --- a/modules/setting/markup.go +++ b/modules/setting/markup.go @@ -50,7 +50,8 @@ var Markdown = struct { MathCodeBlockDetection []string MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"` }{ - EnableMath: true, + EnableMath: true, + FileNamePatterns: []string{"*.md"}, } // MarkupRenderer defines the external parser configured in ini diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 7f62eb9b0b..e47dfb4e81 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -62,7 +62,7 @@ func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, re msgLine, _, _ = strings.Cut(msgLine, "\n") msgLine = strings.TrimSpace(msgLine) rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo) - rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, htmlutil.EscapeString(msgLine)) + rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, msgLine) return renderCodeBlock(rendered) } diff --git a/routers/api/v1/misc/markup.go b/routers/api/v1/misc/markup.go index cb37941d7f..deef088a4d 100644 --- a/routers/api/v1/misc/markup.go +++ b/routers/api/v1/misc/markup.go @@ -4,8 +4,9 @@ package misc import ( - "gitea.dev/modules/markup" - "gitea.dev/modules/markup/markdown" + "io" + + "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "gitea.dev/modules/util" "gitea.dev/modules/web" @@ -84,9 +85,6 @@ func MarkdownRaw(ctx *context.APIContext) { // "$ref": "#/responses/MarkdownRender" // "422": // "$ref": "#/responses/validationError" - defer ctx.Req.Body.Close() - if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil { - ctx.APIErrorInternal(err) - return - } + textBytes, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.UI.MaxDisplayFileSize)) + common.RenderMarkup(ctx.Base, ctx.Repo, "markdown", util.UnsafeBytesToString(textBytes), "", "") } diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index bf02d0a959..d8b83ea330 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -7,19 +7,15 @@ import ( go_context "context" "io" "net/http" - "os" "path" "strings" "testing" - repo_model "gitea.dev/models/repo" - "gitea.dev/models/unittest" "gitea.dev/modules/markup" "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "gitea.dev/modules/test" "gitea.dev/modules/web" - context_service "gitea.dev/services/context" "gitea.dev/services/contexttest" "github.com/stretchr/testify/assert" @@ -27,13 +23,6 @@ import ( const AppURL = "http://localhost:3000/" -func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - FixtureFiles: []string{"repository.yml", "user.yml"}, - }) - os.Exit(m.Run()) -} - func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) { setting.AppURL = AppURL defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() @@ -49,13 +38,11 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe FilePath: filePath, } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") - ctx.Repo = &context_service.Repository{} - ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) web.SetForm(ctx, &options) Markup(ctx) assert.Equal(t, expectedBody, resp.Body.String()) assert.Equal(t, expectedCode, resp.Code) - resp.Body.Reset() + assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-") } func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) { @@ -76,11 +63,10 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody Markdown(ctx) assert.Equal(t, responseBody, resp.Body.String()) assert.Equal(t, responseCode, resp.Code) - resp.Body.Reset() + assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-") } func TestAPI_RenderGFM(t *testing.T) { - unittest.PrepareTestEnv(t) markup.Init(&markup.RenderHelperFuncs{ IsUsernameMentionable: func(ctx go_context.Context, username string) bool { return username == "r-lyeh" diff --git a/routers/common/errpage.go b/routers/common/errpage.go index 1426b05ef9..6152a0c05c 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -40,6 +40,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in if acceptsHTML { err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx) if err != nil { + log.Error("Failed to render error page template %s: %v", tmpl, err) _, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker")) return } diff --git a/routers/common/markup.go b/routers/common/markup.go index 3b626c7031..a379deda3e 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -31,6 +31,8 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur // for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md" // and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc" + ctx.SetHeaderContentSecurityPolicyGeneral() + if mode == "" || mode == "markdown" { // raw Markdown doesn't do any special handling // TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 748c08f53b..011dfd8d49 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -64,6 +64,9 @@ func RenderFile(ctx *context.Context) { extRendererOpts := extRenderer.GetExternalRendererOptions() if extRendererOpts.ContentSandbox != "" { ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+extRendererOpts.ContentSandbox) + } else { + // if no sandbox, just apply the same CSP as a general Gitea web page + ctx.SetHeaderContentSecurityPolicyGeneral() } err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp) diff --git a/services/context/base.go b/services/context/base.go index 726cc3b496..7fc1100b0c 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -188,6 +188,26 @@ func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML { return b.Locale.TrN(cnt, key1, keyN, args...) } +func CspScriptNonce(ctx reqctx.RequestContext) (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 "`, respSub.Body.String(), ) - assert.Empty(t, respSub.Header().Get("Content-Security-Policy")) + assert.NotContains(t, respSub.Header().Get("Content-Security-Policy"), "sandbox") + assert.Contains(t, respSub.Header().Get("Content-Security-Policy"), "nonce-") }) }) })