Skip to content

Commit a257f30

Browse files
authored
Merge branch 'main' into misc
2 parents 3380356 + 522c466 commit a257f30

File tree

18 files changed

+294
-92
lines changed

18 files changed

+294
-92
lines changed

custom/conf/app.example.ini

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2540,13 +2540,19 @@ LEVEL = Info
25402540
;; * 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.*] .
25412541
;; * 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.
25422542
;; * 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.
2543-
;RENDER_CONTENT_MODE=sanitized
2544-
;;
2543+
;RENDER_CONTENT_MODE = sanitized
2544+
;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`.
2545+
;; It defaults to a safe set of "allow-*" restrictions (space separated).
2546+
;; You can also set it by your requirements or use "disabled" to disable the sandbox completely.
2547+
;; When set it, make sure there is no security risk:
2548+
;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox.
2549+
;; * HTML content with JS: if the "RENDER_COMMAND" can guarantee there is no XSS, then it is safe, otherwise, you need to fine tune the "allow-*" restrictions.
2550+
;RENDER_CONTENT_SANDBOX =
25452551
;; Whether post-process the rendered HTML content, including:
25462552
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
25472553
;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc.
25482554
;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false.
2549-
;NEED_POST_PROCESS=false
2555+
;NEED_POST_PROCESS = false
25502556

25512557
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
25522558
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

modules/httplib/serve.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
126126
// no sandbox attribute for pdf as it breaks rendering in at least safari. this
127127
// should generally be safe as scripts inside PDF can not escape the PDF document
128128
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
129+
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
129130
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
130131
}
131132

modules/markup/external/external.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,11 @@ func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
5858
return p.MarkupSanitizerRules
5959
}
6060

61-
// SanitizerDisabled disabled sanitize if return true
62-
func (p *Renderer) SanitizerDisabled() bool {
63-
return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
64-
}
65-
66-
// DisplayInIFrame represents whether render the content with an iframe
67-
func (p *Renderer) DisplayInIFrame() bool {
68-
return p.RenderContentMode == setting.RenderContentModeIframe
61+
func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
62+
ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
63+
ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe
64+
ret.ContentSandbox = p.RenderContentSandbox
65+
return ret
6966
}
7067

7168
func envMark(envName string) string {

modules/markup/internal/finalprocessor.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ package internal
55

66
import (
77
"bytes"
8+
"html/template"
89
"io"
910
)
1011

1112
type finalProcessor struct {
1213
renderInternal *RenderInternal
14+
extraHeadHTML template.HTML
1315

1416
output io.Writer
1517
buf bytes.Buffer
@@ -25,6 +27,32 @@ func (p *finalProcessor) Close() error {
2527
// because "postProcess" already does so. In the future we could optimize the code to process data on the fly.
2628
buf := p.buf.Bytes()
2729
buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`))
28-
_, err := p.output.Write(buf)
30+
31+
tmp := bytes.TrimSpace(buf)
32+
isLikelyHTML := len(tmp) != 0 && tmp[0] == '<' && tmp[len(tmp)-1] == '>' && bytes.Index(tmp, []byte(`</`)) > 0
33+
if !isLikelyHTML {
34+
// not HTML, write back directly
35+
_, err := p.output.Write(buf)
36+
return err
37+
}
38+
39+
// add our extra head HTML into output
40+
headBytes := []byte("<head>")
41+
posHead := bytes.Index(buf, headBytes)
42+
var part1, part2 []byte
43+
if posHead >= 0 {
44+
part1, part2 = buf[:posHead+len(headBytes)], buf[posHead+len(headBytes):]
45+
} else {
46+
part1, part2 = nil, buf
47+
}
48+
if len(part1) > 0 {
49+
if _, err := p.output.Write(part1); err != nil {
50+
return err
51+
}
52+
}
53+
if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil {
54+
return err
55+
}
56+
_, err := p.output.Write(part2)
2957
return err
3058
}

modules/markup/internal/internal_test.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"github.com/stretchr/testify/assert"
1313
)
1414

15-
func TestRenderInternal(t *testing.T) {
15+
func TestRenderInternalAttrs(t *testing.T) {
1616
cases := []struct {
1717
input, protected, recovered string
1818
}{
@@ -30,7 +30,7 @@ func TestRenderInternal(t *testing.T) {
3030
for _, c := range cases {
3131
var r RenderInternal
3232
out := &bytes.Buffer{}
33-
in := r.init("sec", out)
33+
in := r.init("sec", out, "")
3434
protected := r.ProtectSafeAttrs(template.HTML(c.input))
3535
assert.EqualValues(t, c.protected, protected)
3636
_, _ = io.WriteString(in, string(protected))
@@ -41,7 +41,7 @@ func TestRenderInternal(t *testing.T) {
4141
var r1, r2 RenderInternal
4242
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
4343
assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes")
44-
_ = r1.init("sec", nil)
44+
_ = r1.init("sec", nil, "")
4545
protected = r1.ProtectSafeAttrs(`<div class="test"></div>`)
4646
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
4747
assert.Equal(t, "data-attr-class", r1.SafeAttr("class"))
@@ -54,8 +54,37 @@ func TestRenderInternal(t *testing.T) {
5454
assert.Empty(t, recovered)
5555

5656
out2 := &bytes.Buffer{}
57-
in2 := r2.init("sec-other", out2)
57+
in2 := r2.init("sec-other", out2, "")
5858
_, _ = io.WriteString(in2, string(protected))
5959
_ = in2.Close()
6060
assert.Equal(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value")
6161
}
62+
63+
func TestRenderInternalExtraHead(t *testing.T) {
64+
t.Run("HeadExists", func(t *testing.T) {
65+
out := &bytes.Buffer{}
66+
var r RenderInternal
67+
in := r.init("sec", out, `<MY-TAG>`)
68+
_, _ = io.WriteString(in, `<head>any</head>`)
69+
_ = in.Close()
70+
assert.Equal(t, `<head><MY-TAG>any</head>`, out.String())
71+
})
72+
73+
t.Run("HeadNotExists", func(t *testing.T) {
74+
out := &bytes.Buffer{}
75+
var r RenderInternal
76+
in := r.init("sec", out, `<MY-TAG>`)
77+
_, _ = io.WriteString(in, `<div></div>`)
78+
_ = in.Close()
79+
assert.Equal(t, `<MY-TAG><div></div>`, out.String())
80+
})
81+
82+
t.Run("NotHTML", func(t *testing.T) {
83+
out := &bytes.Buffer{}
84+
var r RenderInternal
85+
in := r.init("sec", out, `<MY-TAG>`)
86+
_, _ = io.WriteString(in, `<any>`)
87+
_ = in.Close()
88+
assert.Equal(t, `<any>`, out.String())
89+
})
90+
}

modules/markup/internal/renderinternal.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ type RenderInternal struct {
2929
secureIDPrefix string
3030
}
3131

32-
func (r *RenderInternal) Init(output io.Writer) io.WriteCloser {
32+
func (r *RenderInternal) Init(output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
3333
buf := make([]byte, 12)
3434
_, err := rand.Read(buf)
3535
if err != nil {
3636
panic("unable to generate secure id")
3737
}
38-
return r.init(base64.URLEncoding.EncodeToString(buf), output)
38+
return r.init(base64.URLEncoding.EncodeToString(buf), output, extraHeadHTML)
3939
}
4040

41-
func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser {
41+
func (r *RenderInternal) init(secID string, output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
4242
r.secureID = secID
4343
r.secureIDPrefix = r.secureID + ":"
44-
return &finalProcessor{renderInternal: r, output: output}
44+
return &finalProcessor{renderInternal: r, output: output, extraHeadHTML: extraHeadHTML}
4545
}
4646

4747
func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) {

modules/markup/render.go

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ package markup
66
import (
77
"context"
88
"fmt"
9+
"html/template"
910
"io"
1011
"net/url"
1112
"strconv"
1213
"strings"
1314
"time"
1415

16+
"code.gitea.io/gitea/modules/htmlutil"
1517
"code.gitea.io/gitea/modules/markup/internal"
1618
"code.gitea.io/gitea/modules/setting"
1719
"code.gitea.io/gitea/modules/util"
@@ -163,24 +165,20 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
163165
return buf.String(), nil
164166
}
165167

166-
func renderIFrame(ctx *RenderContext, output io.Writer) error {
167-
// set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
168-
// at the moment, only "allow-scripts" is allowed for sandbox mode.
169-
// "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
170-
// 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
171-
_, err := io.WriteString(output, fmt.Sprintf(`
172-
<iframe src="%s/%s/%s/render/%s/%s"
173-
name="giteaExternalRender"
174-
onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
175-
width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
176-
sandbox="allow-scripts"
177-
></iframe>`,
178-
setting.AppSubURL,
168+
func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
169+
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
179170
url.PathEscape(ctx.RenderOptions.Metas["user"]),
180171
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
181-
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
182-
url.PathEscape(ctx.RenderOptions.RelativePath),
183-
))
172+
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
173+
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
174+
)
175+
176+
var sandboxAttrValue template.HTML
177+
if sandbox != "" {
178+
sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox)
179+
}
180+
iframe := htmlutil.HTMLFormat(`<iframe data-src="%s" class="external-render-iframe" %s></iframe>`, src, sandboxAttrValue)
181+
_, err := io.WriteString(output, string(iframe))
184182
return err
185183
}
186184

@@ -192,22 +190,34 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
192190
}
193191
}
194192

193+
func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
194+
if externalRender, ok := renderer.(ExternalRenderer); ok {
195+
return externalRender.GetExternalRendererOptions(), true
196+
}
197+
return ret, false
198+
}
199+
195200
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
196-
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
201+
var extraHeadHTML template.HTML
202+
if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
197203
if !ctx.RenderOptions.InStandalonePage {
198204
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
199205
// otherwise, a <iframe> should be outputted to embed the external rendered page
200-
return renderIFrame(ctx, output)
206+
return renderIFrame(ctx, extOpts.ContentSandbox, output)
201207
}
202-
// else: this is a standalone page, fallthrough to the real rendering
208+
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
209+
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css"
210+
extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js"
211+
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
212+
extraHeadHTML = htmlutil.HTMLFormat(`<script src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
203213
}
204214

205215
ctx.usedByRender = true
206216
if ctx.RenderHelper != nil {
207217
defer ctx.RenderHelper.CleanUp()
208218
}
209219

210-
finalProcessor := ctx.RenderInternal.Init(output)
220+
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
211221
defer finalProcessor.Close()
212222

213223
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
@@ -218,7 +228,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
218228
eg, _ := errgroup.WithContext(ctx)
219229
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
220230

221-
if r, ok := renderer.(ExternalRenderer); !ok || !r.SanitizerDisabled() {
231+
if r, ok := renderer.(ExternalRenderer); !ok || !r.GetExternalRendererOptions().SanitizerDisabled {
222232
var pr2 io.ReadCloser
223233
var close2 func()
224234
pr2, pw2, close2 = pipes()

modules/markup/renderer.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ type PostProcessRenderer interface {
2525
NeedPostProcess() bool
2626
}
2727

28+
type ExternalRendererOptions struct {
29+
SanitizerDisabled bool
30+
DisplayInIframe bool
31+
ContentSandbox string
32+
}
33+
2834
// ExternalRenderer defines an interface for external renderers
2935
type ExternalRenderer interface {
30-
// SanitizerDisabled disabled sanitize if return true
31-
SanitizerDisabled() bool
32-
33-
// DisplayInIFrame represents whether render the content with an iframe
34-
DisplayInIFrame() bool
36+
GetExternalRendererOptions() ExternalRendererOptions
3537
}
3638

3739
// RendererContentDetector detects if the content can be rendered

modules/setting/markup.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type MarkupRenderer struct {
6363
NeedPostProcess bool
6464
MarkupSanitizerRules []MarkupSanitizerRule
6565
RenderContentMode string
66+
RenderContentSandbox string
6667
}
6768

6869
// MarkupSanitizerRule defines the policy for whitelisting attributes on
@@ -253,13 +254,22 @@ func newMarkupRenderer(name string, sec ConfigSection) {
253254
renderContentMode = RenderContentModeSanitized
254255
}
255256

257+
// ATTENTION! at the moment, only a safe set like "allow-scripts" are allowed for sandbox mode.
258+
// "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
259+
renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString("allow-scripts allow-popups")
260+
if renderContentSandbox == "disabled" {
261+
renderContentSandbox = ""
262+
}
263+
256264
ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
257-
Enabled: sec.Key("ENABLED").MustBool(false),
258-
MarkupName: name,
259-
FileExtensions: exts,
260-
Command: command,
261-
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
262-
RenderContentMode: renderContentMode,
265+
Enabled: sec.Key("ENABLED").MustBool(false),
266+
MarkupName: name,
267+
FileExtensions: exts,
268+
Command: command,
269+
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
270+
271+
RenderContentMode: renderContentMode,
272+
RenderContentSandbox: renderContentSandbox,
263273

264274
// if no sanitizer is needed, no post process is needed
265275
NeedPostProcess: sec.Key("NEED_POST_PROCESS").MustBool(renderContentMode == RenderContentModeSanitized),

0 commit comments

Comments
 (0)