mirror of
https://github.com/go-gitea/gitea.git
synced 2025-10-24 09:04:23 +02:00
parent
8085c75356
commit
522c466e24
@ -2540,13 +2540,19 @@ LEVEL = Info
|
|||||||
;; * 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.*] .
|
;; * 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.
|
;; * 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.
|
;; * 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
|
;RENDER_CONTENT_MODE = sanitized
|
||||||
;;
|
;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`.
|
||||||
|
;; It defaults to a safe set of "allow-*" restrictions (space separated).
|
||||||
|
;; You can also set it by your requirements or use "disabled" to disable the sandbox completely.
|
||||||
|
;; When set it, make sure there is no security risk:
|
||||||
|
;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox.
|
||||||
|
;; * 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.
|
||||||
|
;RENDER_CONTENT_SANDBOX =
|
||||||
;; Whether post-process the rendered HTML content, including:
|
;; Whether post-process the rendered HTML content, including:
|
||||||
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
|
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
|
||||||
;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc.
|
;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc.
|
||||||
;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false.
|
;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false.
|
||||||
;NEED_POST_PROCESS=false
|
;NEED_POST_PROCESS = false
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@ -126,6 +126,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
|
|||||||
// no sandbox attribute for pdf as it breaks rendering in at least safari. this
|
// no sandbox attribute for pdf as it breaks rendering in at least safari. this
|
||||||
// should generally be safe as scripts inside PDF can not escape the PDF document
|
// should generally be safe as scripts inside PDF can not escape the PDF document
|
||||||
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
|
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
|
||||||
|
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
modules/markup/external/external.go
vendored
13
modules/markup/external/external.go
vendored
@ -58,14 +58,11 @@ func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
|||||||
return p.MarkupSanitizerRules
|
return p.MarkupSanitizerRules
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizerDisabled disabled sanitize if return true
|
func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
|
||||||
func (p *Renderer) SanitizerDisabled() bool {
|
ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
|
||||||
return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
|
ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe
|
||||||
}
|
ret.ContentSandbox = p.RenderContentSandbox
|
||||||
|
return ret
|
||||||
// DisplayInIFrame represents whether render the content with an iframe
|
|
||||||
func (p *Renderer) DisplayInIFrame() bool {
|
|
||||||
return p.RenderContentMode == setting.RenderContentModeIframe
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func envMark(envName string) string {
|
func envMark(envName string) string {
|
||||||
|
|||||||
@ -5,11 +5,13 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
type finalProcessor struct {
|
type finalProcessor struct {
|
||||||
renderInternal *RenderInternal
|
renderInternal *RenderInternal
|
||||||
|
extraHeadHTML template.HTML
|
||||||
|
|
||||||
output io.Writer
|
output io.Writer
|
||||||
buf bytes.Buffer
|
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.
|
// because "postProcess" already does so. In the future we could optimize the code to process data on the fly.
|
||||||
buf := p.buf.Bytes()
|
buf := p.buf.Bytes()
|
||||||
buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`))
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderInternal(t *testing.T) {
|
func TestRenderInternalAttrs(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
input, protected, recovered string
|
input, protected, recovered string
|
||||||
}{
|
}{
|
||||||
@ -30,7 +30,7 @@ func TestRenderInternal(t *testing.T) {
|
|||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
var r RenderInternal
|
var r RenderInternal
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
in := r.init("sec", out)
|
in := r.init("sec", out, "")
|
||||||
protected := r.ProtectSafeAttrs(template.HTML(c.input))
|
protected := r.ProtectSafeAttrs(template.HTML(c.input))
|
||||||
assert.EqualValues(t, c.protected, protected)
|
assert.EqualValues(t, c.protected, protected)
|
||||||
_, _ = io.WriteString(in, string(protected))
|
_, _ = io.WriteString(in, string(protected))
|
||||||
@ -41,7 +41,7 @@ func TestRenderInternal(t *testing.T) {
|
|||||||
var r1, r2 RenderInternal
|
var r1, r2 RenderInternal
|
||||||
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
|
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
|
||||||
assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes")
|
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>`)
|
protected = r1.ProtectSafeAttrs(`<div class="test"></div>`)
|
||||||
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
|
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
|
||||||
assert.Equal(t, "data-attr-class", r1.SafeAttr("class"))
|
assert.Equal(t, "data-attr-class", r1.SafeAttr("class"))
|
||||||
@ -54,8 +54,37 @@ func TestRenderInternal(t *testing.T) {
|
|||||||
assert.Empty(t, recovered)
|
assert.Empty(t, recovered)
|
||||||
|
|
||||||
out2 := &bytes.Buffer{}
|
out2 := &bytes.Buffer{}
|
||||||
in2 := r2.init("sec-other", out2)
|
in2 := r2.init("sec-other", out2, "")
|
||||||
_, _ = io.WriteString(in2, string(protected))
|
_, _ = io.WriteString(in2, string(protected))
|
||||||
_ = in2.Close()
|
_ = in2.Close()
|
||||||
assert.Equal(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value")
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -29,19 +29,19 @@ type RenderInternal struct {
|
|||||||
secureIDPrefix string
|
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)
|
buf := make([]byte, 12)
|
||||||
_, err := rand.Read(buf)
|
_, err := rand.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("unable to generate secure id")
|
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.secureID = secID
|
||||||
r.secureIDPrefix = r.secureID + ":"
|
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) {
|
func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) {
|
||||||
|
|||||||
@ -6,12 +6,14 @@ package markup
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/htmlutil"
|
||||||
"code.gitea.io/gitea/modules/markup/internal"
|
"code.gitea.io/gitea/modules/markup/internal"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
@ -163,24 +165,20 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
|
|||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderIFrame(ctx *RenderContext, output io.Writer) error {
|
func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
|
||||||
// set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
|
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
|
||||||
// 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.RenderOptions.Metas["user"]),
|
url.PathEscape(ctx.RenderOptions.Metas["user"]),
|
||||||
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
|
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
|
||||||
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
|
||||||
url.PathEscape(ctx.RenderOptions.RelativePath),
|
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
|
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 {
|
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 {
|
if !ctx.RenderOptions.InStandalonePage {
|
||||||
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
|
// 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
|
// 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
|
ctx.usedByRender = true
|
||||||
@ -207,7 +217,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
|
|||||||
defer ctx.RenderHelper.CleanUp()
|
defer ctx.RenderHelper.CleanUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
finalProcessor := ctx.RenderInternal.Init(output)
|
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
|
||||||
defer finalProcessor.Close()
|
defer finalProcessor.Close()
|
||||||
|
|
||||||
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
|
// 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)
|
eg, _ := errgroup.WithContext(ctx)
|
||||||
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
|
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 pr2 io.ReadCloser
|
||||||
var close2 func()
|
var close2 func()
|
||||||
pr2, pw2, close2 = pipes()
|
pr2, pw2, close2 = pipes()
|
||||||
|
|||||||
@ -25,13 +25,15 @@ type PostProcessRenderer interface {
|
|||||||
NeedPostProcess() bool
|
NeedPostProcess() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExternalRendererOptions struct {
|
||||||
|
SanitizerDisabled bool
|
||||||
|
DisplayInIframe bool
|
||||||
|
ContentSandbox string
|
||||||
|
}
|
||||||
|
|
||||||
// ExternalRenderer defines an interface for external renderers
|
// ExternalRenderer defines an interface for external renderers
|
||||||
type ExternalRenderer interface {
|
type ExternalRenderer interface {
|
||||||
// SanitizerDisabled disabled sanitize if return true
|
GetExternalRendererOptions() ExternalRendererOptions
|
||||||
SanitizerDisabled() bool
|
|
||||||
|
|
||||||
// DisplayInIFrame represents whether render the content with an iframe
|
|
||||||
DisplayInIFrame() bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RendererContentDetector detects if the content can be rendered
|
// RendererContentDetector detects if the content can be rendered
|
||||||
|
|||||||
@ -63,6 +63,7 @@ type MarkupRenderer struct {
|
|||||||
NeedPostProcess bool
|
NeedPostProcess bool
|
||||||
MarkupSanitizerRules []MarkupSanitizerRule
|
MarkupSanitizerRules []MarkupSanitizerRule
|
||||||
RenderContentMode string
|
RenderContentMode string
|
||||||
|
RenderContentSandbox string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkupSanitizerRule defines the policy for whitelisting attributes on
|
// MarkupSanitizerRule defines the policy for whitelisting attributes on
|
||||||
@ -253,13 +254,22 @@ func newMarkupRenderer(name string, sec ConfigSection) {
|
|||||||
renderContentMode = RenderContentModeSanitized
|
renderContentMode = RenderContentModeSanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ATTENTION! at the moment, only a safe set like "allow-scripts" are 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
|
||||||
|
renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString("allow-scripts allow-popups")
|
||||||
|
if renderContentSandbox == "disabled" {
|
||||||
|
renderContentSandbox = ""
|
||||||
|
}
|
||||||
|
|
||||||
ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
|
ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
|
||||||
Enabled: sec.Key("ENABLED").MustBool(false),
|
Enabled: sec.Key("ENABLED").MustBool(false),
|
||||||
MarkupName: name,
|
MarkupName: name,
|
||||||
FileExtensions: exts,
|
FileExtensions: exts,
|
||||||
Command: command,
|
Command: command,
|
||||||
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
|
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
|
||||||
RenderContentMode: renderContentMode,
|
|
||||||
|
RenderContentMode: renderContentMode,
|
||||||
|
RenderContentSandbox: renderContentSandbox,
|
||||||
|
|
||||||
// if no sanitizer is needed, no post process is needed
|
// if no sanitizer is needed, no post process is needed
|
||||||
NeedPostProcess: sec.Key("NEED_POST_PROCESS").MustBool(renderContentMode == RenderContentModeSanitized),
|
NeedPostProcess: sec.Key("NEED_POST_PROCESS").MustBool(renderContentMode == RenderContentModeSanitized),
|
||||||
|
|||||||
@ -4,18 +4,13 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/renderhelper"
|
"code.gitea.io/gitea/models/renderhelper"
|
||||||
"code.gitea.io/gitea/modules/charset"
|
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/markup"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
"code.gitea.io/gitea/modules/typesniffer"
|
|
||||||
"code.gitea.io/gitea/modules/util"
|
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -44,22 +39,8 @@ func RenderFile(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
defer dataRc.Close()
|
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), charset.ConvertOpts{})
|
|
||||||
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts")
|
|
||||||
|
|
||||||
if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType == "" {
|
if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType == "" {
|
||||||
if isTextFile {
|
http.Error(ctx.Resp, "Unsupported file type render", http.StatusBadRequest)
|
||||||
_, _ = io.Copy(ctx.Resp, rd)
|
|
||||||
} else {
|
|
||||||
http.Error(ctx.Resp, "Unsupported file type render", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +49,29 @@ func RenderFile(ctx *context.Context) {
|
|||||||
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
|
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
|
||||||
}).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true)
|
}).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true)
|
||||||
|
|
||||||
err = markup.Render(rctx, rd, ctx.Resp)
|
renderer, err := markup.FindRendererByContext(rctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extRenderer, ok := renderer.(markup.ExternalRenderer)
|
||||||
|
if !ok {
|
||||||
|
http.Error(ctx.Resp, "Unable to get external renderer", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// To render PDF in iframe, the sandbox must NOT be used (iframe & CSP header).
|
||||||
|
// Chrome blocks the PDF rendering when sandboxed, even if all "allow-*" are set.
|
||||||
|
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
|
||||||
|
extRendererOpts := extRenderer.GetExternalRendererOptions()
|
||||||
|
if extRendererOpts.ContentSandbox != "" {
|
||||||
|
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox)
|
||||||
|
} else {
|
||||||
|
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = markup.RenderWithRenderer(rctx, renderer, dataRc, ctx.Resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err)
|
log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err)
|
||||||
http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError)
|
http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError)
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@ -31,12 +30,12 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
|
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
|
||||||
t.Run("RenderNoSanitizer", func(t *testing.T) {
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
_, err := createFile(user2, repo1, "file.no-sanitizer", "master", `any content`)
|
||||||
_, err := createFile(user2, repo1, "file.no-sanitizer", "master", `any content`)
|
require.NoError(t, err)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
|
t.Run("RenderNoSanitizer", func(t *testing.T) {
|
||||||
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
|
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
doc := NewHTMLParser(t, resp.Body)
|
doc := NewHTMLParser(t, resp.Body)
|
||||||
@ -59,23 +58,50 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
|||||||
assert.Equal(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
|
assert.Equal(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// above tested "no-sanitizer" mode, then we test iframe mode below
|
||||||
r := markup.GetRendererByFileName("any-file.html").(*external.Renderer)
|
r := markup.GetRendererByFileName("any-file.html").(*external.Renderer)
|
||||||
defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
|
defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
|
||||||
|
r = markup.GetRendererByFileName("any-file.no-sanitizer").(*external.Renderer)
|
||||||
|
defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
|
||||||
|
|
||||||
t.Run("RenderContentInIFrame", func(t *testing.T) {
|
t.Run("RenderContentInIFrame", func(t *testing.T) {
|
||||||
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
|
t.Run("DefaultSandbox", func(t *testing.T) {
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
|
||||||
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
|
|
||||||
doc := NewHTMLParser(t, resp.Body)
|
|
||||||
iframe := doc.Find("iframe")
|
|
||||||
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("src", ""))
|
|
||||||
|
|
||||||
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
|
t.Run("ParentPage", func(t *testing.T) {
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
respParent := MakeRequest(t, req, http.StatusOK)
|
||||||
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
|
assert.Equal(t, "text/html; charset=utf-8", respParent.Header().Get("Content-Type"))
|
||||||
bs, err := io.ReadAll(resp.Body)
|
|
||||||
assert.NoError(t, err)
|
iframe := NewHTMLParser(t, respParent.Body).Find("iframe.external-render-iframe")
|
||||||
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy"))
|
assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead
|
||||||
assert.Equal(t, "<div>\n\ttest external renderer\n</div>\n", string(bs))
|
|
||||||
|
// default sandbox on parent page
|
||||||
|
assert.Equal(t, "allow-scripts allow-popups", iframe.AttrOr("sandbox", ""))
|
||||||
|
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("data-src", ""))
|
||||||
|
})
|
||||||
|
t.Run("SubPage", func(t *testing.T) {
|
||||||
|
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
|
||||||
|
respSub := MakeRequest(t, req, http.StatusOK)
|
||||||
|
assert.Equal(t, "text/html; charset=utf-8", respSub.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
// default sandbox in sub page response
|
||||||
|
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoSanitizerNoSandbox", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
|
||||||
|
respParent := MakeRequest(t, req, http.StatusOK)
|
||||||
|
iframe := NewHTMLParser(t, respParent.Body).Find("iframe.external-render-iframe")
|
||||||
|
assert.Equal(t, "/user2/repo1/render/branch/master/file.no-sanitizer", iframe.AttrOr("data-src", ""))
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/file.no-sanitizer")
|
||||||
|
respSub := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// no sandbox (disabled by RENDER_CONTENT_SANDBOX)
|
||||||
|
assert.Empty(t, iframe.AttrOr("sandbox", ""))
|
||||||
|
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,7 +123,10 @@ RENDER_CONTENT_MODE = sanitized
|
|||||||
ENABLED = true
|
ENABLED = true
|
||||||
FILE_EXTENSIONS = .no-sanitizer
|
FILE_EXTENSIONS = .no-sanitizer
|
||||||
RENDER_COMMAND = echo '<script>window.alert("hi")</script>'
|
RENDER_COMMAND = echo '<script>window.alert("hi")</script>'
|
||||||
|
; This test case is reused, at first it is used to test "no-sanitizer" (sandbox doesn't take effect here)
|
||||||
|
; Then it will be updated and used to test "iframe + sandbox-disabled"
|
||||||
RENDER_CONTENT_MODE = no-sanitizer
|
RENDER_CONTENT_MODE = no-sanitizer
|
||||||
|
RENDER_CONTENT_SANDBOX = disabled
|
||||||
|
|
||||||
[actions]
|
[actions]
|
||||||
ENABLED = true
|
ENABLED = true
|
||||||
|
|||||||
@ -545,6 +545,11 @@ In markup content, we always use bottom margin for all elements */
|
|||||||
margin: 0 0.25em;
|
margin: 0 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-render-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: max(300px, 80vh);
|
||||||
|
}
|
||||||
|
|
||||||
.markup-content-iframe {
|
.markup-content-iframe {
|
||||||
display: block;
|
display: block;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
1
web_src/css/standalone/external-render-iframe.css
Normal file
1
web_src/css/standalone/external-render-iframe.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* dummy */
|
||||||
@ -4,6 +4,7 @@ import {initMarkupCodeCopy} from './codecopy.ts';
|
|||||||
import {initMarkupRenderAsciicast} from './asciicast.ts';
|
import {initMarkupRenderAsciicast} from './asciicast.ts';
|
||||||
import {initMarkupTasklist} from './tasklist.ts';
|
import {initMarkupTasklist} from './tasklist.ts';
|
||||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||||
|
import {initMarkupRenderIframe} from './render-iframe.ts';
|
||||||
|
|
||||||
// code that runs for all markup content
|
// code that runs for all markup content
|
||||||
export function initMarkupContent(): void {
|
export function initMarkupContent(): void {
|
||||||
@ -13,5 +14,6 @@ export function initMarkupContent(): void {
|
|||||||
initMarkupCodeMermaid(el);
|
initMarkupCodeMermaid(el);
|
||||||
initMarkupCodeMath(el);
|
initMarkupCodeMath(el);
|
||||||
initMarkupRenderAsciicast(el);
|
initMarkupRenderAsciicast(el);
|
||||||
|
initMarkupRenderIframe(el);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
32
web_src/js/markup/render-iframe.ts
Normal file
32
web_src/js/markup/render-iframe.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import {generateElemId, queryElemChildren} from '../utils/dom.ts';
|
||||||
|
import {isDarkTheme} from '../utils.ts';
|
||||||
|
|
||||||
|
export async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
|
||||||
|
const iframeSrcUrl = iframe.getAttribute('data-src');
|
||||||
|
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
|
||||||
|
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return;
|
||||||
|
const cmd = e.data.giteaIframeCmd;
|
||||||
|
if (cmd === 'resize') {
|
||||||
|
iframe.style.height = `${e.data.iframeHeight}px`;
|
||||||
|
} else if (cmd === 'open-link') {
|
||||||
|
if (e.data.anchorTarget === '_blank') {
|
||||||
|
window.open(e.data.openLink, '_blank');
|
||||||
|
} else {
|
||||||
|
window.location.href = e.data.openLink;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown gitea iframe cmd: ${cmd}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const u = new URL(iframeSrcUrl, window.location.origin);
|
||||||
|
u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme()));
|
||||||
|
u.searchParams.set('gitea-iframe-id', iframe.id);
|
||||||
|
iframe.src = u.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMarkupRenderIframe(el: HTMLElement) {
|
||||||
|
queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent);
|
||||||
|
}
|
||||||
43
web_src/js/standalone/external-render-iframe.ts
Normal file
43
web_src/js/standalone/external-render-iframe.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/* To manually test:
|
||||||
|
|
||||||
|
[markup.in-iframe]
|
||||||
|
ENABLED = true
|
||||||
|
FILE_EXTENSIONS = .in-iframe
|
||||||
|
RENDER_CONTENT_MODE = iframe
|
||||||
|
RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px solid red; box-sizing: border-box;"><a href="/">a link</a> <a target="_blank" href="//gitea.com">external link</a></div>'`
|
||||||
|
|
||||||
|
;RENDER_COMMAND = cat /path/to/file.pdf
|
||||||
|
;RENDER_CONTENT_SANDBOX = disabled
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
function mainExternalRenderIframe() {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
const iframeId = u.searchParams.get('gitea-iframe-id');
|
||||||
|
|
||||||
|
// iframe is in different origin, so we need to use postMessage to communicate
|
||||||
|
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
|
||||||
|
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIframeHeight = () => postIframeMsg('resize', {iframeHeight: document.documentElement.scrollHeight});
|
||||||
|
updateIframeHeight();
|
||||||
|
window.addEventListener('DOMContentLoaded', updateIframeHeight);
|
||||||
|
// the easiest way to handle dynamic content changes and easy to debug, can be fine-tuned in the future
|
||||||
|
setInterval(updateIframeHeight, 1000);
|
||||||
|
|
||||||
|
// no way to open an absolute link with CSP frame-src, it also needs some tricks like "postMessage" or "copy the link to clipboard"
|
||||||
|
const openIframeLink = (link: string, target: string) => postIframeMsg('open-link', {openLink: link, anchorTarget: target});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const el = e.target as HTMLAnchorElement;
|
||||||
|
if (el.nodeName !== 'A') return;
|
||||||
|
const href = el.getAttribute('href') || '';
|
||||||
|
// safe links: "./any", "../any", "/any", "//host/any", "http://host/any", "https://host/any"
|
||||||
|
if (href.startsWith('.') || href.startsWith('/') || href.startsWith('http://') || href.startsWith('https://')) {
|
||||||
|
e.preventDefault();
|
||||||
|
openIframeLink(href, el.getAttribute('target'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mainExternalRenderIframe();
|
||||||
@ -75,6 +75,10 @@ export default {
|
|||||||
fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)),
|
fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)),
|
||||||
fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
|
fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
|
||||||
],
|
],
|
||||||
|
'external-render-iframe': [
|
||||||
|
fileURLToPath(new URL('web_src/js/standalone/external-render-iframe.ts', import.meta.url)),
|
||||||
|
fileURLToPath(new URL('web_src/css/standalone/external-render-iframe.css', import.meta.url)),
|
||||||
|
],
|
||||||
'eventsource.sharedworker': [
|
'eventsource.sharedworker': [
|
||||||
fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
|
fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user