Skip to content

Commit dee02c7

Browse files
committed
fix external render iframe
1 parent 50ed07e commit dee02c7

File tree

11 files changed

+156
-28
lines changed

11 files changed

+156
-28
lines changed

modules/markup/internal/finalprocessor.go

Lines changed: 27 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,30 @@ 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 _, err := p.output.Write(part1); err != nil {
49+
return err
50+
}
51+
if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil {
52+
return err
53+
}
54+
_, err := p.output.Write(part2)
2955
return err
3056
}

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: 29 additions & 17 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"
@@ -164,23 +166,28 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
164166
}
165167

166168
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,
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+
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+
190+
_, err := io.WriteString(output, string(iframe))
184191
return err
185192
}
186193

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

195202
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
203+
var extraHeadHTML template.HTML
196204
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
197205
if !ctx.RenderOptions.InStandalonePage {
198206
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
199207
// otherwise, a <iframe> should be outputted to embed the external rendered page
200208
return renderIFrame(ctx, output)
201209
}
202-
// else: this is a standalone page, fallthrough to the real rendering
210+
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
211+
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css"
212+
extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js"
213+
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
214+
extraHeadHTML = htmlutil.HTMLFormat(`<script src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
203215
}
204216

205217
ctx.usedByRender = true
206218
if ctx.RenderHelper != nil {
207219
defer ctx.RenderHelper.CleanUp()
208220
}
209221

210-
finalProcessor := ctx.RenderInternal.Init(output)
222+
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
211223
defer finalProcessor.Close()
212224

213225
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output

routers/web/repo/render.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func RenderFile(ctx *context.Context) {
5252
isTextFile := st.IsText()
5353

5454
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")
55+
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts allow-popups")
5656

5757
if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType == "" {
5858
if isTextFile {

tests/integration/markup_external_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
7575
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
7676
bs, err := io.ReadAll(resp.Body)
7777
assert.NoError(t, err)
78-
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy"))
78+
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", resp.Header().Get("Content-Security-Policy"))
7979
assert.Equal(t, "<div>\n\ttest external renderer\n</div>\n", string(bs))
8080
})
8181
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* dummy */

web_src/js/markup/content.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {initMarkupCodeCopy} from './codecopy.ts';
44
import {initMarkupRenderAsciicast} from './asciicast.ts';
55
import {initMarkupTasklist} from './tasklist.ts';
66
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
7+
import {initMarkupRenderIframe} from './render-iframe.ts';
78

89
// code that runs for all markup content
910
export function initMarkupContent(): void {
@@ -13,5 +14,6 @@ export function initMarkupContent(): void {
1314
initMarkupCodeMermaid(el);
1415
initMarkupCodeMath(el);
1516
initMarkupRenderAsciicast(el);
17+
initMarkupRenderIframe(el);
1618
});
1719
}

web_src/js/markup/render-iframe.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {generateElemId, queryElemChildren} from '../utils/dom.ts';
2+
import {isDarkTheme} from '../utils.ts';
3+
4+
export async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
5+
const iframeSrcUrl = iframe.getAttribute('data-src');
6+
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
7+
8+
window.addEventListener('message', (e) => {
9+
if (e.data && e.data.giteaIframeCmd === 'resize' && e.data.giteaIframeId === iframe.id) {
10+
iframe.style.height = `${e.data.giteaIframeHeight}px`;
11+
}
12+
});
13+
14+
let urlParams = '';
15+
urlParams += `&gitea-is-dark-theme=${isDarkTheme()}`;
16+
urlParams += `&gitea-iframe-id=${iframe.id}`;
17+
iframe.src = iframeSrcUrl + (iframeSrcUrl.includes('?') ? '&' : '?') + urlParams.substring(1);
18+
}
19+
20+
export function initMarkupRenderIframe(el: HTMLElement) {
21+
queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent);
22+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* To manually test:
2+
3+
[markup.in-iframe]
4+
ENABLED = true
5+
FILE_EXTENSIONS = .in-iframe
6+
RENDER_CONTENT_MODE = iframe
7+
RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px solid red; box-sizing: border-box;">content</div>'`
8+
9+
*/
10+
11+
function mainExternalRenderIframe() {
12+
const u = new URL(window.location.href);
13+
const fn = () => window.parent.postMessage({
14+
giteaIframeCmd: 'resize',
15+
giteaIframeId: u.searchParams.get('gitea-iframe-id'),
16+
giteaIframeHeight: document.documentElement.scrollHeight,
17+
}, '*');
18+
fn();
19+
window.addEventListener('DOMContentLoaded', fn);
20+
setInterval(fn, 1000);
21+
22+
// make all absolute links open in new window (otherwise they would be blocked by all parents' frame-src)
23+
document.body.addEventListener('click', (e) => {
24+
const el = e.target as HTMLAnchorElement;
25+
if (el.nodeName !== 'A') return;
26+
const href = el.getAttribute('href');
27+
if (!href.startsWith('//') && !href.includes('://')) return;
28+
el.target = '_blank';
29+
}, true);
30+
}
31+
32+
mainExternalRenderIframe();

0 commit comments

Comments
 (0)