Skip to content

Add new RENDER_CONTENT_IFRAME_SANDBOX for the iframe sandbox when load html, Add RENDER_CONTENT_EXTERNAL_CSP for the external render Content-Security-Policy header #20180

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
4 changes: 4 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2182,6 +2182,10 @@ ROUTER = console
;; * 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
;;
;; When RENDER_CONTENT_MODE is iframe, these two options are available
;RENDER_CONTENT_IFRAME_SANDBOX="allow-scripts"
;RENDER_CONTENT_EXTERNAL_CSP="iframe-src 'self'; sandbox allow-scripts"

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
2 changes: 2 additions & 0 deletions docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,8 @@ IS_INPUT_FILE = false
- 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_IFRAME_SANDBOX: **"allow-scripts"** When `RENDER_CONTENT_MODE` is `iframe`, this will be the allowed sandbox of iframe properties.
- RENDER_CONTENT_EXTERNAL_CSP: **"iframe-src 'self'; sandbox allow-scripts"** When `RENDER_CONTENT_MODE` is `iframe`, this will be the allowed CSP of external renderer response.

Two special environment variables are passed to the render command:

Expand Down
13 changes: 12 additions & 1 deletion modules/markup/external/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,25 @@ func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {

// SanitizerDisabled disabled sanitize if return true
func (p *Renderer) SanitizerDisabled() bool {
return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
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
}

// IframeSandbox represents iframe sandbox
func (p *Renderer) IframeSandbox() string {
return p.RenderContentIframeSandbox
}

// ExternalCSP represents external render CSP
func (p *Renderer) ExternalCSP() string {
return p.RenderContentExternalCSP
}

func envMark(envName string) string {
if runtime.GOOS == "windows" {
return "%" + envName + "%"
Expand Down
110 changes: 67 additions & 43 deletions modules/markup/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,22 @@ type Header struct {

// RenderContext represents a render context
type RenderContext struct {
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
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 is used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
// It is for maintenance and security purpose, to avoid rendering external JS into embedded page unexpectedly.
// The caller of the Render must set security headers correctly before setting it to true
InStandalonePage bool
}

// Cancel runs any cleanup functions that have been registered for this Ctx
Expand Down Expand Up @@ -99,13 +103,19 @@ type PostProcessRenderer interface {
NeedPostProcess() bool
}

// PostProcessRenderer defines an interface for external renderers
// 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

// IframeSandbox represents iframe sandbox attribute for the <iframe> tag
IframeSandbox() string

// ExternalCSP represents the Content-Security-Policy header for external render
ExternalCSP() string
}

// RendererContentDetector detects if the content can be rendered
Expand Down Expand Up @@ -152,14 +162,41 @@ func DetectRendererType(filename string, input io.Reader) string {
return ""
}

// GetRenderer returned the renderer according type or relative path
func GetRenderer(renderType, relativePath string) (Renderer, error) {
if renderType != "" {
if renderer, ok := renderers[renderType]; ok {
return renderer, nil
}
// FIXME: is it correct? if it returns here, then relativePath won't take effect
return nil, ErrUnsupportedRenderType{renderType}
}

if relativePath != "" {
extension := strings.ToLower(filepath.Ext(relativePath))
if renderer, ok := extRenderers[extension]; ok {
return renderer, nil
}
return nil, ErrUnsupportedRenderExtension{extension}
}

return nil, errors.New("render options both filename and type missing")
}

// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
if ctx.Type != "" {
return renderByType(ctx, input, output)
} else if ctx.RelativePath != "" {
return renderFile(ctx, input, output)
renderer, err := GetRenderer(ctx.Type, ctx.RelativePath)
if err != nil {
return err
}

if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
// 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, r.IframeSandbox())
}
return errors.New("Render options both filename and type missing")

return RenderDirect(ctx, renderer, input, output)
}

// RenderString renders Markup string to HTML with all specific handling stuff and return string
Expand All @@ -177,7 +214,7 @@ type nopCloser struct {

func (nopCloser) Close() error { return nil }

func renderIFrame(ctx *RenderContext, output io.Writer) error {
func renderIFrame(ctx *RenderContext, output io.Writer, iframeSandbox string) 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
Expand All @@ -187,18 +224,27 @@ func renderIFrame(ctx *RenderContext, output io.Writer) error {
name="giteaExternalRender"
onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
sandbox="allow-scripts"
sandbox="%s"
></iframe>`,
setting.AppSubURL,
url.PathEscape(ctx.Metas["user"]),
url.PathEscape(ctx.Metas["repo"]),
ctx.Metas["BranchNameSubURL"],
url.PathEscape(ctx.RelativePath),
iframeSandbox,
))
return err
}

func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
// RenderDirect renders markup file to HTML with all specific handling stuff.
func RenderDirect(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
// to prevent from rendering external JS into embedded page unexpectedly, which would lead to XSS attack
if !ctx.InStandalonePage {
return errors.New("external render with iframe can only render in standalone page")
}
}

var wg sync.WaitGroup
var err error
pr, pw := io.Pipe()
Expand Down Expand Up @@ -262,13 +308,6 @@ func (err ErrUnsupportedRenderType) Error() string {
return fmt.Sprintf("Unsupported render type: %s", err.Type)
}

func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error {
if renderer, ok := renderers[ctx.Type]; ok {
return render(ctx, renderer, input, output)
}
return ErrUnsupportedRenderType{ctx.Type}
}

// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render
type ErrUnsupportedRenderExtension struct {
Extension string
Expand All @@ -278,21 +317,6 @@ func (err ErrUnsupportedRenderExtension) Error() string {
return fmt.Sprintf("Unsupported render extension: %s", err.Extension)
}

func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
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}
}

// Type returns if markup format via the filename
func Type(filename string) string {
if parser := GetRendererByFileName(filename); parser != nil {
Expand Down
35 changes: 20 additions & 15 deletions modules/setting/markup.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ const (

// MarkupRenderer defines the external parser configured in ini
type MarkupRenderer struct {
Enabled bool
MarkupName string
Command string
FileExtensions []string
IsInputFile bool
NeedPostProcess bool
MarkupSanitizerRules []MarkupSanitizerRule
RenderContentMode string
Enabled bool
MarkupName string
Command string
FileExtensions []string
IsInputFile bool
NeedPostProcess bool
MarkupSanitizerRules []MarkupSanitizerRule
RenderContentMode string
RenderContentIframeSandbox string
RenderContentExternalCSP string
}

// MarkupSanitizerRule defines the policy for whitelisting attributes on
Expand Down Expand Up @@ -158,6 +160,7 @@ func newMarkupRenderer(name string, sec *ini.Section) {
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 {
Expand All @@ -166,12 +169,14 @@ func newMarkupRenderer(name string, sec *ini.Section) {
}

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),
RenderContentMode: renderContentMode,
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,
RenderContentIframeSandbox: sec.Key("RENDER_CONTENT_IFRAME_SANDBOX").MustString("allow-scripts"),
RenderContentExternalCSP: sec.Key("RENDER_CONTENT_EXTERNAL_CSP").MustString("iframe-src 'self'; sandbox allow-scripts"),
})
}
51 changes: 22 additions & 29 deletions routers/web/repo/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,16 @@
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
// RenderFile uses an external render to render a file by repos path
func RenderFile(ctx *context.Context) {
blob, err := ctx.Repo.Commit.GetBlobByPath(ctx.Repo.TreePath)
if err != nil {
Expand All @@ -37,43 +33,40 @@ func RenderFile(ctx *context.Context) {
}
defer dataRc.Close()

buf := make([]byte, 1024)
n, _ := util.ReadAtMost(dataRc, buf)
buf = buf[:n]

st := typesniffer.DetectContentType(buf)
isTextFile := st.IsText()
treeLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
}

rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc))
renderer, err := markup.GetRenderer("", ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("GetRenderer", err)
return
}

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")
externalRender, ok := renderer.(markup.ExternalRenderer)
if !ok {
ctx.Error(http.StatusBadRequest, "External render only")
return
}

treeLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
externalCSP := externalRender.ExternalCSP()
if externalCSP == "" {
ctx.Error(http.StatusBadRequest, "External render must have valid Content-Security-Header")
return
}

ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts")
err = markup.Render(&markup.RenderContext{
ctx.Resp.Header().Add("Content-Security-Policy", externalCSP)

if err = markup.RenderDirect(&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)
}, renderer, dataRc, ctx.Resp); err != nil {
ctx.ServerError("RenderDirect", err)
return
}
}