Make external iframe render work (#35730)

Fix #35729, #17635, #21098
This commit is contained in:
wxiaoguang
2025-10-23 16:01:38 +08:00
committed by GitHub
parent 8085c75356
commit 522c466e24
18 changed files with 294 additions and 92 deletions

View File

@@ -58,14 +58,11 @@ func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return p.MarkupSanitizerRules
}
// SanitizerDisabled disabled sanitize if return true
func (p *Renderer) SanitizerDisabled() bool {
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 (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe
ret.ContentSandbox = p.RenderContentSandbox
return ret
}
func envMark(envName string) string {

View File

@@ -5,11 +5,13 @@ package internal
import (
"bytes"
"html/template"
"io"
)
type finalProcessor struct {
renderInternal *RenderInternal
extraHeadHTML template.HTML
output io.Writer
buf bytes.Buffer
@@ -25,6 +27,32 @@ func (p *finalProcessor) Close() error {
// because "postProcess" already does so. In the future we could optimize the code to process data on the fly.
buf := p.buf.Bytes()
buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`))
_, err := p.output.Write(buf)
tmp := bytes.TrimSpace(buf)
isLikelyHTML := len(tmp) != 0 && tmp[0] == '<' && tmp[len(tmp)-1] == '>' && bytes.Index(tmp, []byte(`</`)) > 0
if !isLikelyHTML {
// not HTML, write back directly
_, err := p.output.Write(buf)
return err
}
// add our extra head HTML into output
headBytes := []byte("<head>")
posHead := bytes.Index(buf, headBytes)
var part1, part2 []byte
if posHead >= 0 {
part1, part2 = buf[:posHead+len(headBytes)], buf[posHead+len(headBytes):]
} else {
part1, part2 = nil, buf
}
if len(part1) > 0 {
if _, err := p.output.Write(part1); err != nil {
return err
}
}
if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil {
return err
}
_, err := p.output.Write(part2)
return err
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestRenderInternal(t *testing.T) {
func TestRenderInternalAttrs(t *testing.T) {
cases := []struct {
input, protected, recovered string
}{
@@ -30,7 +30,7 @@ func TestRenderInternal(t *testing.T) {
for _, c := range cases {
var r RenderInternal
out := &bytes.Buffer{}
in := r.init("sec", out)
in := r.init("sec", out, "")
protected := r.ProtectSafeAttrs(template.HTML(c.input))
assert.EqualValues(t, c.protected, protected)
_, _ = io.WriteString(in, string(protected))
@@ -41,7 +41,7 @@ func TestRenderInternal(t *testing.T) {
var r1, r2 RenderInternal
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes")
_ = r1.init("sec", nil)
_ = r1.init("sec", nil, "")
protected = r1.ProtectSafeAttrs(`<div class="test"></div>`)
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
assert.Equal(t, "data-attr-class", r1.SafeAttr("class"))
@@ -54,8 +54,37 @@ func TestRenderInternal(t *testing.T) {
assert.Empty(t, recovered)
out2 := &bytes.Buffer{}
in2 := r2.init("sec-other", out2)
in2 := r2.init("sec-other", out2, "")
_, _ = io.WriteString(in2, string(protected))
_ = in2.Close()
assert.Equal(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value")
}
func TestRenderInternalExtraHead(t *testing.T) {
t.Run("HeadExists", func(t *testing.T) {
out := &bytes.Buffer{}
var r RenderInternal
in := r.init("sec", out, `<MY-TAG>`)
_, _ = io.WriteString(in, `<head>any</head>`)
_ = in.Close()
assert.Equal(t, `<head><MY-TAG>any</head>`, out.String())
})
t.Run("HeadNotExists", func(t *testing.T) {
out := &bytes.Buffer{}
var r RenderInternal
in := r.init("sec", out, `<MY-TAG>`)
_, _ = io.WriteString(in, `<div></div>`)
_ = in.Close()
assert.Equal(t, `<MY-TAG><div></div>`, out.String())
})
t.Run("NotHTML", func(t *testing.T) {
out := &bytes.Buffer{}
var r RenderInternal
in := r.init("sec", out, `<MY-TAG>`)
_, _ = io.WriteString(in, `<any>`)
_ = in.Close()
assert.Equal(t, `<any>`, out.String())
})
}

View File

@@ -29,19 +29,19 @@ type RenderInternal struct {
secureIDPrefix string
}
func (r *RenderInternal) Init(output io.Writer) io.WriteCloser {
func (r *RenderInternal) Init(output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
buf := make([]byte, 12)
_, err := rand.Read(buf)
if err != nil {
panic("unable to generate secure id")
}
return r.init(base64.URLEncoding.EncodeToString(buf), output)
return r.init(base64.URLEncoding.EncodeToString(buf), output, extraHeadHTML)
}
func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser {
func (r *RenderInternal) init(secID string, output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
r.secureID = secID
r.secureIDPrefix = r.secureID + ":"
return &finalProcessor{renderInternal: r, output: output}
return &finalProcessor{renderInternal: r, output: output, extraHeadHTML: extraHeadHTML}
}
func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) {

View File

@@ -6,12 +6,14 @@ package markup
import (
"context"
"fmt"
"html/template"
"io"
"net/url"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
@@ -163,24 +165,20 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
return buf.String(), 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,
func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
url.PathEscape(ctx.RenderOptions.Metas["user"]),
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
url.PathEscape(ctx.RenderOptions.RelativePath),
))
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
)
var sandboxAttrValue template.HTML
if sandbox != "" {
sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox)
}
iframe := htmlutil.HTMLFormat(`<iframe data-src="%s" class="external-render-iframe" %s></iframe>`, src, sandboxAttrValue)
_, err := io.WriteString(output, string(iframe))
return err
}
@@ -192,14 +190,26 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
}
}
func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
if externalRender, ok := renderer.(ExternalRenderer); ok {
return externalRender.GetExternalRendererOptions(), true
}
return ret, false
}
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
var extraHeadHTML template.HTML
if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.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 renderIFrame(ctx, extOpts.ContentSandbox, output)
}
// else: this is a standalone page, fallthrough to the real rendering
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css"
extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js"
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
extraHeadHTML = htmlutil.HTMLFormat(`<script src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
}
ctx.usedByRender = true
@@ -207,7 +217,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
defer ctx.RenderHelper.CleanUp()
}
finalProcessor := ctx.RenderInternal.Init(output)
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
defer finalProcessor.Close()
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
@@ -218,7 +228,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
eg, _ := errgroup.WithContext(ctx)
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
if r, ok := renderer.(ExternalRenderer); !ok || !r.SanitizerDisabled() {
if r, ok := renderer.(ExternalRenderer); !ok || !r.GetExternalRendererOptions().SanitizerDisabled {
var pr2 io.ReadCloser
var close2 func()
pr2, pw2, close2 = pipes()

View File

@@ -25,13 +25,15 @@ type PostProcessRenderer interface {
NeedPostProcess() bool
}
type ExternalRendererOptions struct {
SanitizerDisabled bool
DisplayInIframe bool
ContentSandbox string
}
// ExternalRenderer 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
GetExternalRendererOptions() ExternalRendererOptions
}
// RendererContentDetector detects if the content can be rendered