Fix external render (#35727)

Fix #35725

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
wxiaoguang 2025-10-23 07:41:38 +08:00 committed by GitHub
parent 08b9776970
commit 195fc715ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 120 additions and 52 deletions

View File

@ -2541,6 +2541,12 @@ LEVEL = Info
;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code. ;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code.
;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page. ;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page.
;RENDER_CONTENT_MODE=sanitized ;RENDER_CONTENT_MODE=sanitized
;;
;; Whether post-process the rendered HTML content, including:
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc.
;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false.
;NEED_POST_PROCESS=false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -15,6 +15,8 @@ import (
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/kballard/go-shellquote"
) )
// RegisterRenderers registers all supported third part renderers according settings // RegisterRenderers registers all supported third part renderers according settings
@ -81,7 +83,10 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
envMark("GITEA_PREFIX_SRC"), baseLinkSrc, envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
envMark("GITEA_PREFIX_RAW"), baseLinkRaw, envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
).Replace(p.Command) ).Replace(p.Command)
commands := strings.Fields(command) commands, err := shellquote.Split(command)
if err != nil || len(commands) == 0 {
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
}
args := commands[1:] args := commands[1:]
if p.IsInputFile { if p.IsInputFile {

View File

@ -120,31 +120,38 @@ func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext {
return ctx return ctx
} }
// Render renders markup file to HTML with all specific handling stuff. // FindRendererByContext finds renderer by RenderContext
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error { // TODO: it should be merged with other similar functions like GetRendererByFileName, DetectMarkupTypeByFileName, etc
func FindRendererByContext(ctx *RenderContext) (Renderer, error) {
if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" { if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" {
ctx.RenderOptions.MarkupType = DetectMarkupTypeByFileName(ctx.RenderOptions.RelativePath) ctx.RenderOptions.MarkupType = DetectMarkupTypeByFileName(ctx.RenderOptions.RelativePath)
if ctx.RenderOptions.MarkupType == "" { if ctx.RenderOptions.MarkupType == "" {
return util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath) return nil, util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath)
} }
} }
renderer := renderers[ctx.RenderOptions.MarkupType] renderer := renderers[ctx.RenderOptions.MarkupType]
if renderer == nil { if renderer == nil {
return util.NewInvalidArgumentErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType) return nil, util.NewNotExistErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType)
} }
if ctx.RenderOptions.RelativePath != "" { return renderer, nil
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
if !ctx.RenderOptions.InStandalonePage {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
}
} }
return render(ctx, renderer, input, output) func RendererNeedPostProcess(renderer Renderer) bool {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
return true
}
return false
}
// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
renderer, err := FindRendererByContext(ctx)
if err != nil {
return err
}
return RenderWithRenderer(ctx, renderer, input, output)
} }
// RenderString renders Markup string to HTML with all specific handling stuff and return string // RenderString renders Markup string to HTML with all specific handling stuff and return string
@ -185,7 +192,16 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
} }
} }
func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
if !ctx.RenderOptions.InStandalonePage {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
// else: this is a standalone page, fallthrough to the real rendering
}
ctx.usedByRender = true ctx.usedByRender = true
if ctx.RenderHelper != nil { if ctx.RenderHelper != nil {
defer ctx.RenderHelper.CleanUp() defer ctx.RenderHelper.CleanUp()
@ -214,7 +230,7 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
} }
eg.Go(func() (err error) { eg.Go(func() (err error) {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { if RendererNeedPostProcess(renderer) {
err = PostProcessDefault(ctx, pr1, pw2) err = PostProcessDefault(ctx, pr1, pw2)
} else { } else {
_, err = io.Copy(pw2, pr1) _, err = io.Copy(pw2, pr1)

View File

@ -259,7 +259,9 @@ func newMarkupRenderer(name string, sec ConfigSection) {
FileExtensions: exts, FileExtensions: exts,
Command: command, Command: command,
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false), IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
NeedPostProcess: sec.Key("NEED_POSTPROCESS").MustBool(true),
RenderContentMode: renderContentMode, RenderContentMode: renderContentMode,
// if no sanitizer is needed, no post process is needed
NeedPostProcess: sec.Key("NEED_POST_PROCESS").MustBool(renderContentMode == RenderContentModeSanitized),
}) })
} }

View File

@ -151,17 +151,28 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
} }
func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) { func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) {
renderer, err := markup.FindRendererByContext(renderCtx)
if err != nil {
return nil, "", err
}
markupRd, markupWr := io.Pipe() markupRd, markupWr := io.Pipe()
defer markupWr.Close() defer markupWr.Close()
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
sb := &strings.Builder{} sb := &strings.Builder{}
// We allow NBSP here this is rendered if markup.RendererNeedPostProcess(renderer) {
escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.RuneNBSP) escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.RuneNBSP) // We allow NBSP here this is rendered
} else {
escaped = &charset.EscapeStatus{}
_, _ = io.Copy(sb, markupRd)
}
output = template.HTML(sb.String()) output = template.HTML(sb.String())
close(done) close(done)
}() }()
err = markup.Render(renderCtx, input, markupWr)
err = markup.RenderWithRenderer(renderCtx, renderer, input, markupWr)
_ = markupWr.CloseWithError(err) _ = markupWr.CloseWithError(err)
<-done <-done
return escaped, output, err return escaped, output, err

View File

@ -4,18 +4,23 @@
package integration package integration
import ( import (
"bytes"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"testing" "testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/external" "code.gitea.io/gitea/modules/markup/external"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestExternalMarkupRenderer(t *testing.T) { func TestExternalMarkupRenderer(t *testing.T) {
@ -25,36 +30,52 @@ func TestExternalMarkupRenderer(t *testing.T) {
return return
} }
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
t.Run("RenderNoSanitizer", func(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
_, err := createFile(user2, repo1, "file.no-sanitizer", "master", `any content`)
require.NoError(t, err)
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
div := doc.Find("div.file-view")
data, err := div.Html()
assert.NoError(t, err)
assert.Equal(t, `<script>window.alert("hi")</script>`, strings.TrimSpace(data))
})
})
t.Run("RenderContentDirectly", func(t *testing.T) {
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html") req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type")) assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err := io.ReadAll(resp.Body) doc := NewHTMLParser(t, resp.Body)
assert.NoError(t, err)
doc := NewHTMLParser(t, bytes.NewBuffer(bs))
div := doc.Find("div.file-view") div := doc.Find("div.file-view")
data, err := div.Html() data, err := div.Html()
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data)) assert.Equal(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
})
r := markup.GetRendererByFileName("a.html").(*external.Renderer) r := markup.GetRendererByFileName("any-file.html").(*external.Renderer)
r.RenderContentMode = setting.RenderContentModeIframe defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
req = NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html") t.Run("RenderContentInIFrame", func(t *testing.T) {
resp = MakeRequest(t, req, http.StatusOK) req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type")) assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err = io.ReadAll(resp.Body) doc := NewHTMLParser(t, resp.Body)
assert.NoError(t, err)
doc = NewHTMLParser(t, bytes.NewBuffer(bs))
iframe := doc.Find("iframe") iframe := doc.Find("iframe")
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("src", "")) assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("src", ""))
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html") req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type")) assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err = io.ReadAll(resp.Body) bs, err := io.ReadAll(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy")) assert.Equal(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy"))
assert.Equal(t, "<div>\n\ttest external renderer\n</div>\n", string(bs)) assert.Equal(t, "<div>\n\ttest external renderer\n</div>\n", string(bs))
})
} }

View File

@ -114,10 +114,17 @@ ENABLED = true
[markup.html] [markup.html]
ENABLED = true ENABLED = true
FILE_EXTENSIONS = .html FILE_EXTENSIONS = .html
RENDER_COMMAND = `go run build/test-echo.go` RENDER_COMMAND = go run build/test-echo.go
IS_INPUT_FILE = false ;RENDER_COMMAND = cat
;IS_INPUT_FILE = true
RENDER_CONTENT_MODE = sanitized RENDER_CONTENT_MODE = sanitized
[markup.no-sanitizer]
ENABLED = true
FILE_EXTENSIONS = .no-sanitizer
RENDER_COMMAND = echo '<script>window.alert("hi")</script>'
RENDER_CONTENT_MODE = no-sanitizer
[actions] [actions]
ENABLED = true ENABLED = true