Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion modules/markup/internal/finalprocessor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
37 changes: 33 additions & 4 deletions modules/markup/internal/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}{
Expand All @@ -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))
Expand All @@ -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"))
Expand All @@ -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())
})
}
8 changes: 4 additions & 4 deletions modules/markup/internal/renderinternal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 29 additions & 17 deletions modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -164,23 +166,28 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
}

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,
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),
)

defaultWidth := "100%"
defaultHeight := "300"

// ATTENTION! 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
iframe := htmlutil.HTMLFormat(`
<iframe data-src="%s"
class="external-render-iframe"
sandbox="allow-scripts allow-popups"
width="%s" height="%s"
></iframe>
`,
src, defaultWidth, defaultHeight)

_, err := io.WriteString(output, string(iframe))
return err
}

Expand All @@ -193,21 +200,26 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
}

func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var extraHeadHTML template.HTML
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.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)
}
// 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
if ctx.RenderHelper != nil {
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
Expand Down
2 changes: 1 addition & 1 deletion routers/web/repo/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func RenderFile(ctx *context.Context) {
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")
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts allow-popups")

if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType == "" {
if isTextFile {
Expand Down
7 changes: 4 additions & 3 deletions tests/integration/markup_external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ func TestExternalMarkupRenderer(t *testing.T) {
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", ""))
assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("data-src", ""))

req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy"))
assert.Equal(t, "<div>\n\ttest external renderer\n</div>\n", string(bs))
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", resp.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", string(bs))
})
}
1 change: 1 addition & 0 deletions web_src/css/standalone/external-render-iframe.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* dummy */
2 changes: 2 additions & 0 deletions web_src/js/markup/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {initMarkupCodeCopy} from './codecopy.ts';
import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initMarkupRenderIframe} from './render-iframe.ts';

// code that runs for all markup content
export function initMarkupContent(): void {
Expand All @@ -13,5 +14,6 @@ export function initMarkupContent(): void {
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
initMarkupRenderIframe(el);
});
}
32 changes: 32 additions & 0 deletions web_src/js/markup/render-iframe.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
});

let urlParams = '';
urlParams += `&gitea-is-dark-theme=${isDarkTheme()}`;
urlParams += `&gitea-iframe-id=${iframe.id}`;
iframe.src = iframeSrcUrl + (iframeSrcUrl.includes('?') ? '&' : '?') + urlParams.substring(1);
}

export function initMarkupRenderIframe(el: HTMLElement) {
queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent);
}
40 changes: 40 additions & 0 deletions web_src/js/standalone/external-render-iframe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* 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>'`

*/

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();
4 changes: 4 additions & 0 deletions webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export default {
fileURLToPath(new URL('web_src/js/standalone/swagger.ts', 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': [
fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
],
Expand Down
Loading