From 81d90e1b0d42b8d00235d2b98915d42300f8e152 Mon Sep 17 00:00:00 2001 From: Bojidar Marinov Date: Sat, 6 Sep 2025 16:23:01 +0200 Subject: [PATCH] fix: Fix invisible iframes with RENDER_CONTENT_MODE=iframe (#8378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b01dce2a6e98c25915a8e98afb741a1c34d05aba added support for `RENDER_CONTENT_MODE=iframe` which used `onload="this.height=this.contentWindow.document.documentElement.scrollHeight"` to set the height of the iframe to the height of the embedded document. Unfortunately, while this might have worked at some point, with `sandbox="allow-scripts"`, the document embedded in the iframe is counted as a cross-origin document, and browsers prevent any access to cross-origin documents. [The solution](https://stackoverflow.com/questions/8223239/how-to-get-height-of-iframe-cross-domain) is to instead use `window.postMessage` to pass the height from the embedded document back to the embedding page. Would appreciate a review of the privacy implications of this change—I feel it's probably "okay", but I'm not convinced my analysis is perfect. Resolves #7586 Manual test: 1. Add the following snippet to your `app.ini`: ```ini [markup.html] ENABLED = true FILE_EXTENSIONS = .html RENDER_COMMAND = cat RENDER_CONTENT_MODE = iframe NEED_POSTPROCESS = false ``` 2. Create a file in a repository with the name `test.html` and with the following contents: ```html Hi from iframe! Here is a random number: . ``` 3. Go to the file. 4. Observe the HTML is rendered and that the height is not larger than it needs to be (38 pixels). Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8378 Reviewed-by: Gusted Co-authored-by: Bojidar Marinov Co-committed-by: Bojidar Marinov --- modules/markup/renderer.go | 13 +++++++++---- web_src/js/markup/content.js | 2 ++ web_src/js/markup/external.js | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 web_src/js/markup/external.js diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index a622d75085..08502e12ab 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -248,15 +248,14 @@ type nopCloser struct { func (nopCloser) Close() error { return nil } func renderIFrame(ctx *RenderContext, output io.Writer) error { - // set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight) + // set height="300", otherwise if the postMessage mechanism breaks, we are left with a 0-height iframe // 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(` `, setting.AppSubURL, @@ -317,6 +316,12 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr if err1 := renderer.Render(ctx, input, pw); err1 != nil { return err1 } + + if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() { + // Append a short script to the iframe's contents, which will communicate the scroll height of the embedded document via postMessage, either once loaded (in case the containing page loads first) in response to a postMessage from external.js, in case the iframe loads first + // We use '*' as a target origin for postMessage, because can be certain we are embedded on the same domain, due to X-Frame-Options configured elsewhere. (Plus, the offsetHeight of an embedded document is likely not sensitive data anyway.) + _, _ = pw.Write([]byte("")) + } _ = pw.Close() wg.Wait() diff --git a/web_src/js/markup/content.js b/web_src/js/markup/content.js index 1d29dc07f2..349727fbe2 100644 --- a/web_src/js/markup/content.js +++ b/web_src/js/markup/content.js @@ -2,6 +2,7 @@ import {renderMermaid} from './mermaid.js'; import {renderMath} from './math.js'; import {renderCodeCopy} from './codecopy.js'; import {renderAsciicast} from './asciicast.js'; +import {renderExternal} from './external.js'; import {initMarkupTasklist} from './tasklist.js'; // code that runs for all markup content @@ -10,6 +11,7 @@ export function initMarkupContent() { renderMath(); renderCodeCopy(); renderAsciicast(); + renderExternal(); } // code that only runs for comments diff --git a/web_src/js/markup/external.js b/web_src/js/markup/external.js new file mode 100644 index 0000000000..cae0e7488d --- /dev/null +++ b/web_src/js/markup/external.js @@ -0,0 +1,16 @@ +export function renderExternal() { + const giteaExternalRender = document.querySelector('iframe.external-render'); + if (!giteaExternalRender) return; + + giteaExternalRender.contentWindow.postMessage({requestOffsetHeight: true}, '*'); + + const eventListener = (event) => { + if (event.source !== giteaExternalRender.contentWindow) return; + const height = Number(event.data?.frameHeight); + if (!height) return; + giteaExternalRender.height = height; + giteaExternalRender.style.overflow = 'hidden'; + window.removeEventListener('message', eventListener); + }; + window.addEventListener('message', eventListener); +}