mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 14:03:24 +09:00
refactor: markup render (#38864)
1. add missing CSP header to api & web render endpoints. 2. make jupyter render skip post-processors, nothing to process 3. make ShortLinkProcessor correctly validate URL schemes and respect the CustomURLSchemes setting
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,29 +8,14 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/ast"
|
"github.com/yuin/goldmark/ast"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
"github.com/yuin/goldmark/text"
|
"github.com/yuin/goldmark/text"
|
||||||
"github.com/yuin/goldmark/util"
|
"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{}
|
type linkifyParser struct{}
|
||||||
|
|
||||||
var defaultLinkifyParser = &linkifyParser{}
|
var defaultLinkifyParser = &linkifyParser{}
|
||||||
@@ -72,7 +57,7 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont
|
|||||||
var protocol []byte
|
var protocol []byte
|
||||||
typ := ast.AutoLinkURL
|
typ := ast.AutoLinkURL
|
||||||
if bytes.HasPrefix(line, protoHTTP) || bytes.HasPrefix(line, protoHTTPS) || bytes.HasPrefix(line, protoFTP) {
|
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) {
|
if m == nil && bytes.HasPrefix(line, domainWWW) {
|
||||||
m = GlobalVars().wwwURLRegexp.FindSubmatchIndex(line)
|
m = GlobalVars().wwwURLRegexp.FindSubmatchIndex(line)
|
||||||
|
|||||||
Vendored
+1
-8
@@ -21,19 +21,12 @@ type frontendRenderer struct {
|
|||||||
patterns []string
|
patterns []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
|
||||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *frontendRenderer) Name() string {
|
func (p *frontendRenderer) Name() string {
|
||||||
return p.name
|
return p.name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *frontendRenderer) FileNamePatterns() []string {
|
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
|
// 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:
|
// There are some approaches to make it more accurate, but they are all complicated:
|
||||||
|
|||||||
+13
-55
@@ -9,7 +9,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
"mvdan.cc/xurls/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Issue name styles
|
// Issue name styles
|
||||||
@@ -36,7 +34,6 @@ type globalVarsType struct {
|
|||||||
shortLinkPattern *regexp.Regexp
|
shortLinkPattern *regexp.Regexp
|
||||||
anyHashPattern *regexp.Regexp
|
anyHashPattern *regexp.Regexp
|
||||||
comparePattern *regexp.Regexp
|
comparePattern *regexp.Regexp
|
||||||
fullURLPattern *regexp.Regexp
|
|
||||||
emailRegex *regexp.Regexp
|
emailRegex *regexp.Regexp
|
||||||
emojiShortCodeRegex *regexp.Regexp
|
emojiShortCodeRegex *regexp.Regexp
|
||||||
issueFullPattern *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"
|
// 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]+)?`)
|
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,
|
// emailRegex is definitely not perfect with edge cases,
|
||||||
// it is still accepted by the CommonMark specification, as well as the HTML5 spec:
|
// it is still accepted by the CommonMark specification, as well as the HTML5 spec:
|
||||||
// http://spec.commonmark.org/0.28/#email-address
|
// http://spec.commonmark.org/0.28/#email-address
|
||||||
@@ -98,34 +92,6 @@ var globalVars = sync.OnceValue(func() *globalVarsType {
|
|||||||
return v
|
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)
|
type processor func(ctx *RenderContext, node *html.Node)
|
||||||
|
|
||||||
// PostProcessDefault does the final required transformations to the passed raw HTML
|
// PostProcessDefault does the final required transformations to the passed raw HTML
|
||||||
@@ -175,21 +141,10 @@ var emojiProcessors = []processor{
|
|||||||
emojiProcessor,
|
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
|
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||||
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
// 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{
|
procs := []processor{
|
||||||
fullIssuePatternProcessor,
|
fullIssuePatternProcessor,
|
||||||
comparePatternProcessor,
|
comparePatternProcessor,
|
||||||
@@ -200,16 +155,19 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, con
|
|||||||
hashCurrentPatternProcessor,
|
hashCurrentPatternProcessor,
|
||||||
emojiShortCodeProcessor,
|
emojiShortCodeProcessor,
|
||||||
emojiProcessor,
|
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
|
content = strings.TrimSpace(content)
|
||||||
// the subject visually unclickable. Match GitHub: render such subjects as
|
m := common.GlobalVars().LinkifyRegex.FindStringSubmatch(content)
|
||||||
// plain text inside defaultLink. Partial URLs inside larger text still become
|
contentIsFullLink := m != nil && m[0] == content
|
||||||
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
// Only call post-processers when the content is not a full link
|
||||||
// naturally breaks on that span, same as on GitHub).
|
// If the content is a full link, just render it as its text and add our real link to wrap it
|
||||||
if !isBareURLSubject(string(content)) {
|
// Otherwise: if the content full link gets its "A" element by "linkProcessor", the outer link (our real link) won't work
|
||||||
procs = append(procs, linkProcessor)
|
if contentIsFullLink {
|
||||||
|
procs = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||||
node.Type = html.ElementNode
|
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.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
|
||||||
node.FirstChild, node.LastChild = ch, ch
|
node.FirstChild, node.LastChild = ch, ch
|
||||||
})
|
})
|
||||||
rendered := postProcessHTML(ctx, procs, content)
|
rendered := postProcessHTML(ctx, procs, htmlutil.EscapeString(content))
|
||||||
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
|
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
|
|||||||
// Of text and link contents
|
// Of text and link contents
|
||||||
sl := strings.SplitSeq(content, "|")
|
sl := strings.SplitSeq(content, "|")
|
||||||
for v := range sl {
|
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
|
// There is no equal in this argument; this is a mandatory arg
|
||||||
if props["name"] == "" {
|
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
|
// If we clearly see it is a link, we save it so
|
||||||
|
|
||||||
// But first we need to ensure, that if both mandatory args provided
|
// 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)
|
props["link"] = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// There is an equal; optional argument.
|
|
||||||
|
|
||||||
before, after, _ := strings.Cut(v, "=")
|
|
||||||
key, val := before, html.UnescapeString(after)
|
key, val := before, html.UnescapeString(after)
|
||||||
|
|
||||||
// When parsing HTML, x/net/html will change all quotes which are
|
// 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
|
image = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkLink := common.CheckLinkURLScheme(link)
|
||||||
|
if !checkLink.AllowToLinkify {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
childNode := &html.Node{}
|
childNode := &html.Node{}
|
||||||
linkNode := &html.Node{
|
linkNode := &html.Node{
|
||||||
FirstChild: childNode,
|
FirstChild: childNode,
|
||||||
@@ -112,10 +115,9 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
|
|||||||
DataAtom: atom.A,
|
DataAtom: atom.A,
|
||||||
}
|
}
|
||||||
childNode.Parent = linkNode
|
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
|
// 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.
|
// 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 "/".
|
// So only guess for links without "/".
|
||||||
if image {
|
if image {
|
||||||
link = strings.ReplaceAll(link, " ", "+")
|
link = strings.ReplaceAll(link, " ", "+")
|
||||||
@@ -165,7 +167,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
|
|||||||
func linkProcessor(ctx *RenderContext, node *html.Node) {
|
func linkProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
next := node.NextSibling
|
next := node.NextSibling
|
||||||
for node != nil && node != next {
|
for node != nil && node != next {
|
||||||
m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data)
|
m := common.GlobalVars().LinkifyRegex.FindStringIndex(node.Data)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,7 +186,7 @@ func linkProcessor(ctx *RenderContext, node *html.Node) {
|
|||||||
func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
|
func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
next := node.NextSibling
|
next := node.NextSibling
|
||||||
for node != nil && node != next {
|
for node != nil && node != next {
|
||||||
m := common.GlobalVars().LinkRegex.FindStringIndex(node.Data)
|
m := common.GlobalVars().LinkifyRegex.FindStringIndex(node.Data)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]]", `<a href="/base/link">foo</a>`)
|
||||||
|
test("[[name=foo|link=javascript:bar]]", `[[name=foo|link=javascript:bar]]`)
|
||||||
|
}
|
||||||
@@ -179,9 +179,7 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
|
|||||||
if attr.Key != "src" {
|
if attr.Key != "src" {
|
||||||
continue
|
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)
|
attr.Val = camoHandleLink(attr.Val)
|
||||||
node.Attr[i] = attr
|
node.Attr[i] = attr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/modules/emoji"
|
"gitea.dev/modules/emoji"
|
||||||
"gitea.dev/modules/markup"
|
"gitea.dev/modules/markup"
|
||||||
|
"gitea.dev/modules/markup/common"
|
||||||
"gitea.dev/modules/markup/markdown"
|
"gitea.dev/modules/markup/markdown"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
testModule "gitea.dev/modules/test"
|
testModule "gitea.dev/modules/test"
|
||||||
@@ -135,10 +136,10 @@ func TestRender_links(t *testing.T) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
setting.Markdown.CustomURLSchemes = oldCustomURLSchemes
|
setting.Markdown.CustomURLSchemes = oldCustomURLSchemes
|
||||||
markup.ResetDefaultSanitizerForTesting()
|
markup.ResetDefaultSanitizerForTesting()
|
||||||
markup.CustomLinkURLSchemes(oldCustomURLSchemes)
|
common.InitLinkURLSchemes(oldCustomURLSchemes)
|
||||||
}()
|
}()
|
||||||
setting.Markdown.CustomURLSchemes = []string{"ftp", "magnet"}
|
setting.Markdown.CustomURLSchemes = []string{"ftp", "magnet"}
|
||||||
markup.CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
common.InitLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
||||||
|
|
||||||
// Text that should be turned into URL
|
// Text that should be turned into URL
|
||||||
test(
|
test(
|
||||||
@@ -402,7 +403,6 @@ func TestRender_ShortLinks(t *testing.T) {
|
|||||||
renderableFileURL := tree + "/markdown_file.md"
|
renderableFileURL := tree + "/markdown_file.md"
|
||||||
unrenderableFileURL := tree + "/file.zip"
|
unrenderableFileURL := tree + "/file.zip"
|
||||||
favicon := "http://google.com/favicon.ico"
|
favicon := "http://google.com/favicon.ico"
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"[[Link]]",
|
"[[Link]]",
|
||||||
`<p><a href="`+url+`" rel="nofollow">Link</a></p>`,
|
`<p><a href="`+url+`" rel="nofollow">Link</a></p>`,
|
||||||
@@ -597,10 +597,3 @@ func TestIssue18471(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, `<a href="`+markup.TestAppURL+`org/repo/compare/783b039...da951ce" class="compare"><code>783b039...da951ce</code></a>`, res.String())
|
assert.Equal(t, `<a href="`+markup.TestAppURL+`org/repo/compare/783b039...da951ce" class="compare"><code>783b039...da951ce</code></a>`, 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"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ func init() {
|
|||||||
type renderer struct{}
|
type renderer struct{}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ markup.Renderer = (*renderer)(nil)
|
_ 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.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type mimeHandler struct {
|
type mimeHandler struct {
|
||||||
@@ -96,8 +95,6 @@ func (renderer) Name() string {
|
|||||||
return "jupyter-render"
|
return "jupyter-render"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (renderer) NeedPostProcess() bool { return true }
|
|
||||||
|
|
||||||
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
||||||
return 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.
|
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
|||||||
"execution_count": 1,
|
"execution_count": 1,
|
||||||
"data": {
|
"data": {
|
||||||
"text/html": [
|
"text/html": [
|
||||||
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
|
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"metadata": {}
|
"metadata": {}
|
||||||
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
|||||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||||
<div class="cell-right cell-output">
|
<div class="cell-right cell-output">
|
||||||
<div class="cell-output-html">
|
<div class="cell-output-html">
|
||||||
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
|
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
setting.IsInTesting = true
|
setting.IsInTesting = true
|
||||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
|
||||||
markup.RefreshFileNamePatterns()
|
markup.RefreshFileNamePatterns()
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dev/modules/htmlutil"
|
"gitea.dev/modules/htmlutil"
|
||||||
|
"gitea.dev/modules/markup/common"
|
||||||
"gitea.dev/modules/markup/internal"
|
"gitea.dev/modules/markup/internal"
|
||||||
"gitea.dev/modules/public"
|
"gitea.dev/modules/public"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
@@ -303,9 +304,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
|
|||||||
// Init initializes the render global variables
|
// Init initializes the render global variables
|
||||||
func Init(renderHelpFuncs *RenderHelperFuncs) {
|
func Init(renderHelpFuncs *RenderHelperFuncs) {
|
||||||
DefaultRenderHelperFuncs = renderHelpFuncs
|
DefaultRenderHelperFuncs = renderHelpFuncs
|
||||||
if len(setting.Markdown.CustomURLSchemes) > 0 {
|
common.InitLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
||||||
CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// since setting maybe changed extensions, this will reload all renderer extensions mapping
|
// since setting maybe changed extensions, this will reload all renderer extensions mapping
|
||||||
fileNameRenderers = make(map[string]Renderer)
|
fileNameRenderers = make(map[string]Renderer)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"gitea.dev/modules/markup/common"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
@@ -33,19 +34,18 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
|
|||||||
// Line numbers on codepreview
|
// Line numbers on codepreview
|
||||||
policy.AllowAttrs("data-line-number").OnElements("span")
|
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 {
|
if len(setting.Markdown.CustomURLSchemes) > 0 {
|
||||||
policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...)
|
policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...)
|
||||||
} else {
|
} else {
|
||||||
policy.AllowURLSchemesMatching(st.allowAllRegex)
|
policy.AllowURLSchemesMatching(st.allowAllRegex)
|
||||||
|
|
||||||
// Even if every scheme is allowed, these three are blocked for security reasons
|
// Even if every scheme is allowed, these three are blocked for security reasons
|
||||||
disallowScheme := func(*url.URL) bool {
|
disallowScheme := func(*url.URL) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
policy.AllowURLSchemeWithCustomPolicy("javascript", disallowScheme)
|
for _, scheme := range common.GlobalVars().DisallowedSchemes {
|
||||||
policy.AllowURLSchemeWithCustomPolicy("vbscript", disallowScheme)
|
policy.AllowURLSchemeWithCustomPolicy(scheme, disallowScheme)
|
||||||
policy.AllowURLSchemeWithCustomPolicy("data", disallowScheme)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow classes for org mode list item status.
|
// 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
|
// Sanitize use default sanitizer policy to sanitize a string
|
||||||
func Sanitize(s string) template.HTML {
|
func Sanitize[T string | template.HTML](s T) template.HTML {
|
||||||
return template.HTML(GetDefaultSanitizer().defaultPolicy.Sanitize(s))
|
return template.HTML(GetDefaultSanitizer().defaultPolicy.Sanitize(string(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeReader sanitizes a Reader
|
// SanitizeReader sanitizes a Reader
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ var Markdown = struct {
|
|||||||
MathCodeBlockDetection []string
|
MathCodeBlockDetection []string
|
||||||
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
||||||
}{
|
}{
|
||||||
EnableMath: true,
|
EnableMath: true,
|
||||||
|
FileNamePatterns: []string{"*.md"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkupRenderer defines the external parser configured in ini
|
// MarkupRenderer defines the external parser configured in ini
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, re
|
|||||||
msgLine, _, _ = strings.Cut(msgLine, "\n")
|
msgLine, _, _ = strings.Cut(msgLine, "\n")
|
||||||
msgLine = strings.TrimSpace(msgLine)
|
msgLine = strings.TrimSpace(msgLine)
|
||||||
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
||||||
rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, htmlutil.EscapeString(msgLine))
|
rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, msgLine)
|
||||||
return renderCodeBlock(rendered)
|
return renderCodeBlock(rendered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
package misc
|
package misc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.dev/modules/markup"
|
"io"
|
||||||
"gitea.dev/modules/markup/markdown"
|
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
@@ -84,9 +85,6 @@ func MarkdownRaw(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/MarkdownRender"
|
// "$ref": "#/responses/MarkdownRender"
|
||||||
// "422":
|
// "422":
|
||||||
// "$ref": "#/responses/validationError"
|
// "$ref": "#/responses/validationError"
|
||||||
defer ctx.Req.Body.Close()
|
textBytes, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.UI.MaxDisplayFileSize))
|
||||||
if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil {
|
common.RenderMarkup(ctx.Base, ctx.Repo, "markdown", util.UnsafeBytesToString(textBytes), "", "")
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,15 @@ import (
|
|||||||
go_context "context"
|
go_context "context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
repo_model "gitea.dev/models/repo"
|
|
||||||
"gitea.dev/models/unittest"
|
|
||||||
"gitea.dev/modules/markup"
|
"gitea.dev/modules/markup"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
context_service "gitea.dev/services/context"
|
|
||||||
"gitea.dev/services/contexttest"
|
"gitea.dev/services/contexttest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -27,13 +23,6 @@ import (
|
|||||||
|
|
||||||
const AppURL = "http://localhost:3000/"
|
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) {
|
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
|
||||||
setting.AppURL = AppURL
|
setting.AppURL = AppURL
|
||||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
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,
|
FilePath: filePath,
|
||||||
}
|
}
|
||||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
|
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)
|
web.SetForm(ctx, &options)
|
||||||
Markup(ctx)
|
Markup(ctx)
|
||||||
assert.Equal(t, expectedBody, resp.Body.String())
|
assert.Equal(t, expectedBody, resp.Body.String())
|
||||||
assert.Equal(t, expectedCode, resp.Code)
|
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) {
|
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)
|
Markdown(ctx)
|
||||||
assert.Equal(t, responseBody, resp.Body.String())
|
assert.Equal(t, responseBody, resp.Body.String())
|
||||||
assert.Equal(t, responseCode, resp.Code)
|
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) {
|
func TestAPI_RenderGFM(t *testing.T) {
|
||||||
unittest.PrepareTestEnv(t)
|
|
||||||
markup.Init(&markup.RenderHelperFuncs{
|
markup.Init(&markup.RenderHelperFuncs{
|
||||||
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
|
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
|
||||||
return username == "r-lyeh"
|
return username == "r-lyeh"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
|
|||||||
if acceptsHTML {
|
if acceptsHTML {
|
||||||
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
|
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
|
||||||
if err != nil {
|
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"))
|
_, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
// 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"
|
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
|
||||||
|
|
||||||
|
ctx.SetHeaderContentSecurityPolicyGeneral()
|
||||||
|
|
||||||
if mode == "" || mode == "markdown" {
|
if mode == "" || mode == "markdown" {
|
||||||
// raw Markdown doesn't do any special handling
|
// raw Markdown doesn't do any special handling
|
||||||
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
|
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func RenderFile(ctx *context.Context) {
|
|||||||
extRendererOpts := extRenderer.GetExternalRendererOptions()
|
extRendererOpts := extRenderer.GetExternalRendererOptions()
|
||||||
if extRendererOpts.ContentSandbox != "" {
|
if extRendererOpts.ContentSandbox != "" {
|
||||||
ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+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)
|
err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp)
|
||||||
|
|||||||
@@ -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...)
|
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 "<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, _ = ctx.Value("_cspScriptNonce").(string)
|
||||||
|
if ret == "" {
|
||||||
|
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
|
||||||
|
ctx.SetContextValue("_cspScriptNonce", ret)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Base) SetHeaderContentSecurityPolicyGeneral() {
|
||||||
|
if csp := WebContentSecurityPolicy(CspScriptNonce(b)); csp != "" {
|
||||||
|
b.Resp.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
|
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
|
||||||
reqCtx := reqctx.FromContext(req.Context())
|
reqCtx := reqctx.FromContext(req.Context())
|
||||||
b := &Base{
|
b := &Base{
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/htmlutil"
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/public"
|
"gitea.dev/modules/public"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/util"
|
|
||||||
"gitea.dev/modules/web/middleware"
|
"gitea.dev/modules/web/middleware"
|
||||||
"gitea.dev/services/webtheme"
|
"gitea.dev/services/webtheme"
|
||||||
)
|
)
|
||||||
@@ -24,7 +25,7 @@ type TemplateContext map[string]any
|
|||||||
|
|
||||||
var _ context.Context = TemplateContext(nil)
|
var _ context.Context = TemplateContext(nil)
|
||||||
|
|
||||||
func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext {
|
func NewTemplateContext(ctx reqctx.RequestContext, req *http.Request) TemplateContext {
|
||||||
return TemplateContext{"_ctx": ctx, "_req": req}
|
return TemplateContext{"_ctx": ctx, "_req": req}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +33,8 @@ func (c TemplateContext) req() *http.Request {
|
|||||||
return c["_req"].(*http.Request) //nolint:forcetypeassert // must exist
|
return c["_req"].(*http.Request) //nolint:forcetypeassert // must exist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) parentContext() context.Context {
|
func (c TemplateContext) parentContext() reqctx.RequestContext {
|
||||||
return c["_ctx"].(context.Context) //nolint:forcetypeassert // must exist
|
return c["_ctx"].(reqctx.RequestContext) //nolint:forcetypeassert // must exist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
|
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
|
||||||
@@ -100,21 +101,10 @@ func (c TemplateContext) ScriptImport(path string, typ ...string) template.HTML
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) CspScriptNonce() (ret string) {
|
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.
|
return CspScriptNonce(c.parentContext())
|
||||||
//
|
|
||||||
// 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 {
|
func WebContentSecurityPolicy(scriptNonce string) string {
|
||||||
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
|
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
|
||||||
return "" // if site admin disables the general CSP, then we don't use it
|
return "" // if site admin disables the general CSP, then we don't use it
|
||||||
}
|
}
|
||||||
@@ -130,16 +120,24 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
|||||||
// * Browsers will merge and use the stricter rules between Gitea and reverse proxy
|
// * Browsers will merge and use the stricter rules between Gitea and reverse proxy
|
||||||
// B. Introduce some config options in "app.ini"
|
// 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
|
// * 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)
|
// allow all by default (the same as old releases with no CSP)
|
||||||
// * maybe some images or markup (external) renders need "data:", need to investigate
|
// * maybe some images or markup (external) renders need "data:", need to investigate
|
||||||
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
|
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
|
||||||
`default-src * data: blob:;` +
|
return `default-src * data: blob:;` +
|
||||||
|
|
||||||
// enforce nonce for all scripts, disallow inline scripts
|
// enforce nonce for all scripts, disallow inline scripts
|
||||||
`script-src * 'nonce-` + c.CspScriptNonce() + `';` +
|
`script-src * 'nonce-` + scriptNonce + `';` +
|
||||||
|
|
||||||
// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
|
// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
|
||||||
`style-src * 'unsafe-inline';` +
|
`style-src * 'unsafe-inline';`
|
||||||
`">`)
|
}
|
||||||
|
|
||||||
|
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
||||||
|
scriptNonce := c.CspScriptNonce()
|
||||||
|
csp := WebContentSecurityPolicy(scriptNonce)
|
||||||
|
if csp == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return htmlutil.HTMLFormat(`<meta http-equiv="Content-Security-Policy" content="%s">`, csp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
|
|||||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
|
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
|
||||||
tmplCtx := NewTemplateContext(req.Context(), req)
|
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
|
||||||
|
|
||||||
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
|
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
|
||||||
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
|
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
|||||||
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/bin.no-sanitizer")
|
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/bin.no-sanitizer")
|
||||||
respSub := MakeRequest(t, req, http.StatusOK)
|
respSub := MakeRequest(t, req, http.StatusOK)
|
||||||
assert.Equal(t, binaryContent, respSub.Body.String()) // raw content should keep the raw bytes (including invalid UTF-8 bytes), and no "external-render-iframe" helpers
|
assert.Equal(t, binaryContent, respSub.Body.String()) // raw content should keep the raw bytes (including invalid UTF-8 bytes), and no "external-render-iframe" helpers
|
||||||
assert.Empty(t, respSub.Header().Get("Content-Security-Policy"), "sandbox is disabled by RENDER_CONTENT_SANDBOX")
|
assert.NotContains(t, respSub.Header().Get("Content-Security-Policy"), "sandbox", "sandbox is disabled by RENDER_CONTENT_SANDBOX")
|
||||||
|
assert.Contains(t, respSub.Header().Get("Content-Security-Policy"), "nonce-", "it should have the general policies as a normal web page")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
|
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
|
||||||
@@ -141,7 +142,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
|||||||
`<script>foo("raw")</script>`,
|
`<script>foo("raw")</script>`,
|
||||||
respSub.Body.String(),
|
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-")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user