fix: Fix invisible iframes with RENDER_CONTENT_MODE=iframe (#8378)

b01dce2a6e 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
<!DOCTYPE html>

<html lang="en">
<head>
<meta charset="utf-8"/>
</head>
<body>
Hi from iframe!
Here is a random number: <script>document.write(Math.random())</script>.
</body>
</html>
```
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 <gusted@noreply.codeberg.org>
Co-authored-by: Bojidar Marinov <bojidar.marinov.bg@gmail.com>
Co-committed-by: Bojidar Marinov <bojidar.marinov.bg@gmail.com>
This commit is contained in:
Bojidar Marinov 2025-09-06 16:23:01 +02:00 committed by Gusted
commit 81d90e1b0d
3 changed files with 27 additions and 4 deletions

View file

@ -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(`
<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"
class="external-render"
width="100%%" height="300" frameborder="0"
sandbox="allow-scripts"
></iframe>`,
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("<script>{let postHeight = () => {window.parent.postMessage({frameHeight: document.documentElement.offsetHeight}, '*')}; window.addEventListener('load', postHeight); window.addEventListener('message', (event) => {if (event.source === window.parent && event.data.requestOffsetHeight) postHeight()});}</script>"))
}
_ = pw.Close()
wg.Wait()

View file

@ -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

View file

@ -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);
}