From b01dce2a6e98c25915a8e98afb741a1c34d05aba Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Thu, 16 Jun 2022 11:33:23 +0800
Subject: [PATCH] Allow render HTML with css/js external links (#19017)

* Allow render HTML with css/js external links

* Fix bug because of filename escape chars

* Fix lint

* Update docs about new configuration item

* Fix bug of render HTML in sub directory

* Add CSP head for displaying iframe in rendering file

* Fix test

* Apply suggestions from code review

Co-authored-by: delvh <dev.lh@web.de>

* Some improvements

* some improvement

* revert change in SanitizerDisabled of external renderer

* Add sandbox for iframe and support allow-scripts and allow-same-origin

* refactor

* fix

* fix lint

* fine tune

* use single option RENDER_CONTENT_MODE, use sandbox=allow-scripts

* fine tune CSP

* Apply suggestions from code review

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>

Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 custom/conf/app.example.ini                   |  7 +-
 .../doc/advanced/config-cheat-sheet.en-us.md  |  7 +-
 .../doc/advanced/config-cheat-sheet.zh-cn.md  |  7 +-
 modules/csv/csv.go                            |  2 +-
 modules/csv/csv_test.go                       |  2 +-
 modules/markup/console/console.go             |  8 --
 modules/markup/csv/csv.go                     |  8 --
 modules/markup/external/external.go           | 12 ++-
 modules/markup/html_test.go                   | 26 +++---
 modules/markup/markdown/markdown.go           |  9 +--
 modules/markup/orgmode/orgmode.go             |  9 +--
 modules/markup/renderer.go                    | 81 +++++++++++++++----
 modules/setting/markup.go                     | 37 +++++++--
 routers/web/repo/compare.go                   |  4 +-
 routers/web/repo/render.go                    | 79 ++++++++++++++++++
 routers/web/repo/view.go                      | 36 +++++----
 routers/web/web.go                            |  7 ++
 17 files changed, 248 insertions(+), 93 deletions(-)
 create mode 100644 routers/web/repo/render.go

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 8e082233c1..065c57ef51 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -2181,8 +2181,11 @@ PATH =
 ;RENDER_COMMAND = "asciidoc --out-file=- -"
 ;; Don't pass the file on STDIN, pass the filename as argument instead.
 ;IS_INPUT_FILE = false
-; Don't filter html tags and attributes if true
-;DISABLE_SANITIZER = false
+;; How the content will be rendered.
+;; * sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in [markup.sanitizer.*] .
+;; * 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.
+;RENDER_CONTENT_MODE=sanitized
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index 4e32ca00b6..4f041d417e 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -1026,13 +1026,16 @@ IS_INPUT_FILE = false
    command. Multiple extensions needs a comma as splitter.
 - RENDER\_COMMAND: External command to render all matching extensions.
 - IS\_INPUT\_FILE: **false** Input is not a standard input but a file param followed `RENDER_COMMAND`.
-- DISABLE_SANITIZER: **false** Don't filter html tags and attributes if true. Don't change this to true except you know what that means.
+- RENDER_CONTENT_MODE: **sanitized** How the content will be rendered.
+  - sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in `[markup.sanitizer.*]`.
+  - 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.
 
 Two special environment variables are passed to the render command:
 - `GITEA_PREFIX_SRC`, which contains the current URL prefix in the `src` path tree. To be used as prefix for links.
 - `GITEA_PREFIX_RAW`, which contains the current URL prefix in the `raw` path tree. To be used as prefix for image paths.
 
-If `DISABLE_SANITIZER` is false, Gitea supports customizing the sanitization policy for rendered HTML. The example below will support KaTeX output from pandoc.
+If `RENDER_CONTENT_MODE` is `sanitized`, Gitea supports customizing the sanitization policy for rendered HTML. The example below will support KaTeX output from pandoc.
 
 ```ini
 [markup.sanitizer.TeX]
diff --git a/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md b/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md
index cc6e950fbd..ef1504bc94 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md
@@ -318,14 +318,17 @@ IS_INPUT_FILE = false
 - FILE_EXTENSIONS: 关联的文档的扩展名,多个扩展名用都好分隔。
 - RENDER_COMMAND: 工具的命令行命令及参数。
 - IS_INPUT_FILE: 输入方式是最后一个参数为文件路径还是从标准输入读取。
-- DISABLE_SANITIZER: **false** 如果为 true 则不过滤 HTML 标签和属性。除非你知道这意味着什么,否则不要设置为 true。
+- RENDER_CONTENT_MODE: **sanitized** 内容如何被渲染。
+  - sanitized: 对内容进行净化并渲染到当前页面中,仅有一部分 HTML 标签和属性是被允许的。
+  - no-sanitizer: 禁用净化器,把内容渲染到当前页面中。此模式是**不安全**的,如果内容中含有恶意代码,可能会导致 XSS 攻击。
+  - iframe: 把内容渲染在一个独立的页面中并使用 iframe 嵌入到当前页面中。使用的 iframe 工作在沙箱模式并禁用了同源请求,JS 代码被安全的从父页面中隔离出去。
 
 以下两个环境变量将会被传递给渲染命令:
 
 - `GITEA_PREFIX_SRC`:包含当前的`src`路径的URL前缀,可以被用于链接的前缀。
 - `GITEA_PREFIX_RAW`:包含当前的`raw`路径的URL前缀,可以被用于图片的前缀。
 
-如果 `DISABLE_SANITIZER` 为 false,则 Gitea 支持自定义渲染 HTML 的净化策略。以下例子将用 pandoc 支持 KaTeX 输出。
+如果 `RENDER_CONTENT_MODE` 为 `sanitized`,则 Gitea 支持自定义渲染 HTML 的净化策略。以下例子将用 pandoc 支持 KaTeX 输出。
 
 ```ini
 [markup.sanitizer.TeX]
diff --git a/modules/csv/csv.go b/modules/csv/csv.go
index 0dd54271f1..fe0c350960 100644
--- a/modules/csv/csv.go
+++ b/modules/csv/csv.go
@@ -54,7 +54,7 @@ func CreateReaderAndDetermineDelimiter(ctx *markup.RenderContext, rd io.Reader)
 func determineDelimiter(ctx *markup.RenderContext, data []byte) rune {
 	extension := ".csv"
 	if ctx != nil {
-		extension = strings.ToLower(filepath.Ext(ctx.Filename))
+		extension = strings.ToLower(filepath.Ext(ctx.RelativePath))
 	}
 
 	var delimiter rune
diff --git a/modules/csv/csv_test.go b/modules/csv/csv_test.go
index b1e928ae99..9d0848ae5b 100644
--- a/modules/csv/csv_test.go
+++ b/modules/csv/csv_test.go
@@ -230,7 +230,7 @@ John Doe	john@doe.com	This,note,had,a,lot,of,commas,to,test,delimiters`,
 	}
 
 	for n, c := range cases {
-		delimiter := determineDelimiter(&markup.RenderContext{Filename: c.filename}, []byte(decodeSlashes(t, c.csv)))
+		delimiter := determineDelimiter(&markup.RenderContext{RelativePath: c.filename}, []byte(decodeSlashes(t, c.csv)))
 		assert.EqualValues(t, c.expectedDelimiter, delimiter, "case %d: delimiter should be equal, expected '%c' got '%c'", n, c.expectedDelimiter, delimiter)
 	}
 }
diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go
index b59594acb7..597593eee1 100644
--- a/modules/markup/console/console.go
+++ b/modules/markup/console/console.go
@@ -33,9 +33,6 @@ func (Renderer) Name() string {
 	return MarkupName
 }
 
-// NeedPostProcess implements markup.Renderer
-func (Renderer) NeedPostProcess() bool { return false }
-
 // Extensions implements markup.Renderer
 func (Renderer) Extensions() []string {
 	return []string{".sh-session"}
@@ -48,11 +45,6 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
 	}
 }
 
-// SanitizerDisabled disabled sanitize if return true
-func (Renderer) SanitizerDisabled() bool {
-	return false
-}
-
 // CanRender implements markup.RendererContentDetector
 func (Renderer) CanRender(filename string, input io.Reader) bool {
 	buf, err := io.ReadAll(input)
diff --git a/modules/markup/csv/csv.go b/modules/markup/csv/csv.go
index 17c3fe6f4f..5095b85465 100644
--- a/modules/markup/csv/csv.go
+++ b/modules/markup/csv/csv.go
@@ -29,9 +29,6 @@ func (Renderer) Name() string {
 	return "csv"
 }
 
-// NeedPostProcess implements markup.Renderer
-func (Renderer) NeedPostProcess() bool { return false }
-
 // Extensions implements markup.Renderer
 func (Renderer) Extensions() []string {
 	return []string{".csv", ".tsv"}
@@ -46,11 +43,6 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
 	}
 }
 
-// SanitizerDisabled disabled sanitize if return true
-func (Renderer) SanitizerDisabled() bool {
-	return false
-}
-
 func writeField(w io.Writer, element, class, field string) error {
 	if _, err := io.WriteString(w, "<"); err != nil {
 		return err
diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go
index a587abcc3b..23dd45ba0a 100644
--- a/modules/markup/external/external.go
+++ b/modules/markup/external/external.go
@@ -34,6 +34,11 @@ type Renderer struct {
 	*setting.MarkupRenderer
 }
 
+var (
+	_ markup.PostProcessRenderer = (*Renderer)(nil)
+	_ markup.ExternalRenderer    = (*Renderer)(nil)
+)
+
 // Name returns the external tool name
 func (p *Renderer) Name() string {
 	return p.MarkupName
@@ -56,7 +61,12 @@ func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
 
 // SanitizerDisabled disabled sanitize if return true
 func (p *Renderer) SanitizerDisabled() bool {
-	return p.DisableSanitizer
+	return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
+}
+
+// DisplayInIFrame represents whether render the content with an iframe
+func (p *Renderer) DisplayInIFrame() bool {
+	return p.RenderContentMode == setting.RenderContentModeIframe
 }
 
 func envMark(envName string) string {
diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go
index f6aabc6272..f494998c59 100644
--- a/modules/markup/html_test.go
+++ b/modules/markup/html_test.go
@@ -29,10 +29,10 @@ func TestRender_Commits(t *testing.T) {
 	setting.AppURL = TestAppURL
 	test := func(input, expected string) {
 		buffer, err := RenderString(&RenderContext{
-			Ctx:       git.DefaultContext,
-			Filename:  ".md",
-			URLPrefix: TestRepoURL,
-			Metas:     localMetas,
+			Ctx:          git.DefaultContext,
+			RelativePath: ".md",
+			URLPrefix:    TestRepoURL,
+			Metas:        localMetas,
 		}, input)
 		assert.NoError(t, err)
 		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
@@ -80,9 +80,9 @@ func TestRender_CrossReferences(t *testing.T) {
 
 	test := func(input, expected string) {
 		buffer, err := RenderString(&RenderContext{
-			Filename:  "a.md",
-			URLPrefix: setting.AppSubURL,
-			Metas:     localMetas,
+			RelativePath: "a.md",
+			URLPrefix:    setting.AppSubURL,
+			Metas:        localMetas,
 		}, input)
 		assert.NoError(t, err)
 		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
@@ -124,8 +124,8 @@ func TestRender_links(t *testing.T) {
 
 	test := func(input, expected string) {
 		buffer, err := RenderString(&RenderContext{
-			Filename:  "a.md",
-			URLPrefix: TestRepoURL,
+			RelativePath: "a.md",
+			URLPrefix:    TestRepoURL,
 		}, input)
 		assert.NoError(t, err)
 		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
@@ -223,8 +223,8 @@ func TestRender_email(t *testing.T) {
 
 	test := func(input, expected string) {
 		res, err := RenderString(&RenderContext{
-			Filename:  "a.md",
-			URLPrefix: TestRepoURL,
+			RelativePath: "a.md",
+			URLPrefix:    TestRepoURL,
 		}, input)
 		assert.NoError(t, err)
 		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res))
@@ -281,8 +281,8 @@ func TestRender_emoji(t *testing.T) {
 	test := func(input, expected string) {
 		expected = strings.ReplaceAll(expected, "&", "&amp;")
 		buffer, err := RenderString(&RenderContext{
-			Filename:  "a.md",
-			URLPrefix: TestRepoURL,
+			RelativePath: "a.md",
+			URLPrefix:    TestRepoURL,
 		}, input)
 		assert.NoError(t, err)
 		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go
index 7ebdfea6c4..37e11e606f 100644
--- a/modules/markup/markdown/markdown.go
+++ b/modules/markup/markdown/markdown.go
@@ -205,12 +205,14 @@ func init() {
 // Renderer implements markup.Renderer
 type Renderer struct{}
 
+var _ markup.PostProcessRenderer = (*Renderer)(nil)
+
 // Name implements markup.Renderer
 func (Renderer) Name() string {
 	return MarkupName
 }
 
-// NeedPostProcess implements markup.Renderer
+// NeedPostProcess implements markup.PostProcessRenderer
 func (Renderer) NeedPostProcess() bool { return true }
 
 // Extensions implements markup.Renderer
@@ -223,11 +225,6 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
 	return []setting.MarkupSanitizerRule{}
 }
 
-// SanitizerDisabled disabled sanitize if return true
-func (Renderer) SanitizerDisabled() bool {
-	return false
-}
-
 // Render implements markup.Renderer
 func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
 	return render(ctx, input, output)
diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go
index 2f394b992b..8c9f3b3da7 100644
--- a/modules/markup/orgmode/orgmode.go
+++ b/modules/markup/orgmode/orgmode.go
@@ -29,12 +29,14 @@ func init() {
 // Renderer implements markup.Renderer for orgmode
 type Renderer struct{}
 
+var _ markup.PostProcessRenderer = (*Renderer)(nil)
+
 // Name implements markup.Renderer
 func (Renderer) Name() string {
 	return "orgmode"
 }
 
-// NeedPostProcess implements markup.Renderer
+// NeedPostProcess implements markup.PostProcessRenderer
 func (Renderer) NeedPostProcess() bool { return true }
 
 // Extensions implements markup.Renderer
@@ -47,11 +49,6 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
 	return []setting.MarkupSanitizerRule{}
 }
 
-// SanitizerDisabled disabled sanitize if return true
-func (Renderer) SanitizerDisabled() bool {
-	return false
-}
-
 // Render renders orgmode rawbytes to HTML
 func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
 	htmlWriter := org.NewHTMLWriter()
diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go
index 6e4ae4e08c..e88fa31187 100644
--- a/modules/markup/renderer.go
+++ b/modules/markup/renderer.go
@@ -10,6 +10,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"net/url"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -43,17 +44,18 @@ type Header struct {
 
 // RenderContext represents a render context
 type RenderContext struct {
-	Ctx             context.Context
-	Filename        string
-	Type            string
-	IsWiki          bool
-	URLPrefix       string
-	Metas           map[string]string
-	DefaultLink     string
-	GitRepo         *git.Repository
-	ShaExistCache   map[string]bool
-	cancelFn        func()
-	TableOfContents []Header
+	Ctx              context.Context
+	RelativePath     string // relative path from tree root of the branch
+	Type             string
+	IsWiki           bool
+	URLPrefix        string
+	Metas            map[string]string
+	DefaultLink      string
+	GitRepo          *git.Repository
+	ShaExistCache    map[string]bool
+	cancelFn         func()
+	TableOfContents  []Header
+	InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
 }
 
 // Cancel runs any cleanup functions that have been registered for this Ctx
@@ -88,12 +90,24 @@ func (ctx *RenderContext) AddCancel(fn func()) {
 type Renderer interface {
 	Name() string // markup format name
 	Extensions() []string
-	NeedPostProcess() bool
 	SanitizerRules() []setting.MarkupSanitizerRule
-	SanitizerDisabled() bool
 	Render(ctx *RenderContext, input io.Reader, output io.Writer) error
 }
 
+// PostProcessRenderer defines an interface for renderers who need post process
+type PostProcessRenderer interface {
+	NeedPostProcess() bool
+}
+
+// PostProcessRenderer defines an interface for external renderers
+type ExternalRenderer interface {
+	// SanitizerDisabled disabled sanitize if return true
+	SanitizerDisabled() bool
+
+	// DisplayInIFrame represents whether render the content with an iframe
+	DisplayInIFrame() bool
+}
+
 // RendererContentDetector detects if the content can be rendered
 // by specified renderer
 type RendererContentDetector interface {
@@ -142,7 +156,7 @@ func DetectRendererType(filename string, input io.Reader) string {
 func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
 	if ctx.Type != "" {
 		return renderByType(ctx, input, output)
-	} else if ctx.Filename != "" {
+	} else if ctx.RelativePath != "" {
 		return renderFile(ctx, input, output)
 	}
 	return errors.New("Render options both filename and type missing")
@@ -163,6 +177,27 @@ type nopCloser struct {
 
 func (nopCloser) Close() error { return nil }
 
+func renderIFrame(ctx *RenderContext, output io.Writer) error {
+	// set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
+	// at the moment, only "allow-scripts" is allowed for sandbox mode.
+	// "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token
+	// TODO: when using dark theme, if the rendered content doesn't have proper style, the default text color is black, which is not easy to read
+	_, err := io.WriteString(output, fmt.Sprintf(`
+<iframe src="%s/%s/%s/render/%s/%s"
+name="giteaExternalRender"
+onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
+width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
+sandbox="allow-scripts"
+></iframe>`,
+		setting.AppSubURL,
+		url.PathEscape(ctx.Metas["user"]),
+		url.PathEscape(ctx.Metas["repo"]),
+		ctx.Metas["BranchNameSubURL"],
+		url.PathEscape(ctx.RelativePath),
+	))
+	return err
+}
+
 func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
 	var wg sync.WaitGroup
 	var err error
@@ -175,7 +210,12 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
 	var pr2 io.ReadCloser
 	var pw2 io.WriteCloser
 
-	if !renderer.SanitizerDisabled() {
+	var sanitizerDisabled bool
+	if r, ok := renderer.(ExternalRenderer); ok {
+		sanitizerDisabled = r.SanitizerDisabled()
+	}
+
+	if !sanitizerDisabled {
 		pr2, pw2 = io.Pipe()
 		defer func() {
 			_ = pr2.Close()
@@ -194,7 +234,7 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
 
 	wg.Add(1)
 	go func() {
-		if renderer.NeedPostProcess() {
+		if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
 			err = PostProcess(ctx, pr, pw2)
 		} else {
 			_, err = io.Copy(pw2, pr)
@@ -239,8 +279,15 @@ func (err ErrUnsupportedRenderExtension) Error() string {
 }
 
 func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
-	extension := strings.ToLower(filepath.Ext(ctx.Filename))
+	extension := strings.ToLower(filepath.Ext(ctx.RelativePath))
 	if renderer, ok := extRenderers[extension]; ok {
+		if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
+			if !ctx.InStandalonePage {
+				// for an external 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)
 	}
 	return ErrUnsupportedRenderExtension{extension}
diff --git a/modules/setting/markup.go b/modules/setting/markup.go
index 5fb6af6838..fd41bdd7cc 100644
--- a/modules/setting/markup.go
+++ b/modules/setting/markup.go
@@ -20,6 +20,12 @@ var (
 	MermaidMaxSourceCharacters int
 )
 
+const (
+	RenderContentModeSanitized   = "sanitized"
+	RenderContentModeNoSanitizer = "no-sanitizer"
+	RenderContentModeIframe      = "iframe"
+)
+
 // MarkupRenderer defines the external parser configured in ini
 type MarkupRenderer struct {
 	Enabled              bool
@@ -29,7 +35,7 @@ type MarkupRenderer struct {
 	IsInputFile          bool
 	NeedPostProcess      bool
 	MarkupSanitizerRules []MarkupSanitizerRule
-	DisableSanitizer     bool
+	RenderContentMode    string
 }
 
 // MarkupSanitizerRule defines the policy for whitelisting attributes on
@@ -144,13 +150,28 @@ func newMarkupRenderer(name string, sec *ini.Section) {
 		return
 	}
 
+	if sec.HasKey("DISABLE_SANITIZER") {
+		log.Error("Deprecated setting `[markup.*]` `DISABLE_SANITIZER` present. This fallback will be removed in v1.18.0")
+	}
+
+	renderContentMode := sec.Key("RENDER_CONTENT_MODE").MustString(RenderContentModeSanitized)
+	if !sec.HasKey("RENDER_CONTENT_MODE") && sec.Key("DISABLE_SANITIZER").MustBool(false) {
+		renderContentMode = RenderContentModeNoSanitizer // if only the legacy DISABLE_SANITIZER exists, use it
+	}
+	if renderContentMode != RenderContentModeSanitized &&
+		renderContentMode != RenderContentModeNoSanitizer &&
+		renderContentMode != RenderContentModeIframe {
+		log.Error("invalid RENDER_CONTENT_MODE: %q, default to %q", renderContentMode, RenderContentModeSanitized)
+		renderContentMode = RenderContentModeSanitized
+	}
+
 	ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
-		Enabled:          sec.Key("ENABLED").MustBool(false),
-		MarkupName:       name,
-		FileExtensions:   exts,
-		Command:          command,
-		IsInputFile:      sec.Key("IS_INPUT_FILE").MustBool(false),
-		NeedPostProcess:  sec.Key("NEED_POSTPROCESS").MustBool(true),
-		DisableSanitizer: sec.Key("DISABLE_SANITIZER").MustBool(false),
+		Enabled:           sec.Key("ENABLED").MustBool(false),
+		MarkupName:        name,
+		FileExtensions:    exts,
+		Command:           command,
+		IsInputFile:       sec.Key("IS_INPUT_FILE").MustBool(false),
+		NeedPostProcess:   sec.Key("NEED_POSTPROCESS").MustBool(true),
+		RenderContentMode: renderContentMode,
 	})
 }
diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go
index 605594d5a9..5c46882f3d 100644
--- a/routers/web/repo/compare.go
+++ b/routers/web/repo/compare.go
@@ -139,7 +139,7 @@ func setCsvCompareContext(ctx *context.Context) {
 			return csvReader, reader, err
 		}
 
-		baseReader, baseBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, Filename: diffFile.OldName}, baseCommit)
+		baseReader, baseBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, RelativePath: diffFile.OldName}, baseCommit)
 		if baseBlobCloser != nil {
 			defer baseBlobCloser.Close()
 		}
@@ -151,7 +151,7 @@ func setCsvCompareContext(ctx *context.Context) {
 			return CsvDiffResult{nil, "unable to load file from base commit"}
 		}
 
-		headReader, headBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, Filename: diffFile.Name}, headCommit)
+		headReader, headBlobCloser, err := csvReaderFromCommit(&markup.RenderContext{Ctx: ctx, RelativePath: diffFile.Name}, headCommit)
 		if headBlobCloser != nil {
 			defer headBlobCloser.Close()
 		}
diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go
new file mode 100644
index 0000000000..28a6d2f429
--- /dev/null
+++ b/routers/web/repo/render.go
@@ -0,0 +1,79 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package repo
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"path"
+
+	"code.gitea.io/gitea/modules/charset"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/typesniffer"
+	"code.gitea.io/gitea/modules/util"
+)
+
+// RenderFile renders a file by repos path
+func RenderFile(ctx *context.Context) {
+	blob, err := ctx.Repo.Commit.GetBlobByPath(ctx.Repo.TreePath)
+	if err != nil {
+		if git.IsErrNotExist(err) {
+			ctx.NotFound("GetBlobByPath", err)
+		} else {
+			ctx.ServerError("GetBlobByPath", err)
+		}
+		return
+	}
+
+	dataRc, err := blob.DataAsync()
+	if err != nil {
+		ctx.ServerError("DataAsync", err)
+		return
+	}
+	defer dataRc.Close()
+
+	buf := make([]byte, 1024)
+	n, _ := util.ReadAtMost(dataRc, buf)
+	buf = buf[:n]
+
+	st := typesniffer.DetectContentType(buf)
+	isTextFile := st.IsText()
+
+	rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc))
+
+	if markupType := markup.Type(blob.Name()); markupType == "" {
+		if isTextFile {
+			_, err = io.Copy(ctx.Resp, rd)
+			if err != nil {
+				ctx.ServerError("Copy", err)
+			}
+			return
+		}
+		ctx.Error(http.StatusInternalServerError, "Unsupported file type render")
+		return
+	}
+
+	treeLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
+	if ctx.Repo.TreePath != "" {
+		treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
+	}
+
+	ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts")
+	err = markup.Render(&markup.RenderContext{
+		Ctx:              ctx,
+		RelativePath:     ctx.Repo.TreePath,
+		URLPrefix:        path.Dir(treeLink),
+		Metas:            ctx.Repo.Repository.ComposeDocumentMetas(),
+		GitRepo:          ctx.Repo.GitRepo,
+		InStandalonePage: true,
+	}, rd, ctx.Resp)
+	if err != nil {
+		ctx.ServerError("Render", err)
+		return
+	}
+}
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 01bd2d8923..fe60cf44c7 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -356,11 +356,11 @@ func renderReadmeFile(ctx *context.Context, readmeFile *namedBlob, readmeTreelin
 		ctx.Data["MarkupType"] = string(markupType)
 		var result strings.Builder
 		err := markup.Render(&markup.RenderContext{
-			Ctx:       ctx,
-			Filename:  readmeFile.name,
-			URLPrefix: readmeTreelink,
-			Metas:     ctx.Repo.Repository.ComposeDocumentMetas(),
-			GitRepo:   ctx.Repo.GitRepo,
+			Ctx:          ctx,
+			RelativePath: ctx.Repo.TreePath,
+			URLPrefix:    readmeTreelink,
+			Metas:        ctx.Repo.Repository.ComposeDocumentMetas(),
+			GitRepo:      ctx.Repo.GitRepo,
 		}, rd, &result)
 		if err != nil {
 			log.Error("Render failed: %v then fallback", err)
@@ -528,18 +528,22 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 			if !detected {
 				markupType = ""
 			}
+			metas := ctx.Repo.Repository.ComposeDocumentMetas()
+			metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL()
 			err := markup.Render(&markup.RenderContext{
-				Ctx:       ctx,
-				Type:      markupType,
-				Filename:  blob.Name(),
-				URLPrefix: path.Dir(treeLink),
-				Metas:     ctx.Repo.Repository.ComposeDocumentMetas(),
-				GitRepo:   ctx.Repo.GitRepo,
+				Ctx:          ctx,
+				Type:         markupType,
+				RelativePath: ctx.Repo.TreePath,
+				URLPrefix:    path.Dir(treeLink),
+				Metas:        metas,
+				GitRepo:      ctx.Repo.GitRepo,
 			}, rd, &result)
 			if err != nil {
 				ctx.ServerError("Render", err)
 				return
 			}
+			// to prevent iframe load third-party url
+			ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
 			ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlString(result.String())
 		} else if readmeExist && !shouldRenderSource {
 			buf := &bytes.Buffer{}
@@ -627,11 +631,11 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 			ctx.Data["MarkupType"] = markupType
 			var result strings.Builder
 			err := markup.Render(&markup.RenderContext{
-				Ctx:       ctx,
-				Filename:  blob.Name(),
-				URLPrefix: path.Dir(treeLink),
-				Metas:     ctx.Repo.Repository.ComposeDocumentMetas(),
-				GitRepo:   ctx.Repo.GitRepo,
+				Ctx:          ctx,
+				RelativePath: ctx.Repo.TreePath,
+				URLPrefix:    path.Dir(treeLink),
+				Metas:        ctx.Repo.Repository.ComposeDocumentMetas(),
+				GitRepo:      ctx.Repo.GitRepo,
 			}, rd, &result)
 			if err != nil {
 				ctx.ServerError("Render", err)
diff --git a/routers/web/web.go b/routers/web/web.go
index ad005f74df..374bafbc8d 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1161,6 +1161,13 @@ func RegisterRoutes(m *web.Route) {
 			m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.SingleDownload)
 		}, repo.MustBeNotEmpty, reqRepoCodeReader)
 
+		m.Group("/render", func() {
+			m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RenderFile)
+			m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RenderFile)
+			m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.RenderFile)
+			m.Get("/blob/{sha}", context.RepoRefByType(context.RepoRefBlob), repo.RenderFile)
+		}, repo.MustBeNotEmpty, reqRepoCodeReader)
+
 		m.Group("/commits", func() {
 			m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.RefCommits)
 			m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.RefCommits)