mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-13 19:19:52 +00:00
fix: csp regressions (#38047)
fix #37257 , all details are in the comments
This commit is contained in:
@@ -531,6 +531,10 @@ INTERNAL_TOKEN =
|
||||
;;
|
||||
;; The value of the X-Content-Type-Options HTTP header for all responses. Use "unset" to remove the header.
|
||||
;X_CONTENT_TYPE_OPTIONS = nosniff
|
||||
;;
|
||||
;; The value of the general Content-Security-Policy for most web pages.
|
||||
;; Leave it empty to apply the default policy, or set it to "unset" to disable Content-Security-Policy.
|
||||
;CONTENT_SECURITY_POLICY_GENERAL =
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -2668,19 +2672,21 @@ LEVEL = Info
|
||||
;FILE_EXTENSIONS = .adoc,.asciidoc
|
||||
;; External command to render all matching extensions
|
||||
;RENDER_COMMAND = "asciidoc --out-file=- -"
|
||||
;; Don't pass the file on STDIN, pass the filename as argument instead.
|
||||
;; Whether Gitea should write the content into a local temp file for the render command's input.
|
||||
;; * false: the content will be passed via STDIN to the command.
|
||||
;; * true: write the content into a local temp file, and pass the temp filename as argument to the command.
|
||||
;IS_INPUT_FILE = false
|
||||
;; How the content will be rendered.
|
||||
;; * sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in [markup.sanitizer.*] .
|
||||
;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code.
|
||||
;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page.
|
||||
;RENDER_CONTENT_MODE = sanitized
|
||||
;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`.
|
||||
;; The sandbox applied to the Content-Security-Policy for the rendered content when RENDER_CONTENT_MODE is `iframe`.
|
||||
;; It defaults to a safe set of "allow-*" restrictions (space separated).
|
||||
;; You can also set it by your requirements or use "disabled" to disable the sandbox completely.
|
||||
;; When set it, make sure there is no security risk:
|
||||
;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox.
|
||||
;; * HTML content with JS: if the "RENDER_COMMAND" can guarantee there is no XSS, then it is safe, otherwise, you need to fine tune the "allow-*" restrictions.
|
||||
;; * HTML content with JS: do not set "allow-same-origin" unless the "RENDER_COMMAND" can guarantee there is no XSS.
|
||||
;RENDER_CONTENT_SANDBOX =
|
||||
;; Whether post-process the rendered HTML content, including:
|
||||
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
// register supported doc types
|
||||
_ "gitea.dev/modules/markup/asciicast"
|
||||
_ "gitea.dev/modules/markup/console"
|
||||
_ "gitea.dev/modules/markup/csv"
|
||||
_ "gitea.dev/modules/markup/markdown"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package asciicast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/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, `<div class="%s" %s="%s"></div>`, playerClassName, playerSrcAttr, rawURL)
|
||||
}
|
||||
Vendored
+5
@@ -48,6 +48,11 @@ func RegisterRenderers() {
|
||||
},
|
||||
})
|
||||
|
||||
markup.RegisterRenderer(&frontendRenderer{
|
||||
name: "asciicast",
|
||||
patterns: []string{"*.cast"},
|
||||
})
|
||||
|
||||
for _, renderer := range setting.ExternalMarkupRenderers {
|
||||
markup.RegisterRenderer(&Renderer{renderer})
|
||||
}
|
||||
|
||||
Vendored
+3
-3
@@ -5,6 +5,7 @@ package external
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -54,14 +55,13 @@ func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
|
||||
ret.SanitizerDisabled = true
|
||||
ret.DisplayInIframe = true
|
||||
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
|
||||
ret.ContentSandbox = setting.MarkupRenderDefaultSandbox
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
if ctx.RenderOptions.StandalonePageOptions == nil {
|
||||
opts := p.GetExternalRendererOptions()
|
||||
return markup.RenderIFrame(ctx, &opts, output)
|
||||
return errors.New("should only be rendered in standalone page")
|
||||
}
|
||||
|
||||
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
|
||||
|
||||
@@ -211,11 +211,11 @@ func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.W
|
||||
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
||||
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
|
||||
)
|
||||
var extraAttrs template.HTML
|
||||
if opts.ContentSandbox != "" {
|
||||
extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
|
||||
}
|
||||
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
|
||||
|
||||
// The render response should always have correct "sandbox" limits (no same-origin),
|
||||
// otherwise the "render link" direct access can still cause XSS without iframe.
|
||||
// So here we do not need to set sandbox attribute on the iframe.
|
||||
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, src)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ func TestRenderIFrame(t *testing.T) {
|
||||
WithRelativePath("tree-path").
|
||||
WithMetas(map[string]string{"user": "test-owner", "repo": "test-repo", "RefTypeNameSubURL": "src/branch/master"})
|
||||
|
||||
// the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled"
|
||||
ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""})
|
||||
// iframe doesn't need sandbox, the sandbox is set in render's response header
|
||||
ret := render(ctx, ExternalRendererOptions{ContentSandbox: "any"})
|
||||
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, ret)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -237,6 +237,10 @@ func fileExtensionsToPatterns(sectionName string, extensions []string) []string
|
||||
return patterns
|
||||
}
|
||||
|
||||
// MarkupRenderDefaultSandbox only contains a safe set of "sandbox allow" values, it is used to protect users from XSS attack,
|
||||
// DO NOT USE "allow-same-origin" by default: if there is XSS in rendered content, same-origin makes the frame page can access parent window and send requests with user's credentials.
|
||||
const MarkupRenderDefaultSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
|
||||
|
||||
func newMarkupRenderer(name string, sec ConfigSection) {
|
||||
if !sec.Key("ENABLED").MustBool(false) {
|
||||
return
|
||||
@@ -269,9 +273,7 @@ func newMarkupRenderer(name string, sec ConfigSection) {
|
||||
renderContentMode = RenderContentModeSanitized
|
||||
}
|
||||
|
||||
// ATTENTION! at the moment, only a safe set like "allow-scripts" are allowed for sandbox mode.
|
||||
// "allow-same-origin" should NEVER be used, it leads to XSS attack: makes the JS in iframe can access parent window's config and send requests with user's credentials.
|
||||
renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString("allow-scripts allow-popups")
|
||||
renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString(MarkupRenderDefaultSandbox)
|
||||
if renderContentSandbox == "disabled" {
|
||||
renderContentSandbox = ""
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ var Security = struct {
|
||||
// TODO: move more settings to this struct in future
|
||||
XFrameOptions string
|
||||
XContentTypeOptions string
|
||||
|
||||
ContentSecurityPolicyGeneral string // it only supports empty (default policy) or "unset", maybe it can support more in the future
|
||||
}{
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
XContentTypeOptions: "nosniff",
|
||||
@@ -150,13 +152,12 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
|
||||
SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
|
||||
|
||||
deprecatedSetting(rootCfg, "cors", "X_FRAME_OPTIONS", "security", "X_FRAME_OPTIONS", "v1.26.0")
|
||||
if sec.HasKey("X_FRAME_OPTIONS") {
|
||||
Security.XFrameOptions = sec.Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions)
|
||||
} else {
|
||||
if !sec.HasKey("X_FRAME_OPTIONS") {
|
||||
Security.XFrameOptions = rootCfg.Section("cors").Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions)
|
||||
}
|
||||
|
||||
Security.XContentTypeOptions = sec.Key("X_CONTENT_TYPE_OPTIONS").MustString(Security.XContentTypeOptions)
|
||||
if err := sec.MapTo(&Security); err != nil {
|
||||
log.Fatal("Failed to map security settings: %v", err)
|
||||
}
|
||||
|
||||
twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String()
|
||||
switch twoFactorAuth {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoadSecurityFrom(t *testing.T) {
|
||||
cfg, err := NewConfigProviderFromData(`[security]
|
||||
X_FRAME_OPTIONS = DENY
|
||||
X_CONTENT_TYPE_OPTIONS = unset
|
||||
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"`)
|
||||
assert.NoError(t, err)
|
||||
loadSecurityFrom(cfg)
|
||||
assert.Equal(t, "DENY", Security.XFrameOptions)
|
||||
assert.Equal(t, "unset", Security.XContentTypeOptions)
|
||||
assert.Equal(t, `"script-src *`, Security.ContentSecurityPolicyGeneral) // holy shit ini package bug
|
||||
}
|
||||
@@ -63,9 +63,7 @@ func RenderFile(ctx *context.Context) {
|
||||
// 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 {
|
||||
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
|
||||
ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+extRendererOpts.ContentSandbox)
|
||||
}
|
||||
|
||||
err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp)
|
||||
|
||||
@@ -115,6 +115,9 @@ func (c TemplateContext) CspScriptNonce() (ret string) {
|
||||
}
|
||||
|
||||
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
||||
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
|
||||
return "" // if site admin disables the general CSP, then we don't use it
|
||||
}
|
||||
// The CSP problem is more complicated than it looks.
|
||||
// Gitea was designed to support various "customizations", including:
|
||||
// * custom themes (custom CSS and JS)
|
||||
|
||||
@@ -2,7 +2,8 @@ import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
|
||||
|
||||
test('3d model file', async ({page, request}) => {
|
||||
test('3d model file', async ({page, request, browserName}) => {
|
||||
test.skip(browserName === 'firefox', 'unclear firefox-only CI-only failure'); // eslint-disable-line playwright/no-skipped-test
|
||||
const repoName = `e2e-3d-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
@@ -13,7 +14,7 @@ test('3d model file', async ({page, request}) => {
|
||||
await expect(iframe).toBeVisible();
|
||||
const frame = page.frameLocator('iframe.external-render-iframe');
|
||||
const viewer = frame.locator('#frontend-render-viewer');
|
||||
await expect(viewer.locator('canvas')).toBeVisible();
|
||||
await expect(viewer.locator('canvas')).toBeVisible(); // unclear firefox-only CI-only failure
|
||||
expect((await viewer.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
// bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent
|
||||
@@ -39,19 +40,24 @@ 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
|
||||
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)]);
|
||||
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-content"]\n';
|
||||
// on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit
|
||||
await apiCreateFile(request, owner, repoName, 'readme.cast', cast, {newBranch: 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, {newBranch: branch});
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
const frame = iframe.contentFrame();
|
||||
const viewer = frame.locator('#frontend-render-viewer[data-frontend-render-name]');
|
||||
await expect(viewer).toHaveAttribute('data-frontend-render-name', 'asciicast'); // render succeeded
|
||||
await expect(viewer).toHaveAttribute('data-window-origin', 'null'); // no same-origin, avoid XSS
|
||||
const wrapper = frame.locator('.ap-wrapper');
|
||||
await expect(wrapper).toBeVisible();
|
||||
await expect(wrapper).toContainText('test-content');
|
||||
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
|
||||
@@ -96,17 +96,17 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
||||
iframe := NewHTMLParser(t, respParent.Body).Find("iframe.external-render-iframe")
|
||||
assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead
|
||||
|
||||
// default sandbox on parent page
|
||||
assert.Equal(t, "allow-scripts allow-popups", iframe.AttrOr("sandbox", ""))
|
||||
// no sandbox on parent page because the rendered response should always have correct sandbox
|
||||
assert.Equal(t, "(non-existing)", iframe.AttrOr("sandbox", "(non-existing)"))
|
||||
assert.Equal(t, "/user2/repo1/render/branch/master/test.html", iframe.AttrOr("data-src", ""))
|
||||
})
|
||||
t.Run("SubPage", func(t *testing.T) {
|
||||
t.Run("FramePage", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/test.html")
|
||||
respSub := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Equal(t, "text/html; charset=utf-8", respSub.Header().Get("Content-Type"))
|
||||
|
||||
// default sandbox in sub page response
|
||||
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
|
||||
// default sandbox in sub-page response (there should be no "allow-same-origin")
|
||||
assert.Equal(t, "sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads", respSub.Header().Get("Content-Security-Policy"))
|
||||
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
|
||||
assert.Equal(t,
|
||||
`<script nonce crossorigin src="`+public.AssetURI("web_src/js/external-render-helper.ts")+`" id="gitea-external-render-helper" data-render-query-string=""></script>`+
|
||||
@@ -127,10 +127,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
||||
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/bin.no-sanitizer")
|
||||
respSub := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Equal(t, binaryContent, respSub.Body.String()) // raw content should keep the raw bytes (including invalid UTF-8 bytes), and no "external-render-iframe" helpers
|
||||
|
||||
// no sandbox (disabled by RENDER_CONTENT_SANDBOX)
|
||||
assert.Empty(t, iframe.AttrOr("sandbox", ""))
|
||||
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
|
||||
assert.Empty(t, respSub.Header().Get("Content-Security-Policy"), "sandbox is disabled by RENDER_CONTENT_SANDBOX")
|
||||
})
|
||||
|
||||
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
|
||||
@@ -142,7 +139,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
||||
`<script>foo("raw")</script>`,
|
||||
respSub.Body.String(),
|
||||
)
|
||||
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
|
||||
assert.Empty(t, respSub.Header().Get("Content-Security-Policy"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+1
-1
@@ -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}, element: HTMLElement, options?: Record<string, unknown>): void;
|
||||
}
|
||||
const exports: AsciinemaPlayer;
|
||||
export = exports;
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
@import "./markup/content.css";
|
||||
@import "./markup/codeblock.css";
|
||||
@import "./markup/codepreview.css";
|
||||
@import "./markup/asciicast.css";
|
||||
|
||||
@import "./font_i18n.css";
|
||||
@import "./base.css";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.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 {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
@@ -210,10 +210,6 @@ td .commit-summary {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.non-diff-file-content .asciicast {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.repo-editor-header {
|
||||
/* it should match ".repo-button-row" so the tree toggle button stays aligned */
|
||||
margin: 8px 0;
|
||||
|
||||
@@ -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 {
|
||||
@@ -44,23 +45,28 @@ async function initFrontendExternalRender() {
|
||||
const viewerContainer = document.querySelector<HTMLElement>('#frontend-render-viewer')!;
|
||||
const renderNames = viewerContainer.getAttribute('data-frontend-renders')!.split(' ');
|
||||
const fileTreePath = viewerContainer.getAttribute('data-file-tree-path')!;
|
||||
viewerContainer.setAttribute('data-window-origin', window.origin); // mainly for testing purpose
|
||||
|
||||
const fileDataElem = document.querySelector<HTMLTextAreaElement>('#frontend-render-data')!;
|
||||
fileDataElem.remove();
|
||||
const fileDataContent = fileDataElem.value;
|
||||
const fileDataEncoding = fileDataElem.getAttribute('data-content-encoding')!;
|
||||
const opts = new Options(viewerContainer, fileTreePath, fileDataEncoding, fileDataContent);
|
||||
|
||||
let found = false;
|
||||
let renderName = '', rendered = false;
|
||||
for (const name of renderNames) {
|
||||
if (!(name in frontendPlugins)) continue;
|
||||
const plugin = await frontendPlugins[name]();
|
||||
found = true;
|
||||
if (await plugin.frontendRender(opts)) break;
|
||||
renderName = name;
|
||||
rendered = await plugin.frontendRender(opts);
|
||||
if (rendered) break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
if (!renderName) {
|
||||
viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug';
|
||||
} else if (!rendered) {
|
||||
viewerContainer.textContent = `Failed to render by ${renderName}`;
|
||||
} else {
|
||||
viewerContainer.setAttribute('data-frontend-render-name', renderName); // succeeded render, mainly for testing purpose
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './external-render-helper.ts';
|
||||
|
||||
test('isValidCssColor', async () => {
|
||||
const isValidCssColor = window.testModules.externalRenderHelper!.isValidCssColor;
|
||||
const isValidCssColor = window.giteaExternalRenderHelper!.isValidCssColor;
|
||||
expect(isValidCssColor(null)).toBe(false);
|
||||
expect(isValidCssColor('')).toBe(false);
|
||||
|
||||
|
||||
@@ -50,12 +50,12 @@ body { background: ${backgroundColor}; }
|
||||
}
|
||||
|
||||
const iframeId = queryParams.get('gitea-iframe-id');
|
||||
if (iframeId) {
|
||||
// iframe is in different origin, so we need to use postMessage to communicate
|
||||
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
|
||||
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
||||
};
|
||||
// iframe is in different origin, so we need to use postMessage to communicate
|
||||
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
|
||||
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
||||
};
|
||||
|
||||
if (iframeId) {
|
||||
const updateIframeHeight = () => {
|
||||
if (!document.body) return; // the body might not be available when this function is called
|
||||
// Use scrollHeight to get the full content height, even when CSS sets html/body to height:100%
|
||||
@@ -90,6 +90,4 @@ if (iframeId) {
|
||||
});
|
||||
}
|
||||
|
||||
if (window.testModules) {
|
||||
window.testModules.externalRenderHelper = {isValidCssColor};
|
||||
}
|
||||
window.giteaExternalRenderHelper = {isValidCssColor, queryParams, postIframeMsg};
|
||||
|
||||
Vendored
+5
-5
@@ -68,13 +68,13 @@ interface Window {
|
||||
turnstile: any,
|
||||
hcaptcha: any,
|
||||
|
||||
// Make IIFE private functions can be tested in unit tests, without exposing the IIFE module to global scope.
|
||||
// Make IIFE private functions can be managed by us in our scope, without exposing the IIFE module to global scope.
|
||||
// Otherwise, when using "export" in IIFE code, the compiled JS will inject global "var externalRenderHelper = ..."
|
||||
// which is not expected and may cause conflicts with other modules.
|
||||
testModules: {
|
||||
externalRenderHelper?: {
|
||||
isValidCssColor(s: string | null): boolean,
|
||||
}
|
||||
giteaExternalRenderHelper?: {
|
||||
isValidCssColor(s: string | null): boolean,
|
||||
queryParams: URLSearchParams,
|
||||
postIframeMsg(cmd: string, data: Record<string, any> = {}),
|
||||
}
|
||||
|
||||
// do not add more properties here unless it is a must
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
|
||||
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
|
||||
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
|
||||
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, {
|
||||
// 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',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -24,6 +23,5 @@ export function initMarkupContent(): void {
|
||||
initMarkupTasklist(el);
|
||||
initMarkupCodeMermaid(el);
|
||||
initMarkupCodeMath(el);
|
||||
initMarkupRenderAsciicast(el);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
function safeRenderIframeLink(link: any): string | null {
|
||||
try {
|
||||
@@ -65,9 +64,31 @@ export async function initExternalRenderIframe(iframe: HTMLIFrameElement) {
|
||||
u.searchParams.set('gitea-iframe-id', iframe.id);
|
||||
u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe));
|
||||
|
||||
// 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
|
||||
const resp = await GET(u.href);
|
||||
iframe.srcdoc = await resp.text();
|
||||
// There are 3 kinds of external render modes:
|
||||
// * external frontend render:
|
||||
// * parent page creates iframe, iframe navigates to render page
|
||||
// * render generates frame page with external-render-helper (injected), external-render-frontend and file content (hidden textarea)
|
||||
// * frame page executes external-render-frontend JS code to finds a frontend plugin to render
|
||||
// * external backend render (HTML)
|
||||
// * parent page creates iframe, iframe navigates to render page
|
||||
// * render executes command to generate rendered HTML content with external-render-helper (injected)
|
||||
// * frame page displays the rendered content
|
||||
// * external backend render (non-HTML, e.g.: PDF, image)
|
||||
// * parent page creates iframe, iframe navigates to render page
|
||||
// * render executes command to generate rendered content
|
||||
// * response header is automatically detected from rendered content
|
||||
|
||||
// It must use "src" here, because the frame content should not inherit parent's CSP.
|
||||
// Otherwise, "srcdoc" makes the frame content inherit the parent's CSP,
|
||||
// then some renders like "asciicast (asciinema)" which require "unsafe-eval" won't work.
|
||||
//
|
||||
// When using "src", Chrome can report false-alarm error like:
|
||||
// * Unsafe attempt to load URL http://localhost/owner/repo/render/branch/main/file from frame with URL http://localhost/owner/repo/render/branch/main/file. Domains, protocols and ports must match.
|
||||
// (only for the first time that the developer opens the browser console)
|
||||
// Such error log can also appear even if you access the link "http://.../owner/repo/render/branch/main/file" directly.
|
||||
// Everything just works, it is just a false-alarm caused by Chrome's Developer Tools, so such error log can be ignored.
|
||||
//
|
||||
// Another reason for why "src" is a must: if the render outputs non-HTML contents like PDF or image,
|
||||
// Only "src" can correctly load and display the rendered content, "srcdoc" won't work.
|
||||
iframe.src = u.href;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {FrontendRenderFunc} from '../plugin.ts';
|
||||
|
||||
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'),
|
||||
]);
|
||||
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',
|
||||
});
|
||||
// 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
|
||||
opts.container.querySelector<HTMLElement>('.ap-term')!.style.overflow = 'hidden';
|
||||
opts.container.querySelector<HTMLElement>('.ap-player')!.style.borderRadius = '0';
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -14,5 +14,3 @@ window.config = {
|
||||
i18n: {},
|
||||
frontendInited: false,
|
||||
};
|
||||
|
||||
window.testModules = {};
|
||||
|
||||
Reference in New Issue
Block a user