From 20d9d3bfbc07ddafc7056cadf0cf582ebbb7152a Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 19 Apr 2026 04:36:51 +0200 Subject: [PATCH 1/6] Fix CSP blocking WebAssembly in asciicast renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asciinema-player uses WebAssembly, which is blocked by the main site CSP introduced in #37232 (script-src lacks 'wasm-unsafe-eval'). Moving the renderer into an iframe does not help on its own because srcdoc iframes inherit the parent CSP per CSP3 §4.2.3.6. Two changes: - add 'wasm-unsafe-eval' to script-src so srcdoc iframes can load WASM - convert the asciicast renderer to the existing frontendRenderer iframe pattern (same as 3D viewer and openapi-swagger) for consistency and isolation Fixes #37257 Co-Authored-By: Claude (Opus 4.7) --- main.go | 1 - modules/markup/asciicast/asciicast.go | 49 ------------------- modules/markup/external/external.go | 7 +++ modules/markup/render.go | 2 +- services/context/context_template.go | 4 +- tests/e2e/file-view-render.test.ts | 27 +++++----- types.d.ts | 2 +- web_src/css/index.css | 1 - web_src/css/markup/asciicast.css | 10 ---- web_src/css/repo.css | 4 -- web_src/js/external-render-frontend.ts | 1 + web_src/js/markup/content.ts | 2 - .../plugins/frontend-asciicast.ts} | 17 ++++--- 13 files changed, 37 insertions(+), 90 deletions(-) delete mode 100644 modules/markup/asciicast/asciicast.go delete mode 100644 web_src/css/markup/asciicast.css rename web_src/js/{markup/asciicast.ts => render/plugins/frontend-asciicast.ts} (55%) diff --git a/main.go b/main.go index fcfbb73371535..b5a51ca597d89 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,6 @@ import ( "code.gitea.io/gitea/modules/setting" // register supported doc types - _ "code.gitea.io/gitea/modules/markup/asciicast" _ "code.gitea.io/gitea/modules/markup/console" _ "code.gitea.io/gitea/modules/markup/csv" _ "code.gitea.io/gitea/modules/markup/markdown" diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go deleted file mode 100644 index b3af5eef091f9..0000000000000 --- a/modules/markup/asciicast/asciicast.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package asciicast - -import ( - "fmt" - "io" - "net/url" - - "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/setting" -) - -func init() { - markup.RegisterRenderer(Renderer{}) -} - -// Renderer implements markup.Renderer for asciicast files. -// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md -type Renderer struct{} - -func (Renderer) Name() string { - return "asciicast" -} - -func (Renderer) FileNamePatterns() []string { - return []string{"*.cast"} -} - -const ( - playerClassName = "asciinema-player-container" - playerSrcAttr = "data-asciinema-player-src" -) - -func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { - return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}} -} - -func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error { - rawURL := fmt.Sprintf("%s/%s/%s/raw/%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), - ) - return ctx.RenderInternal.FormatWithSafeAttrs(output, `
`, playerClassName, playerSrcAttr, rawURL) -} diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 4b3c96fd33d2c..807064b354319 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,6 +48,13 @@ func RegisterRenderers() { }, }) + // Asciicast (terminal recording) files are rendered in an iframe because the player uses WebAssembly, + // which is blocked by the main site CSP (no 'wasm-unsafe-eval'). See https://github.com/go-gitea/gitea/issues/37257 + markup.RegisterRenderer(&frontendRenderer{ + name: "asciicast", + patterns: []string{"*.cast"}, + }) + for _, renderer := range setting.ExternalMarkupRenderers { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/modules/markup/render.go b/modules/markup/render.go index 6e8838d49fe7d..a559e1982e60c 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -59,7 +59,7 @@ type RenderOptions struct { MarkupType string // user&repo, format&style®exp (for external issue pattern), teams&org (for mention) - // RefTypeNameSubURL (for iframe&asciicast) + // RefTypeNameSubURL (for iframe render) // markupAllowShortIssuePattern // markdownNewLineHardBreak Metas map[string]string diff --git a/services/context/context_template.go b/services/context/context_template.go index 2b34681faa44c..68193ca55f0b6 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -153,7 +153,9 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML { `default-src * data:;` + // enforce nonce for all scripts, disallow inline scripts - `script-src * 'nonce-` + c.CspScriptNonce() + `';` + + // 'wasm-unsafe-eval' lets srcdoc iframe renderers (e.g. asciinema-player) load WebAssembly; + // srcdoc iframes inherit the parent CSP per CSP3 §4.2.3.6 and a child CSP only narrows, never widens. + `script-src * 'nonce-` + c.CspScriptNonce() + `' 'wasm-unsafe-eval';` + // it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it `style-src * 'unsafe-inline';` + diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index a3afe85b2675a..3f8069fb2bfc0 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; +import {apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; test('3d model file', async ({page, request}) => { const repoName = `e2e-3d-render-${randomString(8)}`; @@ -47,22 +47,23 @@ test('pdf file', async ({page, request}) => { }); test('asciicast file', async ({page, request}) => { - // regression for repo_file.go's RefTypeNameSubURL double-escape: readme.cast on a non-ASCII branch - // is rendered via view_readme.go (no metas override), exposing the bug as a broken player URL + // regression for #37257: the asciinema player uses WebAssembly, blocked by the main site CSP + // (srcdoc iframes inherit the parent CSP, so moving into an iframe alone doesn't help — the + // CSP must grant 'wasm-unsafe-eval'). const repoName = `e2e-asciicast-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; - const branch = '日本語-branch'; - const branchEnc = encodeURIComponent(branch); - await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]); + await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]); try { const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; - await apiCreateFile(request, owner, repoName, 'readme.cast', cast); - await apiCreateBranch(request, owner, repoName, branch); - await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`); - const container = page.locator('.asciinema-player-container'); - await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`); - await expect(container.locator('.ap-wrapper')).toBeVisible(); - expect((await container.boundingBox())!.height).toBeGreaterThan(300); + await apiCreateFile(request, owner, repoName, 'test.cast', cast); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.cast`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('.ap-wrapper')).toBeVisible(); + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); + await assertNoJsError(page); } finally { await apiDeleteRepo(request, owner, repoName); } diff --git a/types.d.ts b/types.d.ts index bdf35428bc62a..b156fc8b77f4c 100644 --- a/types.d.ts +++ b/types.d.ts @@ -50,7 +50,7 @@ declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { declare module 'asciinema-player' { interface AsciinemaPlayer { - create(src: string, element: HTMLElement, options?: Record): void; + create(src: string | {data: string} | {url: string}, element: HTMLElement, options?: Record): void; } const exports: AsciinemaPlayer; export = exports; diff --git a/web_src/css/index.css b/web_src/css/index.css index c23e3e1c19ff8..d4bfb77fe7818 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -53,7 +53,6 @@ @import "./markup/content.css"; @import "./markup/codeblock.css"; @import "./markup/codepreview.css"; -@import "./markup/asciicast.css"; @import "./font_i18n.css"; @import "./base.css"; diff --git a/web_src/css/markup/asciicast.css b/web_src/css/markup/asciicast.css deleted file mode 100644 index a45daaa8e8bca..0000000000000 --- a/web_src/css/markup/asciicast.css +++ /dev/null @@ -1,10 +0,0 @@ -.asciinema-player-container { - width: 100%; - height: auto; -} - -/* Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js :
-Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 */ -.ap-term { - overflow: hidden !important; -} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 51745b4adac9a..341202f2066e9 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -183,10 +183,6 @@ td .commit-summary { overflow: auto; } -.non-diff-file-content .asciicast { - padding: 0 !important; -} - .repo-editor-header { display: flex; margin: 1rem 0; diff --git a/web_src/js/external-render-frontend.ts b/web_src/js/external-render-frontend.ts index 9d969bcf90004..be643fad5a1cd 100644 --- a/web_src/js/external-render-frontend.ts +++ b/web_src/js/external-render-frontend.ts @@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>; const frontendPlugins: Record = { 'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'), 'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'), + 'asciicast': () => import('./render/plugins/frontend-asciicast.ts'), }; class Options implements FrontendRenderOptions { diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index 77ba0eaed4f76..38b470253b4f1 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -1,7 +1,6 @@ import {initMarkupCodeMermaid} from './mermaid.ts'; import {initMarkupCodeMath} from './math.ts'; import {initMarkupCodeCopy} from './codecopy.ts'; -import {initMarkupRenderAsciicast} from './asciicast.ts'; import {initMarkupTasklist} from './tasklist.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; import {initExternalRenderIframe} from './render-iframe.ts'; @@ -25,7 +24,6 @@ export function initMarkupContent(): void { initMarkupTasklist(el); initMarkupCodeMermaid(el); initMarkupCodeMath(el); - initMarkupRenderAsciicast(el); initMarkupRefIssue(el); }); } diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/render/plugins/frontend-asciicast.ts similarity index 55% rename from web_src/js/markup/asciicast.ts rename to web_src/js/render/plugins/frontend-asciicast.ts index 90515e1363cdb..cca9ac14cf901 100644 --- a/web_src/js/markup/asciicast.ts +++ b/web_src/js/render/plugins/frontend-asciicast.ts @@ -1,16 +1,19 @@ -import {queryElems} from '../utils/dom.ts'; +import type {FrontendRenderFunc} from '../plugin.ts'; -export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise { - queryElems(elMarkup, '.asciinema-player-container', async (el) => { +export const frontendRender: FrontendRenderFunc = async (opts): Promise => { + try { const [player] = await Promise.all([ import('asciinema-player'), import('asciinema-player/dist/bundle/asciinema-player.css'), ]); - - player.create(el.getAttribute('data-asciinema-player-src')!, el, { + player.create({data: opts.contentString()}, opts.container, { // poster (a preview frame) to display until the playback is started. // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. poster: 'npt:1:0:0', }); - }); -} + return true; + } catch (error) { + console.error(error); + return false; + } +}; From c8dd3b527481d7ee29ba03d54bfca6d937cf6313 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 19 Apr 2026 04:40:31 +0200 Subject: [PATCH 2/6] Restore .ap-term overflow rule The asciinema-player's .ap-term div still needs overflow: hidden to fix the scrollbar regression from #26159. The rule is now scoped to the iframe via a local CSS import in the plugin. Co-Authored-By: Claude (Opus 4.7) --- web_src/js/render/plugins/frontend-asciicast.css | 5 +++++ web_src/js/render/plugins/frontend-asciicast.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 web_src/js/render/plugins/frontend-asciicast.css diff --git a/web_src/js/render/plugins/frontend-asciicast.css b/web_src/js/render/plugins/frontend-asciicast.css new file mode 100644 index 0000000000000..792504714166b --- /dev/null +++ b/web_src/js/render/plugins/frontend-asciicast.css @@ -0,0 +1,5 @@ +/* Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js :
+Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 */ +.ap-term { + overflow: hidden !important; +} diff --git a/web_src/js/render/plugins/frontend-asciicast.ts b/web_src/js/render/plugins/frontend-asciicast.ts index cca9ac14cf901..dcc533865545e 100644 --- a/web_src/js/render/plugins/frontend-asciicast.ts +++ b/web_src/js/render/plugins/frontend-asciicast.ts @@ -5,6 +5,7 @@ export const frontendRender: FrontendRenderFunc = async (opts): Promise const [player] = await Promise.all([ import('asciinema-player'), import('asciinema-player/dist/bundle/asciinema-player.css'), + import('./frontend-asciicast.css'), ]); player.create({data: opts.contentString()}, opts.container, { // poster (a preview frame) to display until the playback is started. From 1b91a498cf8825bea4f6bab5de6810f1f68fc832 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 19 Apr 2026 18:13:58 +0200 Subject: [PATCH 3/6] Per-renderer iframe SrcMethod option, asciicast opts into "src" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the main-site CSP change. Instead introduces ExternalRendererOptions.SrcMethod ("src" | "srcdoc"/""): - "srcdoc" (default, all other iframe renderers): iframe content is fetched by JS and set via srcdoc; the iframe document inherits the parent page's CSP per CSP3 §4.2.3.6. - "src" (asciicast only): the parent page sets iframe.src and the iframe document uses the response's own CSP — which allows 'wasm-unsafe-eval' for WebAssembly. Isolation is preserved by the iframe sandbox attribute; the main site CSP stays untouched. The e2e test now asserts the cast's rendered "test" text appears inside the player — that only happens if WASM actually loaded. Co-Authored-By: Claude (Opus 4.7) --- modules/markup/external/external.go | 10 ++++++---- modules/markup/external/frontend.go | 6 ++++-- modules/markup/render.go | 3 +++ modules/markup/render_test.go | 3 +++ modules/markup/renderer.go | 9 +++++++++ routers/web/repo/render.go | 13 +++++++++++-- services/context/context_template.go | 4 +--- tests/e2e/file-view-render.test.ts | 14 +++++++++----- web_src/js/markup/render-iframe.ts | 8 ++++++++ 9 files changed, 54 insertions(+), 16 deletions(-) diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 807064b354319..2846b705cddc6 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,11 +48,13 @@ func RegisterRenderers() { }, }) - // Asciicast (terminal recording) files are rendered in an iframe because the player uses WebAssembly, - // which is blocked by the main site CSP (no 'wasm-unsafe-eval'). See https://github.com/go-gitea/gitea/issues/37257 + // Asciicast (terminal recording) files are rendered in an iframe because the player uses WebAssembly. + // srcMethod="src" gives the iframe its own response CSP (which allows wasm-unsafe-eval) + // instead of inheriting the main site's stricter one. See https://github.com/go-gitea/gitea/issues/37257 markup.RegisterRenderer(&frontendRenderer{ - name: "asciicast", - patterns: []string{"*.cast"}, + name: "asciicast", + patterns: []string{"*.cast"}, + srcMethod: "src", }) for _, renderer := range setting.ExternalMarkupRenderers { diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 7327503d28a9b..5f391161f0dd8 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -16,8 +16,9 @@ import ( ) type frontendRenderer struct { - name string - patterns []string + name string + patterns []string + srcMethod string } var ( @@ -55,6 +56,7 @@ func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRend ret.SanitizerDisabled = true ret.DisplayInIframe = true ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" + ret.SrcMethod = p.srcMethod return ret } diff --git a/modules/markup/render.go b/modules/markup/render.go index a559e1982e60c..2816b89801d24 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -224,6 +224,9 @@ func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.W if opts.ContentSandbox != "" { extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox) } + if opts.SrcMethod == "src" { + extraAttrs += ` data-src-method="src"` + } _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) return err } diff --git a/modules/markup/render_test.go b/modules/markup/render_test.go index 3b89d8485e1e8..a6d8238dc11c6 100644 --- a/modules/markup/render_test.go +++ b/modules/markup/render_test.go @@ -28,4 +28,7 @@ func TestRenderIFrame(t *testing.T) { ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) assert.Equal(t, ``, ret) + + ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow", SrcMethod: "src"}) + assert.Equal(t, ``, ret) } diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index c62c28ad2a11a..f53d1f5d3ad26 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -29,6 +29,15 @@ type ExternalRendererOptions struct { SanitizerDisabled bool DisplayInIframe bool ContentSandbox string + + // SrcMethod controls how the parent page loads the iframe content: + // "" or "srcdoc" — default, fetched by JS then set via iframe.srcdoc. The iframe + // document inherits the parent page's CSP per CSP3 §4.2.3.6. + // "src" — set iframe.src to the render URL. The iframe document uses the + // response's own CSP (no inheritance). Required for renderers that + // need permissions the main CSP denies (e.g. asciinema-player needs + // WebAssembly). Isolation still comes from the iframe sandbox attr. + SrcMethod string } // ExternalRenderer defines an interface for external renderers diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index ace871a9f18a3..ea88f857b1bf2 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -62,9 +62,18 @@ func RenderFile(ctx *context.Context) { // 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 != "" { + switch { + case extRendererOpts.SrcMethod == "src": + // The iframe is loaded via "src", so the response must NOT carry the CSP "sandbox" + // directive (Firefox refuses same-origin src loading of a sandboxed response with + // "Unsafe attempt to load URL ..."). Isolation is enforced by the iframe element's + // sandbox attribute instead. The script-src here is permissive enough to allow + // WebAssembly (asciinema-player), while the main site CSP stays untouched. + ctx.Resp.Header().Add("Content-Security-Policy", + "frame-src 'self'; script-src * 'unsafe-inline' 'wasm-unsafe-eval'; style-src * 'unsafe-inline'; default-src * data: blob:") + case extRendererOpts.ContentSandbox != "": ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox) - } else { + default: ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") } diff --git a/services/context/context_template.go b/services/context/context_template.go index 68193ca55f0b6..2b34681faa44c 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -153,9 +153,7 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML { `default-src * data:;` + // enforce nonce for all scripts, disallow inline scripts - // 'wasm-unsafe-eval' lets srcdoc iframe renderers (e.g. asciinema-player) load WebAssembly; - // srcdoc iframes inherit the parent CSP per CSP3 §4.2.3.6 and a child CSP only narrows, never widens. - `script-src * 'nonce-` + c.CspScriptNonce() + `' 'wasm-unsafe-eval';` + + `script-src * 'nonce-` + c.CspScriptNonce() + `';` + // it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it `style-src * 'unsafe-inline';` + diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index 3f8069fb2bfc0..6bf9331ca3e7e 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -30,7 +30,7 @@ test('3d model file', async ({page, request}) => { }); test('pdf file', async ({page, request}) => { - // headless playwright cannot render PDFs (PDFObject.embed returns false), so this is a limited test + // headless playwright cannot render PDFs (PDFObject.embed returns false), so ttests is a limited test const repoName = `e2e-pdf-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await apiCreateRepo(request, {name: repoName}); @@ -48,19 +48,23 @@ test('pdf file', async ({page, request}) => { test('asciicast file', async ({page, request}) => { // regression for #37257: the asciinema player uses WebAssembly, blocked by the main site CSP - // (srcdoc iframes inherit the parent CSP, so moving into an iframe alone doesn't help — the - // CSP must grant 'wasm-unsafe-eval'). + // (srcdoc iframes inherit the parent CSP). Fix: the asciicast renderer opts into + // SrcMethod="src" so the iframe gets its own response CSP with 'wasm-unsafe-eval'. The + // cast's rendered "test" text only appears if WASM actually loaded and processed the recording — + // that's what this test asserts. const repoName = `e2e-asciicast-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]); try { - const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; + const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test"]\n'; await apiCreateFile(request, owner, repoName, 'test.cast', cast); await page.goto(`/${owner}/${repoName}/src/branch/main/test.cast`); const iframe = page.locator('iframe.external-render-iframe'); await expect(iframe).toBeVisible(); const frame = page.frameLocator('iframe.external-render-iframe'); - await expect(frame.locator('.ap-wrapper')).toBeVisible(); + const wrapper = frame.locator('.ap-wrapper'); + await expect(wrapper).toBeVisible(); + await expect(wrapper).toContainText('test'); await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); await assertFlushWithParent(iframe, page.locator('.file-view')); await assertNoJsError(page); diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 2b1b06e5c0bfd..29a7973f8414c 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -64,6 +64,14 @@ export async function initExternalRenderIframe(iframe: HTMLIFrameElement) { u.searchParams.set('gitea-iframe-id', iframe.id); u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe)); + if (iframe.getAttribute('data-src-method') === 'src') { + // Renderers that set `ExternalRendererOptions.SrcMethod = "src"` get their own + // response-level CSP (no inheritance from the parent page's). Isolation still comes + // from the iframe's sandbox attribute. + iframe.src = u.href; + return; + } + // It must use "srcdoc" here, because our backend always sends CSP sandbox directive for the rendered content // (to protect from XSS risks), so we can't use "src" to load the content directly, otherwise there will be console errors like: // Unsafe attempt to load URL http://localhost:3000/test from frame with URL http://localhost:3000/test From ca0149c11393ae6324ceec26ed581aac71deb2a2 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 19 Apr 2026 18:22:11 +0200 Subject: [PATCH 4/6] Shorten comments; clean up switch in RenderFile - SrcMethod doc collapsed to 2 lines mentioning CSP - Drop regression narration in e2e test and one-liner for asciicast registration - Replace switch-with-no-variable in RenderFile with an if/else ladder; move the PDF-RENDER-SANDBOX note next to the branch it actually describes - Fix a leftover typo in pdf test comment Co-Authored-By: Claude (Opus 4.7) --- modules/markup/external/external.go | 5 +---- modules/markup/renderer.go | 9 ++------- routers/web/repo/render.go | 22 ++++++++++------------ tests/e2e/file-view-render.test.ts | 7 +------ 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 2846b705cddc6..e5fc7522b3c82 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,13 +48,10 @@ func RegisterRenderers() { }, }) - // Asciicast (terminal recording) files are rendered in an iframe because the player uses WebAssembly. - // srcMethod="src" gives the iframe its own response CSP (which allows wasm-unsafe-eval) - // instead of inheriting the main site's stricter one. See https://github.com/go-gitea/gitea/issues/37257 markup.RegisterRenderer(&frontendRenderer{ name: "asciicast", patterns: []string{"*.cast"}, - srcMethod: "src", + srcMethod: "src", // asciinema-player uses WebAssembly, needs its own iframe CSP }) for _, renderer := range setting.ExternalMarkupRenderers { diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index f53d1f5d3ad26..0e51facc85d89 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -30,13 +30,8 @@ type ExternalRendererOptions struct { DisplayInIframe bool ContentSandbox string - // SrcMethod controls how the parent page loads the iframe content: - // "" or "srcdoc" — default, fetched by JS then set via iframe.srcdoc. The iframe - // document inherits the parent page's CSP per CSP3 §4.2.3.6. - // "src" — set iframe.src to the render URL. The iframe document uses the - // response's own CSP (no inheritance). Required for renderers that - // need permissions the main CSP denies (e.g. asciinema-player needs - // WebAssembly). Isolation still comes from the iframe sandbox attr. + // SrcMethod: "src" gives the iframe its own response CSP; "srcdoc" (default) inherits + // the parent page's CSP per CSP3 §4.2.3.6. SrcMethod string } diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index ea88f857b1bf2..9baf87a5859ff 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -58,22 +58,20 @@ func RenderFile(ctx *context.Context) { 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() - switch { - case extRendererOpts.SrcMethod == "src": - // The iframe is loaded via "src", so the response must NOT carry the CSP "sandbox" - // directive (Firefox refuses same-origin src loading of a sandboxed response with - // "Unsafe attempt to load URL ..."). Isolation is enforced by the iframe element's - // sandbox attribute instead. The script-src here is permissive enough to allow - // WebAssembly (asciinema-player), while the main site CSP stays untouched. + if extRendererOpts.SrcMethod == "src" { + // Iframe is loaded via "src", so the response must NOT carry the CSP "sandbox" directive + // (Firefox refuses same-origin src loading of a sandboxed response). Isolation comes from + // the iframe's sandbox attribute. The script-src here grants WebAssembly for this response + // only, without widening the main site CSP. ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; script-src * 'unsafe-inline' 'wasm-unsafe-eval'; style-src * 'unsafe-inline'; default-src * data: blob:") - case extRendererOpts.ContentSandbox != "": + } else if extRendererOpts.ContentSandbox != "" { ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox) - default: + } else { + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context — Chrome blocks the PDF + // rendering when sandboxed, even if all "allow-*" are set; renderers opt out of sandboxing + // by leaving ContentSandbox empty. ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") } diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index 6bf9331ca3e7e..3f42f56785704 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -30,7 +30,7 @@ test('3d model file', async ({page, request}) => { }); test('pdf file', async ({page, request}) => { - // headless playwright cannot render PDFs (PDFObject.embed returns false), so ttests is a limited test + // headless playwright cannot render PDFs (PDFObject.embed returns false), so this is a limited test const repoName = `e2e-pdf-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await apiCreateRepo(request, {name: repoName}); @@ -47,11 +47,6 @@ test('pdf file', async ({page, request}) => { }); test('asciicast file', async ({page, request}) => { - // regression for #37257: the asciinema player uses WebAssembly, blocked by the main site CSP - // (srcdoc iframes inherit the parent CSP). Fix: the asciicast renderer opts into - // SrcMethod="src" so the iframe gets its own response CSP with 'wasm-unsafe-eval'. The - // cast's rendered "test" text only appears if WASM actually loaded and processed the recording — - // that's what this test asserts. const repoName = `e2e-asciicast-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]); From bbdde5230d03761139f9b4198ef7961e47071b75 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 19 Apr 2026 18:42:49 +0200 Subject: [PATCH 5/6] Generalize iframe CSP extension: AdditionalCSPSources Renderers declare per-directive source additions as a map instead of hardcoding 'wasm-unsafe-eval' in the router. Only the asciicast renderer sets {"script-src": {"'wasm-unsafe-eval'"}}; the main site CSP and other iframe renderers are unaffected. Co-Authored-By: Claude (Opus 4.7) --- modules/markup/external/external.go | 3 +++ modules/markup/external/frontend.go | 8 +++++--- modules/markup/renderer.go | 4 ++++ routers/web/repo/render.go | 30 +++++++++++++++++++++++++---- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index e5fc7522b3c82..eec71edb8c457 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -52,6 +52,9 @@ func RegisterRenderers() { name: "asciicast", patterns: []string{"*.cast"}, srcMethod: "src", // asciinema-player uses WebAssembly, needs its own iframe CSP + additionalCSPSources: map[string][]string{ + "script-src": {"'wasm-unsafe-eval'"}, + }, }) for _, renderer := range setting.ExternalMarkupRenderers { diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 5f391161f0dd8..ad564dc7fc11b 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -16,9 +16,10 @@ import ( ) type frontendRenderer struct { - name string - patterns []string - srcMethod string + name string + patterns []string + srcMethod string + additionalCSPSources map[string][]string } var ( @@ -57,6 +58,7 @@ func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRend ret.DisplayInIframe = true ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" ret.SrcMethod = p.srcMethod + ret.AdditionalCSPSources = p.additionalCSPSources return ret } diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 0e51facc85d89..d24ee4aa18bfb 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -33,6 +33,10 @@ type ExternalRendererOptions struct { // SrcMethod: "src" gives the iframe its own response CSP; "srcdoc" (default) inherits // the parent page's CSP per CSP3 §4.2.3.6. SrcMethod string + + // AdditionalCSPSources appends source expressions to the iframe response's CSP directives + // (e.g. {"script-src": {"'wasm-unsafe-eval'"}}). Only applied when SrcMethod="src". + AdditionalCSPSources map[string][]string } // ExternalRenderer defines an interface for external renderers diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 9baf87a5859ff..6ad9d45221c8e 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -6,6 +6,8 @@ package repo import ( "net/http" "path" + "slices" + "strings" "code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/modules/git" @@ -14,6 +16,27 @@ import ( "code.gitea.io/gitea/services/context" ) +// buildIframeCSP returns the CSP for an iframe response loaded via iframe.src. The baseline is +// permissive (isolation is provided by the iframe sandbox attribute); renderer-specific sources +// are appended to the named directive. +func buildIframeCSP(additional map[string][]string) string { + directives := []struct { + name string + srcs []string + }{ + {"frame-src", []string{"'self'"}}, + {"script-src", []string{"*", "'unsafe-inline'"}}, + {"style-src", []string{"*", "'unsafe-inline'"}}, + {"default-src", []string{"*", "data:", "blob:"}}, + } + parts := make([]string, 0, len(directives)) + for _, d := range directives { + srcs := slices.Concat(d.srcs, additional[d.name]) + parts = append(parts, d.name+" "+strings.Join(srcs, " ")) + } + return strings.Join(parts, "; ") +} + // RenderFile renders a file by repos path func RenderFile(ctx *context.Context) { var blob *git.Blob @@ -62,10 +85,9 @@ func RenderFile(ctx *context.Context) { if extRendererOpts.SrcMethod == "src" { // Iframe is loaded via "src", so the response must NOT carry the CSP "sandbox" directive // (Firefox refuses same-origin src loading of a sandboxed response). Isolation comes from - // the iframe's sandbox attribute. The script-src here grants WebAssembly for this response - // only, without widening the main site CSP. - ctx.Resp.Header().Add("Content-Security-Policy", - "frame-src 'self'; script-src * 'unsafe-inline' 'wasm-unsafe-eval'; style-src * 'unsafe-inline'; default-src * data: blob:") + // the iframe's sandbox attribute. Renderers can append additional sources (e.g. + // 'wasm-unsafe-eval' for asciinema-player) without widening the main site CSP. + ctx.Resp.Header().Add("Content-Security-Policy", buildIframeCSP(extRendererOpts.AdditionalCSPSources)) } else if extRendererOpts.ContentSandbox != "" { ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox) } else { From 2a69ae3c2344f23c4566722b334c93536775dc03 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 19 Apr 2026 19:13:19 +0200 Subject: [PATCH 6/6] Address Copilot review: sandbox guard, generalize CSP builder, restore non-ASCII test - Reject SrcMethod="src" when the iframe sandbox is missing or contains allow-same-origin (panic in dev/testing, log + 500 in prod). - buildIframeCSP now emits any directive in AdditionalCSPSources, not just the baseline ones; output sorted for deterministic tests. - Restore the non-ASCII branch case in the asciicast e2e to guard the render-URL escaping path; still asserts WASM rendered the cast text. - Unit test for iframeSandboxSafeForSrc covering safe/unsafe sandboxes. Co-Authored-By: Claude (Opus 4.7) --- routers/web/repo/render.go | 66 +++++++++++++++++------------- routers/web/repo/render_test.go | 29 +++++++++++++ tests/e2e/file-view-render.test.ts | 10 ++++- 3 files changed, 75 insertions(+), 30 deletions(-) create mode 100644 routers/web/repo/render_test.go diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 6ad9d45221c8e..a09063093f58c 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -4,6 +4,7 @@ package repo import ( + "maps" "net/http" "path" "slices" @@ -13,26 +14,32 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" ) -// buildIframeCSP returns the CSP for an iframe response loaded via iframe.src. The baseline is -// permissive (isolation is provided by the iframe sandbox attribute); renderer-specific sources -// are appended to the named directive. +// iframeSandboxSafeForSrc reports whether ContentSandbox is strong enough to justify the +// permissive CSP emitted for SrcMethod="src": sandbox must be set and not neutralized by +// allow-same-origin (which would restore parent-origin privileges). +func iframeSandboxSafeForSrc(contentSandbox string) bool { + return contentSandbox != "" && !slices.Contains(strings.Fields(contentSandbox), "allow-same-origin") +} + +// buildIframeCSP emits a permissive CSP for iframes loaded via iframe.src — isolation is +// provided by the iframe sandbox attribute. Renderer-supplied sources are appended per directive. func buildIframeCSP(additional map[string][]string) string { - directives := []struct { - name string - srcs []string - }{ - {"frame-src", []string{"'self'"}}, - {"script-src", []string{"*", "'unsafe-inline'"}}, - {"style-src", []string{"*", "'unsafe-inline'"}}, - {"default-src", []string{"*", "data:", "blob:"}}, + csp := map[string][]string{ + "frame-src": {"'self'"}, + "script-src": {"*", "'unsafe-inline'"}, + "style-src": {"*", "'unsafe-inline'"}, + "default-src": {"*", "data:", "blob:"}, + } + for name, srcs := range additional { + csp[name] = append(csp[name], srcs...) } - parts := make([]string, 0, len(directives)) - for _, d := range directives { - srcs := slices.Concat(d.srcs, additional[d.name]) - parts = append(parts, d.name+" "+strings.Join(srcs, " ")) + parts := make([]string, 0, len(csp)) + for _, name := range slices.Sorted(maps.Keys(csp)) { + parts = append(parts, name+" "+strings.Join(csp[name], " ")) } return strings.Join(parts, "; ") } @@ -81,19 +88,22 @@ func RenderFile(ctx *context.Context) { return } - extRendererOpts := extRenderer.GetExternalRendererOptions() - if extRendererOpts.SrcMethod == "src" { - // Iframe is loaded via "src", so the response must NOT carry the CSP "sandbox" directive - // (Firefox refuses same-origin src loading of a sandboxed response). Isolation comes from - // the iframe's sandbox attribute. Renderers can append additional sources (e.g. - // 'wasm-unsafe-eval' for asciinema-player) without widening the main site CSP. - ctx.Resp.Header().Add("Content-Security-Policy", buildIframeCSP(extRendererOpts.AdditionalCSPSources)) - } else if extRendererOpts.ContentSandbox != "" { - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox) - } else { - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context — Chrome blocks the PDF - // rendering when sandboxed, even if all "allow-*" are set; renderers opt out of sandboxing - // by leaving ContentSandbox empty. + opts := extRenderer.GetExternalRendererOptions() + switch { + case opts.SrcMethod == "src": + // No CSP "sandbox" directive (Firefox refuses same-origin src loading of a sandboxed + // response); the iframe element's sandbox attribute is our isolation — require a safe one. + if !iframeSandboxSafeForSrc(opts.ContentSandbox) { + setting.PanicInDevOrTesting("renderer %q SrcMethod=\"src\" needs sandbox without allow-same-origin (got %q)", renderer.Name(), opts.ContentSandbox) + log.Error("renderer %q SrcMethod=\"src\" without safe sandbox", renderer.Name()) + http.Error(ctx.Resp, "Renderer misconfigured", http.StatusInternalServerError) + return + } + ctx.Resp.Header().Add("Content-Security-Policy", buildIframeCSP(opts.AdditionalCSPSources)) + case opts.ContentSandbox != "": + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+opts.ContentSandbox) + default: + // HINT: PDF-RENDER-SANDBOX: Chrome refuses to render PDFs in a sandboxed context. ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") } diff --git a/routers/web/repo/render_test.go b/routers/web/repo/render_test.go new file mode 100644 index 0000000000000..794567a6677fa --- /dev/null +++ b/routers/web/repo/render_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIframeSandboxSafeForSrc(t *testing.T) { + cases := []struct { + sandbox string + safe bool + }{ + {"", false}, + {"allow-scripts", true}, + {"allow-scripts allow-forms allow-popups", true}, + {"allow-scripts allow-same-origin", false}, + {"allow-same-origin", false}, + {" allow-scripts allow-same-origin ", false}, + } + for _, tc := range cases { + t.Run(tc.sandbox, func(t *testing.T) { + assert.Equal(t, tc.safe, iframeSandboxSafeForSrc(tc.sandbox)) + }) + } +} diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index 3f42f56785704..206a4271ea66b 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; +import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; test('3d model file', async ({page, request}) => { const repoName = `e2e-3d-render-${randomString(8)}`; @@ -47,15 +47,21 @@ test('pdf file', async ({page, request}) => { }); test('asciicast file', async ({page, request}) => { + // Non-ASCII branch name guards the render-URL escaping path — the iframe's data-src and + // the eventual iframe.src must survive round-tripping through RefTypeNameSubURL. const repoName = `e2e-asciicast-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; + const branch = '日本語-branch'; + const branchEnc = encodeURIComponent(branch); await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]); try { const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test"]\n'; await apiCreateFile(request, owner, repoName, 'test.cast', cast); - await page.goto(`/${owner}/${repoName}/src/branch/main/test.cast`); + await apiCreateBranch(request, owner, repoName, branch); + await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`); const iframe = page.locator('iframe.external-render-iframe'); await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/${branchEnc}/test\\.cast`)); const frame = page.frameLocator('iframe.external-render-iframe'); const wrapper = frame.locator('.ap-wrapper'); await expect(wrapper).toBeVisible();