Skip to content
Draft
1 change: 0 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 0 additions & 49 deletions modules/markup/asciicast/asciicast.go

This file was deleted.

9 changes: 9 additions & 0 deletions modules/markup/external/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
Expand Down
8 changes: 6 additions & 2 deletions modules/markup/external/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import (
)

type frontendRenderer struct {
name string
patterns []string
name string
patterns []string
srcMethod string
additionalCSPSources map[string][]string
}

var (
Expand Down Expand Up @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type RenderOptions struct {
MarkupType string

// user&repo, format&style&regexp (for external issue pattern), teams&org (for mention)
// RefTypeNameSubURL (for iframe&asciicast)
// RefTypeNameSubURL (for iframe render)
// markupAllowShortIssuePattern
// markdownNewLineHardBreak
Metas map[string]string
Expand Down Expand Up @@ -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, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
return err
}
Expand Down
3 changes: 3 additions & 0 deletions modules/markup/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ func TestRenderIFrame(t *testing.T) {

ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow"></iframe>`, ret)

ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow", SrcMethod: "src"})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow" data-src-method="src"></iframe>`, ret)
}
8 changes: 8 additions & 0 deletions modules/markup/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Comment thread
silverwind marked this conversation as resolved.
AdditionalCSPSources map[string][]string
}

// ExternalRenderer defines an interface for external renderers
Expand Down
53 changes: 46 additions & 7 deletions routers/web/repo/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'")
}

Expand Down
29 changes: 29 additions & 0 deletions routers/web/repo/render_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
26 changes: 16 additions & 10 deletions tests/e2e/file-view-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
silverwind marked this conversation as resolved.
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);
}
Expand Down
2 changes: 1 addition & 1 deletion types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): void;
create(src: string | {data: string} | {url: string}, element: HTMLElement, options?: Record<string, unknown>): void;
}
const exports: AsciinemaPlayer;
export = exports;
Expand Down
1 change: 0 additions & 1 deletion web_src/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 0 additions & 4 deletions web_src/css/repo.css
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,6 @@ td .commit-summary {
overflow: auto;
}

.non-diff-file-content .asciicast {
padding: 0 !important;
}

.repo-editor-header {
display: flex;
margin: 1rem 0;
Expand Down
1 change: 1 addition & 0 deletions web_src/js/external-render-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>;
const frontendPlugins: Record<string, LazyLoadFunc> = {
'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 {
Expand Down
2 changes: 0 additions & 2 deletions web_src/js/markup/content.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,7 +24,6 @@ export function initMarkupContent(): void {
initMarkupTasklist(el);
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
initMarkupRefIssue(el);
});
}
8 changes: 8 additions & 0 deletions web_src/js/markup/render-iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
.asciinema-player-container {
width: 100%;
height: auto;
}

/* Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js : <div class="ap-term" ...>
Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 */
.ap-term {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import {queryElems} from '../utils/dom.ts';
import type {FrontendRenderFunc} from '../plugin.ts';

export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
export const frontendRender: FrontendRenderFunc = async (opts): Promise<boolean> => {
try {
const [player] = await Promise.all([
import('asciinema-player'),
import('asciinema-player/dist/bundle/asciinema-player.css'),
import('./frontend-asciicast.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;
}
};