Skip to content

Commit 6104163

Browse files
committed
fix sandbox
1 parent 72b3eeb commit 6104163

File tree

10 files changed

+121
-81
lines changed

10 files changed

+121
-81
lines changed

custom/conf/app.example.ini

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2540,13 +2540,17 @@ 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 to "disabled" to disable the sandbox completely (for example: if the content is only PDF)
2547+
;; Don't set it unless you know what you are doing and you are sure there is no security risk.
2548+
;RENDER_CONTENT_SANDBOX =
25452549
;; Whether post-process the rendered HTML content, including:
25462550
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
25472551
;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc.
25482552
;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false.
2549-
;NEED_POST_PROCESS=false
2553+
;NEED_POST_PROCESS = false
25502554

25512555
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
25522556
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

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/render.go

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -165,28 +165,19 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
165165
return buf.String(), nil
166166
}
167167

168-
func renderIFrame(ctx *RenderContext, output io.Writer) error {
168+
func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
169169
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
170170
url.PathEscape(ctx.RenderOptions.Metas["user"]),
171171
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
172172
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
173173
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
174174
)
175175

176-
defaultWidth := "100%"
177-
defaultHeight := "300"
178-
179-
// ATTENTION! at the moment, only "allow-scripts" is allowed for sandbox mode.
180-
// "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
181-
iframe := htmlutil.HTMLFormat(`
182-
<iframe data-src="%s"
183-
class="external-render-iframe"
184-
sandbox="allow-scripts allow-popups"
185-
width="%s" height="%s"
186-
></iframe>
187-
`,
188-
src, defaultWidth, defaultHeight)
189-
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)
190181
_, err := io.WriteString(output, string(iframe))
191182
return err
192183
}
@@ -199,13 +190,20 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
199190
}
200191
}
201192

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+
202200
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
203201
var extraHeadHTML template.HTML
204-
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
202+
if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
205203
if !ctx.RenderOptions.InStandalonePage {
206204
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
207205
// otherwise, a <iframe> should be outputted to embed the external rendered page
208-
return renderIFrame(ctx, output)
206+
return renderIFrame(ctx, extOpts.ContentSandbox, output)
209207
}
210208
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
211209
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css"
@@ -230,7 +228,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
230228
eg, _ := errgroup.WithContext(ctx)
231229
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
232230

233-
if r, ok := renderer.(ExternalRenderer); !ok || !r.SanitizerDisabled() {
231+
if r, ok := renderer.(ExternalRenderer); !ok || !r.GetExternalRendererOptions().SanitizerDisabled {
234232
var pr2 io.ReadCloser
235233
var close2 func()
236234
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),

routers/web/repo/render.go

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,13 @@
44
package repo
55

66
import (
7-
"bytes"
8-
"io"
97
"net/http"
108
"path"
119

1210
"code.gitea.io/gitea/models/renderhelper"
13-
"code.gitea.io/gitea/modules/charset"
1411
"code.gitea.io/gitea/modules/git"
1512
"code.gitea.io/gitea/modules/log"
1613
"code.gitea.io/gitea/modules/markup"
17-
"code.gitea.io/gitea/modules/typesniffer"
18-
"code.gitea.io/gitea/modules/util"
1914
"code.gitea.io/gitea/services/context"
2015
)
2116

@@ -44,22 +39,8 @@ func RenderFile(ctx *context.Context) {
4439
}
4540
defer dataRc.Close()
4641

47-
buf := make([]byte, 1024)
48-
n, _ := util.ReadAtMost(dataRc, buf)
49-
buf = buf[:n]
50-
51-
st := typesniffer.DetectContentType(buf)
52-
isTextFile := st.IsText()
53-
54-
rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{})
55-
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts allow-popups")
56-
5742
if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType == "" {
58-
if isTextFile {
59-
_, _ = io.Copy(ctx.Resp, rd)
60-
} else {
61-
http.Error(ctx.Resp, "Unsupported file type render", http.StatusInternalServerError)
62-
}
43+
http.Error(ctx.Resp, "Unsupported file type render", http.StatusBadRequest)
6344
return
6445
}
6546

@@ -68,7 +49,29 @@ func RenderFile(ctx *context.Context) {
6849
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
6950
}).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true)
7051

71-
err = markup.Render(rctx, rd, ctx.Resp)
52+
renderer, err := markup.FindRendererByContext(rctx)
53+
if err != nil {
54+
http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest)
55+
return
56+
}
57+
58+
extRenderer, ok := renderer.(markup.ExternalRenderer)
59+
if !ok {
60+
http.Error(ctx.Resp, "Unable to get external renderer", http.StatusBadRequest)
61+
return
62+
}
63+
64+
// To render PDF in iframe, the sandbox must NOT be used (iframe & CSP header).
65+
// Chrome blocks the PDF rendering when sandboxed, even if all "allow-*" are set.
66+
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
67+
extRendererOpts := extRenderer.GetExternalRendererOptions()
68+
if extRendererOpts.ContentSandbox != "" {
69+
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox)
70+
} else {
71+
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
72+
}
73+
74+
err = markup.RenderWithRenderer(rctx, renderer, dataRc, ctx.Resp)
7275
if err != nil {
7376
log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err)
7477
http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError)

tests/integration/markup_external_test.go

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package integration
55

66
import (
7-
"io"
87
"net/http"
98
"net/url"
109
"strings"
@@ -31,12 +30,12 @@ func TestExternalMarkupRenderer(t *testing.T) {
3130
}
3231

3332
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
34-
t.Run("RenderNoSanitizer", func(t *testing.T) {
35-
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
36-
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
37-
_, err := createFile(user2, repo1, "file.no-sanitizer", "master", `any content`)
38-
require.NoError(t, err)
33+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
34+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
35+
_, err := createFile(user2, repo1, "file.no-sanitizer", "master", `any content`)
36+
require.NoError(t, err)
3937

38+
t.Run("RenderNoSanitizer", func(t *testing.T) {
4039
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
4140
resp := MakeRequest(t, req, http.StatusOK)
4241
doc := NewHTMLParser(t, resp.Body)
@@ -63,20 +62,40 @@ func TestExternalMarkupRenderer(t *testing.T) {
6362
defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
6463

6564
t.Run("RenderContentInIFrame", func(t *testing.T) {
66-
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
67-
resp := MakeRequest(t, req, http.StatusOK)
68-
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
69-
doc := NewHTMLParser(t, resp.Body)
70-
iframe := doc.Find("iframe")
71-
assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead
72-
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("data-src", ""))
65+
t.Run("DefaultSandbox", func(t *testing.T) {
66+
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
67+
var iframeDataSrc, iframeSandbox string
68+
t.Run("ParentPage", func(t *testing.T) {
69+
respParent := MakeRequest(t, req, http.StatusOK)
70+
assert.Equal(t, "text/html; charset=utf-8", respParent.Header().Get("Content-Type"))
71+
iframe := NewHTMLParser(t, respParent.Body).Find("iframe")
72+
iframeDataSrc = iframe.AttrOr("data-src", "")
73+
iframeSandbox = iframe.AttrOr("sandbox", "")
74+
assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead
75+
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframeDataSrc)
76+
})
77+
t.Run("SubPage", func(t *testing.T) {
78+
req = NewRequest(t, "GET", iframeDataSrc)
79+
respSub := MakeRequest(t, req, http.StatusOK)
80+
assert.Equal(t, "text/html; charset=utf-8", respSub.Header().Get("Content-Type"))
7381

74-
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
75-
resp = MakeRequest(t, req, http.StatusOK)
76-
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
77-
bs, err := io.ReadAll(resp.Body)
78-
assert.NoError(t, err)
79-
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", resp.Header().Get("Content-Security-Policy"))
80-
assert.Equal(t, "<script src=\"/assets/js/external-render-iframe.js\"></script><link rel=\"stylesheet\" href=\"/assets/css/external-render-iframe.css\"><div>\n\ttest external renderer\n</div>\n", string(bs))
82+
// default sandbox on both parent and sub pages
83+
assert.Equal(t, "allow-scripts allow-popups", iframeSandbox)
84+
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
85+
assert.Equal(t, "<script src=\"/assets/js/external-render-iframe.js\"></script><link rel=\"stylesheet\" href=\"/assets/css/external-render-iframe.css\"><div>\n\ttest external renderer\n</div>\n", respSub.Body.String())
86+
})
87+
})
88+
89+
t.Run("NoSanitizerNoSandbox", func(t *testing.T) {
90+
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
91+
respParent := MakeRequest(t, req, http.StatusOK)
92+
iframe := NewHTMLParser(t, respParent.Body).Find("iframe")
93+
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/file.no-sanitizer")
94+
respSub := MakeRequest(t, req, http.StatusOK)
95+
96+
// no sandbox (disabled by RENDER_CONTENT_SANDBOX)
97+
assert.Empty(t, iframe.AttrOr("sandbox", ""))
98+
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
99+
})
81100
})
82101
}

tests/sqlite.ini.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ ENABLED = true
124124
FILE_EXTENSIONS = .no-sanitizer
125125
RENDER_COMMAND = echo '<script>window.alert("hi")</script>'
126126
RENDER_CONTENT_MODE = no-sanitizer
127+
RENDER_CONTENT_SANDBOX = disabled
127128

128129
[actions]
129130
ENABLED = true

web_src/css/markup/content.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,11 @@ In markup content, we always use bottom margin for all elements */
545545
margin: 0 0.25em;
546546
}
547547

548+
.external-render-iframe {
549+
width: 100%;
550+
height: max(300px, 80vh);
551+
}
552+
548553
.markup-content-iframe {
549554
display: block;
550555
border: none;

0 commit comments

Comments
 (0)