mirror of
https://github.com/go-gitea/gitea
synced 2026-06-14 14:37:04 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f25811942c | ||
|
|
c0c11c551c | ||
|
|
e99e24cb04 | ||
|
|
c20df84548 | ||
|
|
24ce5ae082 |
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
||||
[security]
|
||||
INSTALL_LOCK = $INSTALL_LOCK
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||
|
||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
||||
[security]
|
||||
INSTALL_LOCK = $INSTALL_LOCK
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
// register supported doc types
|
||||
_ "gitea.dev/modules/markup/console"
|
||||
_ "gitea.dev/modules/markup/csv"
|
||||
_ "gitea.dev/modules/markup/jupyter"
|
||||
_ "gitea.dev/modules/markup/markdown"
|
||||
_ "gitea.dev/modules/markup/orgmode"
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
//
|
||||
@@ -118,6 +119,28 @@ func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
||||
return totp.Validate(passcode, secretStr), nil
|
||||
}
|
||||
|
||||
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
|
||||
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
|
||||
// invalid passcode as well as for a replay, including the case where a concurrent request with
|
||||
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
|
||||
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
|
||||
ok, err := t.ValidateTOTP(passcode)
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||
t.LastUsedPasscode = passcode
|
||||
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||
Cols("last_used_passcode").Update(t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// NewTwoFactor creates a new two-factor authentication token.
|
||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||
_, err := db.GetEngine(ctx).Insert(t)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
|
||||
require.NoError(t, err)
|
||||
|
||||
tfa := &auth_model.TwoFactor{UID: 1}
|
||||
require.NoError(t, tfa.SetSecret(key.Secret()))
|
||||
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||
|
||||
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
// first use of a valid passcode succeeds
|
||||
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||
require.NoError(t, err)
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
|
||||
// an invalid passcode is rejected without consuming anything
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package htmlutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -89,52 +88,6 @@ func EscapeString(s string) template.HTML {
|
||||
return template.HTML(template.HTMLEscapeString(s))
|
||||
}
|
||||
|
||||
type HTMLWriter interface {
|
||||
OriginWriter() io.Writer
|
||||
WriteString(s string) HTMLWriter
|
||||
WriteHTML(s template.HTML) HTMLWriter
|
||||
WriteFormat(fmt template.HTML, args ...any) HTMLWriter
|
||||
Err() error
|
||||
}
|
||||
|
||||
type htmlWriter struct {
|
||||
w io.Writer
|
||||
errs []error
|
||||
}
|
||||
|
||||
func (h *htmlWriter) OriginWriter() io.Writer {
|
||||
return h.w
|
||||
}
|
||||
|
||||
func (h *htmlWriter) WriteString(s string) HTMLWriter {
|
||||
if _, err := io.WriteString(h.w, template.HTMLEscapeString(s)); err != nil {
|
||||
h.errs = append(h.errs, err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *htmlWriter) WriteHTML(s template.HTML) HTMLWriter {
|
||||
if _, err := io.WriteString(h.w, string(s)); err != nil {
|
||||
h.errs = append(h.errs, err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *htmlWriter) WriteFormat(fmt template.HTML, args ...any) HTMLWriter {
|
||||
if _, err := HTMLPrintf(h.w, fmt, args...); err != nil {
|
||||
h.errs = append(h.errs, err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *htmlWriter) Err() error {
|
||||
return errors.Join(h.errs...)
|
||||
}
|
||||
|
||||
func NewHTMLWriter(w io.Writer) HTMLWriter {
|
||||
return &htmlWriter{w: w}
|
||||
}
|
||||
|
||||
type HTMLBuilder struct {
|
||||
sb strings.Builder
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package htmlutil
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -30,11 +29,3 @@ func TestHTMLBuilder(t *testing.T) {
|
||||
assert.Equal(t, "<<hr><span>>></span>", b.String())
|
||||
assert.Equal(t, template.HTML("<<hr><span>>></span>"), b.HTMLString())
|
||||
}
|
||||
|
||||
func TestHTMLWriter(t *testing.T) {
|
||||
sb := new(strings.Builder)
|
||||
w := NewHTMLWriter(sb)
|
||||
w.WriteString("<").WriteHTML("<hr>").WriteFormat("<span>%s%s</span>", ">", EscapeString(">"))
|
||||
assert.Equal(t, "<<hr><span>>></span>", sb.String())
|
||||
assert.NoError(t, w.Err())
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["print('very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong')"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "execute_result",
|
||||
"text": ["very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong ...\n"]
|
||||
},
|
||||
{
|
||||
"output_type": "stream",
|
||||
"name": "stdout",
|
||||
"text": ["stdout 1 ...\n", "stdout 2 ...\n"]
|
||||
},
|
||||
{
|
||||
"output_type": "stream",
|
||||
"name": "stderr",
|
||||
"text": ["stderr ...\n"]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": ["data text 1\n", "data text 2\n"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": ["<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2000\" height=\"20\"><rect width=\"2000\" height=\"20\" x=\"0\" y=\"0\" rx=\"5\" ry=\"5\" fill=\"red\"/></svg>"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/html": "<a href='/'>HTML Link</a>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/latex": "$$a=1$$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": "plain text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_type": "error",
|
||||
"ename": "Error Name",
|
||||
"traceback": ["stacktrace 1", "stacktrace 2"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "unknown-cell"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"source": [
|
||||
"# h1\n", "## h2\n", "### h3\n", "\n", "paragraph 1\n", "\n",
|
||||
"very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\n",
|
||||
"- list item 1\n", "- list item 2\n", "\n", "```python\n", "print('code block')\n", "```\n",
|
||||
"<table><tr><th>th1</th><th>th2</th></tr><tr><td>td1</td><td>td2</td></tr></table>\n"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jupyter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/highlight"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(renderer{})
|
||||
}
|
||||
|
||||
// Renderer implements markup.Renderer for Jupyter notebooks
|
||||
type renderer struct{}
|
||||
|
||||
var (
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.PostProcessRenderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
)
|
||||
|
||||
type mimeHandler struct {
|
||||
Mime string
|
||||
Fn func(w htmlutil.HTMLWriter, data string) error
|
||||
}
|
||||
|
||||
func renderCellCodeOutputTextPlain(w htmlutil.HTMLWriter, text string) error {
|
||||
w.WriteFormat(`<div class="cell-output-text"><pre>%s</pre></div>`, text)
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func renderCellCodeOutputUnsupported(w htmlutil.HTMLWriter, message string) error {
|
||||
w.WriteFormat(`<div class="cell-output-unsupported">%s</div>`, message)
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
var dataMimeHandlers = sync.OnceValue(func() []mimeHandler {
|
||||
renderImage := func(w htmlutil.HTMLWriter, subtype, payload string) error {
|
||||
w.WriteFormat(`<div class="cell-output-image"><img src="data:image/%s;base64,%s"></div>`, subtype, payload)
|
||||
return w.Err()
|
||||
}
|
||||
renderUnsupportedOutput := func(message string) func(htmlutil.HTMLWriter, string) error {
|
||||
return func(w htmlutil.HTMLWriter, _ string) error {
|
||||
return renderCellCodeOutputUnsupported(w, message)
|
||||
}
|
||||
}
|
||||
return []mimeHandler{
|
||||
// Images (PNG, JPEG, SVG)
|
||||
{"image/png", func(w htmlutil.HTMLWriter, d string) error {
|
||||
return renderImage(w, "png", d)
|
||||
}},
|
||||
{"image/jpeg", func(w htmlutil.HTMLWriter, d string) error {
|
||||
return renderImage(w, "jpeg", d)
|
||||
}},
|
||||
{"image/svg+xml", func(w htmlutil.HTMLWriter, d string) error {
|
||||
return renderImage(w, "svg+xml", base64.StdEncoding.EncodeToString(util.UnsafeStringToBytes(d)))
|
||||
}},
|
||||
|
||||
// Rich & Math Layouts
|
||||
{"text/html", func(w htmlutil.HTMLWriter, d string) error {
|
||||
// To future developers: don't allow custom CSS classes or attributes,
|
||||
// because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS.
|
||||
// If you'd really like to support more, do remember to correctly sanitize the values.
|
||||
w.WriteFormat(`<div class="cell-output-html">%s</div>`, markup.Sanitize(d))
|
||||
return w.Err()
|
||||
}},
|
||||
{"text/latex", func(w htmlutil.HTMLWriter, d string) error {
|
||||
w.WriteFormat(`<div class="cell-output-latex"><pre><code class="language-math display">%s</code></pre></div>`, trimMathDelimiters(d))
|
||||
return w.Err()
|
||||
}},
|
||||
{"text/plain", renderCellCodeOutputTextPlain},
|
||||
|
||||
// Security Placeholders
|
||||
{"application/javascript", renderUnsupportedOutput("[JavaScript output - execution disabled for security]")},
|
||||
{"application/vnd.plotly.v1+json", renderUnsupportedOutput("[Plotly output - interactive plots not supported]")},
|
||||
{"application/vnd.jupyter.widget-view+json", renderUnsupportedOutput("[Jupyter widget - interactive widgets not supported]")},
|
||||
}
|
||||
})
|
||||
|
||||
func (renderer) Name() string {
|
||||
return "jupyter-render"
|
||||
}
|
||||
|
||||
func (renderer) NeedPostProcess() bool { return true }
|
||||
|
||||
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
||||
return markup.ExternalRendererOptions{
|
||||
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
||||
// This render must guarantee that the output is safe and no XSS
|
||||
SanitizerDisabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (renderer) FileNamePatterns() []string {
|
||||
return []string{"*.ipynb"}
|
||||
}
|
||||
|
||||
func (renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Notebook structures
|
||||
type Notebook struct {
|
||||
Cells []Cell `json:"cells"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Nbformat int `json:"nbformat"`
|
||||
}
|
||||
|
||||
type Cell struct {
|
||||
CellType string `json:"cell_type"`
|
||||
Source any `json:"source"` // string or []string
|
||||
Outputs []Output `json:"outputs,omitempty"`
|
||||
ExecutionCount any `json:"execution_count,omitempty"` // int or null
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
OutputType string `json:"output_type"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Text any `json:"text,omitempty"` // string or []string
|
||||
Name string `json:"name,omitempty"`
|
||||
Traceback any `json:"traceback,omitempty"` // []string
|
||||
Ename string `json:"ename,omitempty"`
|
||||
Evalue string `json:"evalue,omitempty"`
|
||||
}
|
||||
|
||||
// Render renders Jupyter notebook to HTML
|
||||
func (renderer) Render(ctx *markup.RenderContext, input io.Reader, outputWriter io.Writer) error {
|
||||
htmlWriter := htmlutil.NewHTMLWriter(outputWriter)
|
||||
// the size is (should be) checked and/or limited by the caller to avoid OOM
|
||||
var notebook Notebook
|
||||
if err := json.NewDecoder(input).Decode(¬ebook); err != nil {
|
||||
htmlWriter.WriteFormat(`<div class="ui error message">Failed to parse notebook JSON: %v</div>`, err)
|
||||
return htmlWriter.Err()
|
||||
}
|
||||
|
||||
// Check nbformat version
|
||||
if notebook.Nbformat < 4 {
|
||||
htmlWriter.WriteFormat(
|
||||
`<div class="ui info message">This notebook uses an older format (nbformat %d). Only nbformat 4+ is supported for rendering. Please upgrade the notebook in Jupyter or view the raw JSON.</div>`,
|
||||
notebook.Nbformat,
|
||||
)
|
||||
return htmlWriter.Err()
|
||||
}
|
||||
|
||||
// Detect language
|
||||
language := "python" // default
|
||||
if metadata, ok := notebook.Metadata["language_info"].(map[string]any); ok {
|
||||
if name, ok := metadata["name"].(string); ok {
|
||||
language = name
|
||||
}
|
||||
} else if kernelSpec, ok := notebook.Metadata["kernelspec"].(map[string]any); ok {
|
||||
if lang, ok := kernelSpec["language"].(string); ok {
|
||||
language = lang
|
||||
}
|
||||
}
|
||||
|
||||
// Start rendering
|
||||
htmlWriter.WriteHTML(`<div class="jupyter-notebook">`)
|
||||
|
||||
// limiting the cell rendering to 100 cells
|
||||
cells := notebook.Cells
|
||||
truncated := false
|
||||
const maxRenderedCells = 100
|
||||
|
||||
if len(cells) > maxRenderedCells {
|
||||
cells = cells[:maxRenderedCells] // Slice down to exactly 100 elements instantly at the pointer layer
|
||||
truncated = true
|
||||
}
|
||||
|
||||
for _, cell := range cells {
|
||||
if err := renderCell(ctx, htmlWriter, cell, language); err != nil {
|
||||
log.Warn("Failed to render cell: %v", err) // TODO: RENDER-LOG-HANDLING: see other comments
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if truncated {
|
||||
htmlWriter.WriteHTML(`<div class="ui warning message">`)
|
||||
htmlWriter.WriteHTML(`<strong>Output truncated.</strong> This notebook contains too many cells to display efficiently.`)
|
||||
htmlWriter.WriteHTML(`</div>`)
|
||||
}
|
||||
|
||||
htmlWriter.WriteHTML(`</div>`)
|
||||
return htmlWriter.Err()
|
||||
}
|
||||
|
||||
func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) error {
|
||||
source := joinSource(cell.Source)
|
||||
var executionCount *int64
|
||||
if cell.ExecutionCount != nil {
|
||||
if count, err := util.ToInt64(cell.ExecutionCount); err == nil {
|
||||
executionCount = &count
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteHTML(`<div class="cell-line">`)
|
||||
{
|
||||
if executionCount != nil {
|
||||
output.WriteFormat(`<div class="cell-left cell-prompt">In [%d]:</div>`, *executionCount)
|
||||
} else {
|
||||
output.WriteHTML(`<div class="cell-left cell-prompt">In [ ]:</div>`)
|
||||
}
|
||||
|
||||
// Highlight code
|
||||
lexer := highlight.DetectChromaLexerByFileName("", language)
|
||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language))
|
||||
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
|
||||
output.WriteHTML("</code></pre></div>")
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
|
||||
// Render outputs
|
||||
if len(cell.Outputs) > 0 {
|
||||
hasExecutionResult := false
|
||||
for _, out := range cell.Outputs {
|
||||
if out.OutputType == "execute_result" {
|
||||
hasExecutionResult = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteHTML(`<div class="cell-line">`)
|
||||
{
|
||||
if hasExecutionResult && executionCount != nil {
|
||||
output.WriteFormat(`<div class="cell-left cell-prompt">Out [%d]:</div>`, *executionCount)
|
||||
} else {
|
||||
output.WriteHTML(`<div class="cell-left cell-prompt"></div>`)
|
||||
}
|
||||
|
||||
output.WriteHTML(`<div class="cell-right cell-output">`)
|
||||
for _, out := range cell.Outputs {
|
||||
renderCellCodeOutput(output, out)
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
}
|
||||
|
||||
return output.Err()
|
||||
}
|
||||
|
||||
func renderCell(ctx *markup.RenderContext, output htmlutil.HTMLWriter, cell Cell, language string) error {
|
||||
switch cell.CellType {
|
||||
case "markdown":
|
||||
output.WriteHTML(`
|
||||
<div class="notebook-cell cell-type-markdown">
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt"></div>
|
||||
<div class="cell-right">`)
|
||||
if err := renderCellMarkdown(ctx, output, joinSource(cell.Source)); err != nil {
|
||||
return err
|
||||
}
|
||||
output.WriteHTML(`</div></div></div>`)
|
||||
case "code":
|
||||
output.WriteHTML(`<div class="notebook-cell cell-type-code">`)
|
||||
if err := renderCellCode(output, cell, language); err != nil {
|
||||
return err
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
default:
|
||||
output.WriteFormat(`
|
||||
<div class="notebook-cell">
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">Cell:</div>
|
||||
<div class="cell-right cell-prompt">[Cell type %s - unsupported, skipped]</div>
|
||||
</div>
|
||||
</div>`, cell.CellType)
|
||||
}
|
||||
return output.Err()
|
||||
}
|
||||
|
||||
func renderCellMarkdown(rctx *markup.RenderContext, output htmlutil.HTMLWriter, source string) error {
|
||||
markdownCtx := markup.NewRenderContext(rctx)
|
||||
// make sure the markdown render use the same options and helper to generate correct contents (e.g.: links)
|
||||
markdownCtx.RenderOptions = rctx.RenderOptions
|
||||
markdownCtx.RenderHelper = rctx.RenderHelper
|
||||
output.WriteHTML(`<div class="embedded-markdown">`)
|
||||
if err := markdown.Render(markdownCtx, strings.NewReader(source), output.OriginWriter()); err != nil {
|
||||
return err
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
return output.Err()
|
||||
}
|
||||
|
||||
func renderCellCodeOutput(output htmlutil.HTMLWriter, out Output) {
|
||||
if out.Data != nil {
|
||||
// Iterate through our priority list to find the best matching MIME handler available
|
||||
for _, h := range dataMimeHandlers() {
|
||||
if rawPayload, exists := out.Data[h.Mime]; exists {
|
||||
var stringPayload string
|
||||
|
||||
// Flatten the polymorphic JSON input (string or []any) into a single clean string
|
||||
switch v := rawPayload.(type) {
|
||||
case string:
|
||||
stringPayload = v
|
||||
case []any:
|
||||
stringPayload = joinSource(v)
|
||||
default:
|
||||
_ = renderCellCodeOutputUnsupported(output, fmt.Sprintf("[Data output - unsupported data type %T for mime type %s]", rawPayload, h.Mime))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.Fn(output, stringPayload); err != nil {
|
||||
// TODO: RENDER-LOG-HANDLING: outputting render's error to sever's log is not a proper approach
|
||||
// The errors can be:
|
||||
// * unsupported element (cell, data, etc): it should render the message on the UI to tell users that the content is not supported, or ignore them if they are ignore-able
|
||||
// * logic error: it should report to server logs
|
||||
// * network error: io.Writer tries to write to the HTTP connection, so the error can also be a network error, such error should be ignored
|
||||
log.Error("Jupyter rendering engine failed for MIME type %s: %v", h.Mime, err)
|
||||
}
|
||||
|
||||
// Return immediately after rendering the top matching priority format
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream output
|
||||
if out.OutputType == "stream" && out.Text != nil {
|
||||
streamName := util.Iif(out.Name == "stderr", "stderr", "stdout")
|
||||
output.WriteFormat(`<pre class="cell-output-stream stream-%s">%s</pre>`, streamName, joinSource(out.Text))
|
||||
return
|
||||
}
|
||||
|
||||
// Error output
|
||||
if out.OutputType == "error" {
|
||||
traceback := ""
|
||||
if tb, ok := out.Traceback.([]any); ok {
|
||||
lines := make([]string, len(tb))
|
||||
for i, line := range tb {
|
||||
lines[i] = fmt.Sprint(line)
|
||||
}
|
||||
traceback = strings.Join(lines, "\n")
|
||||
}
|
||||
if traceback == "" && out.Ename != "" {
|
||||
traceback = fmt.Sprintf("%s: %s", out.Ename, out.Evalue)
|
||||
}
|
||||
output.WriteFormat(`<pre class="cell-output-error">%s</pre>`, traceback)
|
||||
return
|
||||
}
|
||||
|
||||
// Generic text output
|
||||
if out.Text != nil {
|
||||
_ = renderCellCodeOutputTextPlain(output, joinSource(out.Text))
|
||||
}
|
||||
}
|
||||
|
||||
func joinSource(source any) string {
|
||||
switch v := source.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case []any:
|
||||
// the "source slice item" has EOL ("\n"), so just join them together
|
||||
parts := make([]string, len(v))
|
||||
for i, part := range v {
|
||||
parts[i] = fmt.Sprint(part)
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// trimMathDelimiters strips a single pair of surrounding math delimiters ("$$...$$" or "$...$"),
|
||||
// so the inner expression is handled by the math post-processor. Unlike strings.Trim, it does not
|
||||
// eat unrelated "$" characters elsewhere in multi-expression content.
|
||||
func trimMathDelimiters(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if t, ok := strings.CutPrefix(s, "$$"); ok {
|
||||
return strings.TrimSuffix(t, "$$")
|
||||
}
|
||||
if t, ok := strings.CutPrefix(s, "$"); ok {
|
||||
return strings.TrimSuffix(t, "$")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jupyter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
r := renderer{}
|
||||
|
||||
t.Run("Basic notebook", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["print('hello')"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "stream",
|
||||
"name": "stdout",
|
||||
"text": ["hello\n"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := &markup.RenderContext{}
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.Contains(t, result, `<div class="jupyter-notebook">`)
|
||||
assert.Contains(t, result, `<div class="notebook-cell cell-type-code">`)
|
||||
assert.Contains(t, result, `In [1]:`)
|
||||
assert.Contains(t, result, `print`)
|
||||
assert.Contains(t, result, `hello`)
|
||||
assert.Contains(t, result, `stream-stdout`)
|
||||
})
|
||||
|
||||
t.Run("Markdown cell with XSS Protection", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"source": [
|
||||
"# Title\n",
|
||||
"Some text\n",
|
||||
"[click me](javascript:alert(1))\n",
|
||||
"<script>alert('dangerous')</script>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
|
||||
// Assert normal markup still renders correctly
|
||||
assert.Contains(t, result, `<div class="notebook-cell cell-type-markdown">`)
|
||||
assert.Contains(t, result, `Title`)
|
||||
assert.Contains(t, result, `Some text`)
|
||||
assert.Contains(t, result, `click me`)
|
||||
|
||||
// CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped
|
||||
assert.NotContains(t, result, `javascript:alert`)
|
||||
assert.NotContains(t, result, `<script>`)
|
||||
})
|
||||
|
||||
t.Run("Cell limit truncation guardrail", func(t *testing.T) {
|
||||
// Generate an oversized notebook containing 105 cells dynamically
|
||||
var cellBlocks []string
|
||||
for range 105 {
|
||||
cellBlocks = append(cellBlocks, `{"cell_type": "markdown", "source": ["cell text"]}`)
|
||||
}
|
||||
input := fmt.Sprintf(`{"cells": [%s], "metadata": {}, "nbformat": 4}`, strings.Join(cellBlocks, ","))
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
|
||||
// Verify it halts rendering gracefully and shows the truncation warning
|
||||
assert.Contains(t, result, "Output truncated.")
|
||||
assert.Contains(t, result, "This notebook contains too many cells to display efficiently.")
|
||||
|
||||
// Count occurrences of the rendered cells to ensure it sliced down to exactly 100 elements
|
||||
assert.Equal(t, 100, strings.Count(result, `class="notebook-cell cell-type-markdown"`))
|
||||
})
|
||||
|
||||
t.Run("Image output", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["import matplotlib.pyplot as plt"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "display_data",
|
||||
"data": {
|
||||
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.Contains(t, result, `<img src="data:image/png;base64,`)
|
||||
assert.Contains(t, result, `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`)
|
||||
})
|
||||
|
||||
t.Run("HTML output with style tag", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["import pandas as pd"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "execute_result",
|
||||
"data": {
|
||||
"text/html": ["<style scoped>.dataframe tbody tr th { vertical-align: top; }</style><table class=\"dataframe\"><tr><td>1</td></tr></table>"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.NotContains(t, result, `<style scoped>`)
|
||||
assert.Contains(t, result, `<table><tr><td>1</td></tr></table>`)
|
||||
assert.Contains(t, result, `<td>1</td>`)
|
||||
})
|
||||
|
||||
t.Run("Error output", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["raise ValueError('test error')"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "error",
|
||||
"ename": "ValueError",
|
||||
"evalue": "test error",
|
||||
"traceback": ["ValueError: test error"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.Contains(t, result, `ValueError: test error`)
|
||||
assert.Contains(t, result, `cell-output-error`)
|
||||
})
|
||||
|
||||
t.Run("Old nbformat version", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [],
|
||||
"metadata": {},
|
||||
"nbformat": 3
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Regexp(t, `<div class="ui info message">This notebook uses an older format.*</div>`, output.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "String input",
|
||||
input: "hello world",
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "Array input",
|
||||
input: []any{"line1\n", "line2\n", "line3"},
|
||||
expected: "line1\nline2\nline3",
|
||||
},
|
||||
{
|
||||
name: "Empty array",
|
||||
input: []any{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Single element array",
|
||||
input: []any{"single"},
|
||||
expected: "single",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := joinSource(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAndSanitization(t *testing.T) {
|
||||
// A mock malicious Jupyter notebook containing an XSS injection attempt
|
||||
// inside a text/html output cell (e.g., pretending to be a poisoned Pandas DataFrame).
|
||||
maliciousNotebook := `{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2,
|
||||
"metadata": {},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"source": ["a=1"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "execute_result",
|
||||
"execution_count": 1,
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
|
||||
]
|
||||
},
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
ctx.RenderOptions.MarkupType = "jupyter-render"
|
||||
err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output)
|
||||
assert.NoError(t, err)
|
||||
const expected = `
|
||||
<div class="jupyter-notebook">
|
||||
<div class="notebook-cell cell-type-code">
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">In [1]:</div>
|
||||
<div class="cell-right cell-input">
|
||||
<pre><code class="chroma language-python">
|
||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||
<div class="cell-right cell-output">
|
||||
<div class="cell-output-html">
|
||||
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String()))
|
||||
}
|
||||
@@ -12,16 +12,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// RedirectURL returns the redirect URL of a http response.
|
||||
@@ -186,48 +182,3 @@ func ExternalServiceHTTP(t TestingT, envVarName, def string) string {
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
var normalizeHTMLSpacesRegexp = sync.OnceValue(func() (ret struct {
|
||||
afterRt, beforeLt *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
ret.afterRt = regexp.MustCompile(`>\s*`)
|
||||
ret.beforeLt = regexp.MustCompile(`\s*<`)
|
||||
return ret
|
||||
})
|
||||
|
||||
func NormalizeHTMLSpaces(s string) string {
|
||||
vars := normalizeHTMLSpacesRegexp()
|
||||
s = vars.afterRt.ReplaceAllString(s, ">\n")
|
||||
s = vars.beforeLt.ReplaceAllString(s, "\n<")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func NormalizeHTMLAttributes(t TestingT, s string) string {
|
||||
nodes, err := html.Parse(strings.NewReader(s))
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse expected HTML: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var normalize func(n *html.Node)
|
||||
normalize = func(n *html.Node) {
|
||||
slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
|
||||
if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(a.Val, b.Val)
|
||||
})
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
normalize(c)
|
||||
}
|
||||
}
|
||||
var sb strings.Builder
|
||||
if err = html.Render(&sb, nodes); err != nil {
|
||||
t.Errorf("failed to render HTML: %v", err)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
+16
-1
@@ -504,6 +504,21 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
|
||||
func reqOrgVisible() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
if ctx.Org.Organization == nil {
|
||||
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
|
||||
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||
return
|
||||
}
|
||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reqTeamMembership user should be an team member, or a site admin
|
||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
@@ -1673,7 +1688,7 @@ func Routes() *web.Router {
|
||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||
})
|
||||
}, reqOrgVisible())
|
||||
m.Group("/hooks", func() {
|
||||
m.Combo("").Get(org.ListHooks).
|
||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||
|
||||
@@ -1336,6 +1336,9 @@ func MergeUpstream(ctx *context.APIContext) {
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
return
|
||||
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.APIError(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
|
||||
@@ -40,9 +40,6 @@ type preReceiveContext struct {
|
||||
canCreatePullRequest bool
|
||||
checkedCanCreatePullRequest bool
|
||||
|
||||
canWriteCode bool
|
||||
checkedCanWriteCode bool
|
||||
|
||||
protectedTags []*git_model.ProtectedTag
|
||||
gotProtectedTags bool
|
||||
|
||||
@@ -55,14 +52,13 @@ type preReceiveContext struct {
|
||||
|
||||
// CanWriteCode returns true if pusher can write code
|
||||
func (ctx *preReceiveContext) CanWriteCode() bool {
|
||||
if !ctx.checkedCanWriteCode {
|
||||
if !ctx.loadPusherAndPermission() {
|
||||
return false
|
||||
}
|
||||
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
ctx.checkedCanWriteCode = true
|
||||
if !ctx.loadPusherAndPermission() {
|
||||
return false
|
||||
}
|
||||
return ctx.canWriteCode
|
||||
// Must not be cached: CanMaintainerWriteToBranch is evaluated against ctx.branchName, which
|
||||
// differs for each ref in a batch push. Caching the first result would let a per-branch
|
||||
// maintainer-edit grant on one ref authorize writes to every other ref in the same push.
|
||||
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||
}
|
||||
|
||||
// AssertCanWriteCode returns true if pusher can write code
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package private
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||
// the current branch on every call, instead of being cached from the first ref of a batch push.
|
||||
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
// together with a protected branch to escalate into full repository write.
|
||||
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
|
||||
require.NoError(t, baseRepo.LoadOwner(t.Context()))
|
||||
require.NoError(t, headRepo.LoadOwner(t.Context()))
|
||||
|
||||
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
|
||||
// repo owner write access to exactly this head branch and nothing else.
|
||||
pr := &issues_model.PullRequest{
|
||||
Issue: &issues_model.Issue{
|
||||
RepoID: baseRepo.ID,
|
||||
PosterID: headRepo.OwnerID,
|
||||
},
|
||||
HeadRepoID: headRepo.ID,
|
||||
BaseRepoID: baseRepo.ID,
|
||||
HeadBranch: "granted-branch",
|
||||
BaseBranch: "master",
|
||||
AllowMaintainerEdit: true,
|
||||
}
|
||||
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||
|
||||
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||
maintainer := baseRepo.Owner
|
||||
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{
|
||||
PrivateContext: mockCtx,
|
||||
loadedPusher: true,
|
||||
user: maintainer,
|
||||
userPerm: headPerm,
|
||||
}
|
||||
|
||||
// The granted branch must be writable...
|
||||
ctx.branchName = "granted-branch"
|
||||
assert.True(t, ctx.CanWriteCode())
|
||||
|
||||
// ...but another branch in the same push must NOT inherit that grant.
|
||||
ctx.branchName = "master"
|
||||
assert.False(t, ctx.CanWriteCode())
|
||||
}
|
||||
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the passcode with the stored TOTP secret.
|
||||
ok, err := twofa.ValidateTOTP(form.Passcode)
|
||||
// Validate the passcode and atomically consume it to prevent reuse/replay.
|
||||
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok && twofa.LastUsedPasscode != form.Passcode {
|
||||
if ok {
|
||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||
u, err := user_model.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
twofa.LastUsedPasscode = form.Passcode
|
||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||
handleSignIn(ctx, u, remember)
|
||||
return
|
||||
|
||||
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
|
||||
regenerateScratchToken = true
|
||||
} else {
|
||||
passcode := ctx.FormString("passcode")
|
||||
ok, err := twofa.ValidateTOTP(passcode)
|
||||
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
||||
return
|
||||
}
|
||||
if !ok || twofa.LastUsedPasscode == passcode {
|
||||
if !ok {
|
||||
ctx.Data["IsResetForm"] = true
|
||||
ctx.Data["Err_Passcode"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
||||
return
|
||||
}
|
||||
|
||||
twofa.LastUsedPasscode = passcode
|
||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
||||
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
|
||||
branchName := ctx.FormString("branch")
|
||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.JSONErrorNotFound()
|
||||
return
|
||||
} else if pull_service.IsErrMergeConflicts(err) {
|
||||
|
||||
@@ -176,7 +176,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
|
||||
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
issue_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
@@ -26,6 +28,17 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
|
||||
if err = repo.GetBaseRepo(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// The doer must still be able to read the base repository's code. Otherwise a fork created
|
||||
// while the base repo was public could keep pulling commits after it turned private.
|
||||
basePerm, err := access_model.GetDoerRepoPermission(ctx, repo.BaseRepo, doer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !basePerm.CanRead(unit.TypeCode) {
|
||||
return "", util.NewPermissionDeniedErrorf("permission denied to read base repo %d", repo.BaseRepo.ID)
|
||||
}
|
||||
|
||||
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
org_model "gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -292,3 +293,50 @@ func testAPIDeleteOrgRepos(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIOrgLabelsVisibility ensures the organization label read endpoints honor
|
||||
// the organization visibility: labels of a private org must not be disclosed to
|
||||
// users who cannot see the org (GHSA: unauthorized access to private org labels).
|
||||
func TestAPIOrgLabelsVisibility(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// privated_org (id 23) is a private organization; user5 is its only member.
|
||||
privateOrg := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 23})
|
||||
label := &issues_model.Label{OrgID: privateOrg.ID, Name: "internal-label", Color: "#aabbcc", Description: "private organization label"}
|
||||
require.NoError(t, issues_model.NewLabel(t.Context(), label))
|
||||
|
||||
listURL := fmt.Sprintf("/api/v1/orgs/%s/labels", privateOrg.Name)
|
||||
getURL := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", privateOrg.Name, label.ID)
|
||||
|
||||
t.Run("NonMemberDenied", func(t *testing.T) {
|
||||
// user2 is not a member of the private org and must not see its labels.
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
|
||||
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusNotFound)
|
||||
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("AnonymousDenied", func(t *testing.T) {
|
||||
MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound)
|
||||
MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("MemberAllowed", func(t *testing.T) {
|
||||
token := getUserToken(t, "user5", auth_model.AccessTokenScopeReadOrganization)
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
|
||||
labels := DecodeJSON(t, resp, &[]*api.Label{})
|
||||
assert.Len(t, *labels, 1)
|
||||
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("SiteAdminAllowed", func(t *testing.T) {
|
||||
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
|
||||
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
|
||||
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("PublicOrgStillReadable", func(t *testing.T) {
|
||||
// org3 (id 3) is a public org with labels; non-members may read them.
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
|
||||
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/labels").AddTokenAuth(token), http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) {
|
||||
AddBasicAuth(user.Name)
|
||||
req.Header.Set("X-Gitea-OTP", passcode)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// the same passcode must not be replayable on the basic-auth surface (RFC 6238 single-use)
|
||||
req = NewRequest(t, "GET", "/api/v1/user").
|
||||
AddBasicAuth(user.Name)
|
||||
req.Header.Set("X-Gitea-OTP", passcode)
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestBasicAuthWithWebAuthn(t *testing.T) {
|
||||
|
||||
@@ -5,12 +5,13 @@ package integration
|
||||
|
||||
import (
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// HTMLDoc struct
|
||||
@@ -52,10 +53,36 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string
|
||||
|
||||
func assertHTMLEq(t testing.TB, expected, actual string) {
|
||||
t.Helper()
|
||||
if expected == actual { // fast path
|
||||
if expected == actual {
|
||||
return
|
||||
}
|
||||
exp := test.NormalizeHTMLAttributes(t, expected)
|
||||
act := test.NormalizeHTMLAttributes(t, actual)
|
||||
assert.Equal(t, exp, act)
|
||||
exp, err := html.Parse(strings.NewReader(expected))
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
act, err := html.Parse(strings.NewReader(actual))
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
var normalize func(n *html.Node)
|
||||
normalize = func(n *html.Node) {
|
||||
slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
|
||||
if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(a.Val, b.Val)
|
||||
})
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
normalize(c)
|
||||
}
|
||||
}
|
||||
normalize(exp)
|
||||
normalize(act)
|
||||
var expNormalized, actNormalized strings.Builder
|
||||
assert.NoError(t, html.Render(&expNormalized, exp))
|
||||
assert.NoError(t, html.Render(&actNormalized, act))
|
||||
assert.Equal(t, expNormalized.String(), actNormalized.String())
|
||||
}
|
||||
|
||||
@@ -171,5 +171,24 @@ func TestRepoMergeUpstream(t *testing.T) {
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("BasePrivateBlocksSync", func(t *testing.T) {
|
||||
// add a new commit to the base repo, then make the base repo private
|
||||
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "secret.txt", "master", "private-content"))
|
||||
baseRepo.IsPrivate = true
|
||||
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
baseRepo.IsPrivate = false
|
||||
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// the fork owner can no longer read the base repo, so syncing must be refused
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
|
||||
Branch: "fork-branch",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
@import "./markup/content.css";
|
||||
@import "./markup/codeblock.css";
|
||||
@import "./markup/codepreview.css";
|
||||
@import "./markup/jupyter.css";
|
||||
|
||||
@import "./font_i18n.css";
|
||||
@import "./base.css";
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
.markup.jupyter-render {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook {
|
||||
padding: 20px;
|
||||
background: var(--color-body);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
font-family: var(--fonts-monospace);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
}
|
||||
|
||||
/* cell code */
|
||||
.markup .jupyter-notebook .cell-line {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-left {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-right {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-prompt {
|
||||
padding: 10px 0;
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-left.cell-prompt {
|
||||
padding-left: 10px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-right.cell-prompt {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-input,
|
||||
.markup .jupyter-notebook .cell-output {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-input pre,
|
||||
.markup .jupyter-notebook .cell-output pre {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
min-height: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-input pre {
|
||||
background-color: var(--color-code-bg);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-type-code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-output-unsupported {
|
||||
color: var(--color-text-light-2);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markup .jupyter-notebook .cell-output-error {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
/* cell markdown */
|
||||
.markup .jupyter-notebook .cell-right .embedded-markdown {
|
||||
padding: 0 16px; /* match cell code right padding */
|
||||
}
|
||||
Reference in New Issue
Block a user