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..eec71edb8c457 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,6 +48,15 @@ func RegisterRenderers() { }, }) + markup.RegisterRenderer(&frontendRenderer{ + 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 { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 7327503d28a9b..ad564dc7fc11b 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -16,8 +16,10 @@ import ( ) type frontendRenderer struct { - name string - patterns []string + name string + patterns []string + srcMethod string + additionalCSPSources map[string][]string } var ( @@ -55,6 +57,8 @@ 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 + ret.AdditionalCSPSources = p.additionalCSPSources return ret } diff --git a/modules/markup/render.go b/modules/markup/render.go index 6e8838d49fe7d..2816b89801d24 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 @@ -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..d24ee4aa18bfb 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -29,6 +29,14 @@ type ExternalRendererOptions struct { SanitizerDisabled bool DisplayInIframe bool ContentSandbox string + + // 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 ace871a9f18a3..a09063093f58c 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -4,16 +4,46 @@ package repo import ( + "maps" "net/http" "path" + "slices" + "strings" "code.gitea.io/gitea/models/renderhelper" "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" ) +// 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 { + 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(csp)) + for _, name := range slices.Sorted(maps.Keys(csp)) { + parts = append(parts, name+" "+strings.Join(csp[name], " ")) + } + return strings.Join(parts, "; ") +} + // RenderFile renders a file by repos path func RenderFile(ctx *context.Context) { var blob *git.Blob @@ -58,13 +88,22 @@ 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() - if extRendererOpts.ContentSandbox != "" { - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox) - } else { + 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 a3afe85b2675a..206a4271ea66b 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -47,22 +47,28 @@ 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 + // 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, 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); + const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test"]\n'; + await apiCreateFile(request, owner, repoName, 'test.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 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(); + 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); } 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