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": [
- ""
+ "| [[name=no-post-process|link=/link]] |
"
]
},
"metadata": {}
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
Out [1]:
-
+
| [[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-")
})
})
})