From 7134c1f845f8167afe19d8abd3b9144c6b7393de Mon Sep 17 00:00:00 2001 From: metsw24-max Date: Wed, 10 Jun 2026 08:57:57 +0530 Subject: [PATCH 01/16] fix: bound debian ParseControlFile to a single control stanza (#38044) **Packages-index stanza injection via Debian control file** A `.deb` whose `control` file appends extra paragraphs after a blank line was still accepted, and `ParseControlFile` stored the whole multi-stanza blob in `p.Control`. That blob is re-emitted verbatim into the generated `Packages` index, so the embedded blank line splits it into separate stanzas and an uploader can smuggle a package entry with an attacker-chosen `Filename` into the shared index. A binary control file only holds one stanza, so parsing now stops at the blank line that terminates it; well-formed packages are unaffected and the new subtest covers the trailing-stanza case. --------- Signed-off-by: wxiaoguang Co-authored-by: wxiaoguang --- modules/packages/debian/metadata.go | 15 +++++++++++++-- modules/packages/debian/metadata_test.go | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/modules/packages/debian/metadata.go b/modules/packages/debian/metadata.go index ba12424e747..8d8b03147f6 100644 --- a/modules/packages/debian/metadata.go +++ b/modules/packages/debian/metadata.go @@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) { var depends strings.Builder var control strings.Builder - s := bufio.NewScanner(io.TeeReader(r, &control)) + // https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files + s := bufio.NewScanner(r) for s.Scan() { line := s.Text() trimmed := strings.TrimSpace(line) if trimmed == "" { - continue + // A binary package control file holds exactly one stanza. Stop at the + // blank line that terminates it, otherwise a crafted control file could + // smuggle additional stanzas (with attacker-chosen Filename/Package + // fields) into the generated repository "Packages" index. + if control.Len() == 0 { + continue + } + break } + control.WriteString(line) + control.WriteByte('\n') + if line[0] == ' ' || line[0] == '\t' { switch key { case "Description": diff --git a/modules/packages/debian/metadata_test.go b/modules/packages/debian/metadata_test.go index 9598c6cac8d..6ff10a7f210 100644 --- a/modules/packages/debian/metadata_test.go +++ b/modules/packages/debian/metadata_test.go @@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) { assert.NotNil(t, p) } }) + + t.Run("SingleStanzaOnly", func(t *testing.T) { + // A control file with a trailing stanza must not leak the extra fields into + // p.Control, otherwise buildPackagesIndices would emit a second package entry + // with an attacker-chosen Filename into the repository "Packages" index. + content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a \nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n") + + p, err := ParseControlFile(content) + assert.NoError(t, err) + assert.NotNil(t, p) + assert.Equal(t, "realpkg", p.Name) + assert.Equal(t, "1.0.0", p.Version) + assert.NotContains(t, p.Control, "openssl") + assert.NotContains(t, p.Control, "evil.deb") + }) } From a51781527b9976ba8efc291b5eaa6ea52ccf3562 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 10 Jun 2026 15:06:16 +0800 Subject: [PATCH 02/16] fix: commit display name (#38057) fix #38054 --- templates/repo/commit_page.tmpl | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 76fbf3c159a..fd9ff52ce0f 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -129,11 +129,7 @@
{{if .Author}} {{ctx.AvatarUtils.Avatar .Author 20}} - {{if .Author.FullName}} - {{.Author.FullName}} - {{else}} - {{.Commit.Author.Name}} - {{end}} + {{.Author.GetShortDisplayNameLinkHTML}} {{else}} {{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 20}} {{.Commit.Author.Name}} @@ -141,18 +137,19 @@
{{DateUtils.TimeSince .Commit.Committer.When}} -
- {{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}} + {{$committerIsAuthor := and (eq .Commit.Committer.Name .Commit.Author.Name) (eq .Commit.Committer.Email .Commit.Author.Email)}} + {{if not $committerIsAuthor}} +
{{ctx.Locale.Tr "repo.diff.committed_by"}} - {{if and .Verification.CommittingUser .Verification.CommittingUser.ID}} + {{if and .Verification.CommittingUser}} {{ctx.AvatarUtils.Avatar .Verification.CommittingUser 20}} - {{.Commit.Committer.Name}} + {{.Verification.CommittingUser.GetShortDisplayNameLinkHTML}} {{else}} - {{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Name 20}} + {{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Email 20}} {{.Commit.Committer.Name}} {{end}} - {{end}} -
+
+ {{end}} {{if .CommitOtherParticipants}}
@@ -162,16 +159,12 @@ {{$gitIdentity := $participant.GitIdentity}} {{if $user}} {{ctx.AvatarUtils.Avatar $user 20}} - {{$user.GetDisplayName}} + {{$user.GetShortDisplayNameLinkHTML}} {{else}} {{$gitName := $gitIdentity.Name}} {{$gitEmail := $gitIdentity.Email}} - {{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}} - {{if $gitEmail}} - {{$gitName}} - {{else}} - {{$gitName}} - {{end}} + {{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitEmail 20}}{{/* use the same layout as the "author" above */}} + {{$gitName}} {{end}} {{end}}
From 4ba0a545f285c6a4d054a6ae4a891b5b4681ec1e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 10 Jun 2026 15:36:44 +0800 Subject: [PATCH 03/16] chore: js html (#38056) remove unnecessary "eslint-disable-line" rules --- web_src/js/utils/dom.test.ts | 2 ++ web_src/js/utils/dom.ts | 9 +++++++-- web_src/js/utils/url.ts | 8 +++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index 3edbe94ce4a..a30b29bab2c 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -9,6 +9,8 @@ import { test('createElementFromHTML', () => { expect(createElementFromHTML('foobar').outerHTML).toEqual('foobar'); expect(createElementFromHTML('foo').outerHTML).toEqual('foo'); + expect(createElementFromHTML('foo').outerHTML).toEqual('foo'); + expect(createElementFromHTML('').outerHTML).toEqual(''); }); test('createElementFromAttrs', () => { diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index d6823fc8950..b18f33f33dc 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -267,9 +267,14 @@ export function isElemVisible(el: HTMLElement): boolean { export function createElementFromHTML(htmlString: string): T { htmlString = htmlString.trim(); + const isLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122); + const startsWithTag = (s: string, tag: string) => { + return s.startsWith('<') && + s.substring(1, 1 + tag.length).toLowerCase() === tag.toLowerCase() && + !isLetter(s[1 + tag.length].charCodeAt(0)); + }; // There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js - // eslint-disable-next-line github/unescaped-html-literal - if (htmlString.startsWith('('tr')!; diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts index 84328faf249..06e1aa29511 100644 --- a/web_src/js/utils/url.ts +++ b/web_src/js/utils/url.ts @@ -1,3 +1,5 @@ +import {html, htmlRaw} from './html.ts'; + export function urlQueryEscape(s: string) { // See "TestQueryEscape" in backend // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986 @@ -35,9 +37,9 @@ const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\ const trailingPunctPattern = /[.,;:!?]+$/; // Convert URLs to clickable links in HTML, preserving existing HTML tags -export function linkifyURLs(html: string): string { +export function linkifyURLs(htmlString: string): string { let inAnchor = false; - return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => { + return htmlString.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => { // skip URLs inside existing tags if (openTag === 'a') { inAnchor = true; @@ -54,6 +56,6 @@ export function linkifyURLs(html: string): string { const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url; const trailing = trailingPunct ? trailingPunct[0] : ''; // safe because regexp only matches valid URLs (no quotes or angle brackets) - return `${cleanUrl}${trailing}`; // eslint-disable-line github/unescaped-html-literal + return html`${htmlRaw(cleanUrl)}${htmlRaw(trailing)}`; }); } From 920b3f8cb6000b4189ba62ce3628a7c8adb41dcf Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 10 Jun 2026 10:03:36 +0200 Subject: [PATCH 04/16] fix(hostmatcher): block reserved IP ranges from external/private filters (#38039) --- modules/hostmatcher/hostmatcher.go | 63 +++++++++++++++++++++++-- modules/hostmatcher/hostmatcher_test.go | 55 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/modules/hostmatcher/hostmatcher.go b/modules/hostmatcher/hostmatcher.go index 044dba679a1..7c17bc95da5 100644 --- a/modules/hostmatcher/hostmatcher.go +++ b/modules/hostmatcher/hostmatcher.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "strings" + "sync" ) // HostMatchList is used to check if a host or IP is in a list. @@ -23,10 +24,64 @@ type HostMatchList struct { ipNets []*net.IPNet } -// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched +// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate) +// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet. const MatchBuiltinExternal = "external" -// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet. +// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be +// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer +// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are +// picked up automatically, while still covering the ranges it leaves out; otherwise the default +// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition +// endpoints (SSRF), and a "private" block-list would fail to catch them. +var reservedIPNets = sync.OnceValue(func() []*net.IPNet { + var nets []*net.IPNet + for _, cidr := range []string{ + // IPv4 + "100.64.0.0/10", // RFC 6598 Carrier-Grade NAT + "168.63.129.16/32", // Azure WireServer metadata endpoint + "192.0.0.0/24", // RFC 6890 IETF protocol assignments + "192.0.2.0/24", // RFC 5737 TEST-NET-1 + "192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated) + "198.18.0.0/15", // RFC 2544 benchmarking + "198.51.100.0/24", // RFC 5737 TEST-NET-2 + "203.0.113.0/24", // RFC 5737 TEST-NET-3 + // IPv6 + "100::/64", // RFC 6666 discard-only + "64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254) + "64:ff9b:1::/48", // RFC 8215 local-use NAT64 + "2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4) + "2001:10::/28", // RFC 4843 ORCHID (deprecated) + "2001:20::/28", // RFC 7343 ORCHIDv2 + "2001:db8::/32", // RFC 3849 documentation + "2002::/16", // RFC 3056 6to4 (embeds IPv4) + } { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error()) + } + nets = append(nets, ipNet) + } + return nets +}) + +// isPrivateIP reports whether ip falls in a private (net.IP.IsPrivate) or reserved special-purpose +// range (see reservedIPNets) that must not be considered a public/external destination. +func isPrivateIP(ip net.IP) bool { + if ip.IsPrivate() { + return true + } + for _, ipNet := range reservedIPNets() { + if ipNet.Contains(ip) { + return true + } + } + return false +} + +// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7), +// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.). +// Also called LAN/Intranet. const MatchBuiltinPrivate = "private" // MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included. @@ -100,11 +155,11 @@ func (hl *HostMatchList) checkIP(ip net.IP) bool { for _, builtin := range hl.builtins { switch builtin { case MatchBuiltinExternal: - if ip.IsGlobalUnicast() && !ip.IsPrivate() { + if ip.IsGlobalUnicast() && !isPrivateIP(ip) { return true } case MatchBuiltinPrivate: - if ip.IsPrivate() { + if isPrivateIP(ip) { return true } case MatchBuiltinLoopback: diff --git a/modules/hostmatcher/hostmatcher_test.go b/modules/hostmatcher/hostmatcher_test.go index c781847471e..61582f28d3e 100644 --- a/modules/hostmatcher/hostmatcher_test.go +++ b/modules/hostmatcher/hostmatcher_test.go @@ -159,3 +159,58 @@ func TestHostOrIPMatchesList(t *testing.T) { } test(cases) } + +// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the +// "external" allow-list (the default for webhook delivery and repository migrations) and folded into +// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints. +func TestReservedRanges(t *testing.T) { + external := ParseHostMatchList("", "external") + private := ParseHostMatchList("", "private") + + // legitimate public destinations: external, not private + for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} { + addr := net.ParseIP(ip) + assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip) + assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip) + } + + // RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate): + // not external, blockable as private. Includes range edges to guard the CIDR boundaries. + for _, ip := range []string{ + "10.0.0.0", "10.255.255.255", // 10.0.0.0/8 + "172.16.0.0", "172.31.255.255", // 172.16.0.0/12 + "192.168.0.0", "192.168.255.255", // 192.168.0.0/16 + "fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7 + } { + addr := net.ParseIP(ip) + assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip) + assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip) + } + + // 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private + if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) { + assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external") + assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private") + } + + // reserved ranges that IsPrivate does not cover: not external, but blockable as private + for _, ip := range []string{ + "100.64.0.1", // CGNAT + "100.127.255.254", // CGNAT + "168.63.129.16", // Azure WireServer + "192.0.2.1", // TEST-NET-1 + "198.18.0.1", // benchmarking + "198.51.100.1", // TEST-NET-2 + "203.0.113.1", // TEST-NET-3 + "192.88.99.1", // 6to4 relay anycast + "64:ff9b::1", // NAT64 + "64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254 + "2001::1", // Teredo + "2002::1", // 6to4 + "2001:db8::1", // documentation + } { + addr := net.ParseIP(ip) + assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip) + assert.Truef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip) + } +} From 19d1e1d33492000861cea7ca11104adfc90a7d02 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 10 Jun 2026 10:32:32 +0200 Subject: [PATCH 05/16] test: enable WAL for sqlite integration tests (#37861) Enable `SQLITE_JOURNAL_MODE = WAL` for the sqlite integration test config. With modernc as the default driver, concurrent writers serialize on SQLite's single write lock and the tail of the queue can exceed the 20s busy timeout under CI load. WAL drains the queue fast enough to stay inside the timeout (removes rollback's fsync-per-commit and reader-vs-commit blocking) and covers all sqlite integration tests in one change. --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Claude (Opus 4.7) Co-authored-by: Lunny Xiao Co-authored-by: Giteabot --- .github/workflows/files-changed.yml | 5 +++++ tests/sqlite.ini.tmpl | 1 + 2 files changed, 6 insertions(+) diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index 3c0603974e4..11ff4cec683 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -64,6 +64,11 @@ jobs: - ".golangci.yml" - ".editorconfig" - "options/locale/locale_en-US.json" + - "models/fixtures/**" + - "tests/*.ini.tmpl" + - "tests/gitea-repositories-meta/**" + - "tests/testdata/**" + - "tools/test-integration.sh" frontend: - "*.ts" diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index 95a1df283fa..a12735e06d9 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -5,6 +5,7 @@ RUN_MODE = prod [database] DB_TYPE = sqlite3 PATH = gitea-test.db +SQLITE_JOURNAL_MODE = WAL [indexer] REPO_INDEXER_ENABLED = true From fa89785d339aea96cd25bdd922a4a067288f4a24 Mon Sep 17 00:00:00 2001 From: Eugenio Paolantonio Date: Wed, 10 Jun 2026 19:34:10 +0200 Subject: [PATCH 06/16] feat(api): add Link header in ListForks (#38052) Fixes #38051. Disclosure: writing of the integration test was AI assisted. --------- Signed-off-by: Eugenio Paolantonio Co-authored-by: wxiaoguang --- routers/api/v1/repo/fork.go | 4 +++- tests/integration/api_fork_test.go | 32 +++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index 8943ad39931..9ca52436310 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -55,7 +55,8 @@ func ListForks(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx)) + listOptions := utils.GetListOptions(ctx) + forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, listOptions) if err != nil { ctx.APIErrorInternal(err) return @@ -79,6 +80,7 @@ func ListForks(ctx *context.APIContext) { apiForks[i] = convert.ToRepo(ctx, fork, permission) } + ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, apiForks) } diff --git a/tests/integration/api_fork_test.go b/tests/integration/api_fork_test.go index cf0a2e4384c..4ce2c934284 100644 --- a/tests/integration/api_fork_test.go +++ b/tests/integration/api_fork_test.go @@ -89,7 +89,7 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) { assert.Equal(t, "0", resp.Header().Get("X-Total-Count")) }) - t.Run("Logged in", func(t *testing.T) { + t.Run("LoggedIn", func(t *testing.T) { defer tests.PrintCurrentTest(t)() req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token) @@ -107,6 +107,36 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) { assert.Len(t, forks, 2) assert.Equal(t, "2", resp.Header().Get("X-Total-Count")) }) + + t.Run("RespHeaderLinks", func(t *testing.T) { + t.Run("Page1", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=1&limit=1").AddTokenAuth(user1Token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "2", resp.Header().Get("X-Total-Count")) + + linkHeader := resp.Header().Get("Link") + assert.NotEmpty(t, linkHeader, "Link header should not be empty") + assert.Contains(t, linkHeader, `rel="next"`) + assert.Contains(t, linkHeader, `rel="last"`) + assert.Contains(t, linkHeader, `/api/v1/repos/user2/repo1/forks?limit=1&page=2>`) + + forks := DecodeJSON(t, resp, []*api.Repository{}) + assert.Len(t, forks, 1) + }) + + t.Run("Page2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=2&limit=1").AddTokenAuth(user1Token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "2", resp.Header().Get("X-Total-Count")) + + forks := DecodeJSON(t, resp, []*api.Repository{}) + assert.Len(t, forks, 1) + }) + }) } func testGetPrivateReposForks(t *testing.T) { From 988f0ea54ad11315fcd912b0af7b782a951b91d4 Mon Sep 17 00:00:00 2001 From: metsw24-max Date: Wed, 10 Jun 2026 23:33:06 +0530 Subject: [PATCH 07/16] fix: validate gem name in rubygems parseMetadataFile (#38061) The registry writes the stored gem name straight into its line-based compact index, both the shared `/versions` listing (one `GEMNAME versions md5` line per gem) and the per-package `info/{name}` file. The parser only rejected an empty name or one containing a slash, so a `.gem` whose gemspec `name` carries a newline was accepted and persisted as the package name, letting an authenticated uploader forge extra lines in the shared index and so spoof additional gem names, versions and checksums to clients. The name is now checked against the upstream RubyGems name pattern in the parser, which is the layer that already validates the version. --------- Co-authored-by: wxiaoguang --- modules/packages/rubygems/metadata.go | 15 ++++++++++----- modules/packages/rubygems/metadata_test.go | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/modules/packages/rubygems/metadata.go b/modules/packages/rubygems/metadata.go index 8a989bbe7f0..2258034ba45 100644 --- a/modules/packages/rubygems/metadata.go +++ b/modules/packages/rubygems/metadata.go @@ -8,7 +8,6 @@ import ( "compress/gzip" "io" "regexp" - "strings" "sync" "gitea.dev/modules/util" @@ -26,8 +25,14 @@ var ( ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") ) -var versionMatcher = sync.OnceValue(func() *regexp.Regexp { - return regexp.MustCompile(`\A[0-9]+(?:\.[0-9a-zA-Z]+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z`) +var globalVars = sync.OnceValue(func() (ret struct { + nameMatcher, versionMatcher *regexp.Regexp +}, +) { + // https://github.com/rubygems/rubygems/blob/master/lib/rubygems/specification.rb (VALID_NAME_PATTERN) + ret.nameMatcher = regexp.MustCompile(`\A[\w.-]+\z`) + ret.versionMatcher = regexp.MustCompile(`\A[0-9]+(?:\.[0-9a-zA-Z]+)*(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z`) + return ret }) // Package represents a RubyGems package @@ -175,11 +180,11 @@ func parseMetadataFile(r io.Reader) (*Package, error) { return nil, err } - if len(spec.Name) == 0 || strings.Contains(spec.Name, "/") { + if !globalVars().nameMatcher.MatchString(spec.Name) { return nil, ErrInvalidName } - if !versionMatcher().MatchString(spec.Version.Version) { + if !globalVars().versionMatcher.MatchString(spec.Version.Version) { return nil, ErrInvalidVersion } diff --git a/modules/packages/rubygems/metadata_test.go b/modules/packages/rubygems/metadata_test.go index da75edd04af..8917bd2b36b 100644 --- a/modules/packages/rubygems/metadata_test.go +++ b/modules/packages/rubygems/metadata_test.go @@ -32,6 +32,17 @@ version: assert.NoError(t, err) assert.NotNil(t, rp) }) + + t.Run("InvalidName", func(t *testing.T) { + // a name carrying a newline would be re-emitted verbatim into the + // line-based compact index, letting an upload forge extra entries + for _, quotedName := range []string{`"evil\n1.0.0"`, `"a b"`, `"a/b"`, `""`} { + content := test.CompressGzip("name: " + quotedName + "\nversion:\n version: 1\n") + rp, err := parseMetadataFile(content) + assert.ErrorIs(t, err, ErrInvalidName, "name %s should be rejected", quotedName) + assert.Nil(t, rp) + } + }) } func TestParseMetadataFile(t *testing.T) { From 442f5e7d06461e1e828ec9fbb90555a6b71cb2c6 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 11 Jun 2026 06:44:21 +0800 Subject: [PATCH 08/16] chore: fine tune pull request merge box and commit status item (#38060) --- routers/web/repo/issue_view.go | 14 +++++--------- routers/web/repo/pull.go | 11 ++--------- routers/web/repo/pull_merge_box.go | 11 +++-------- .../repo/issue/view_content/pull_merge_box.tmpl | 2 +- templates/repo/pulls/status_items.tmpl | 2 +- web_src/css/repo.css | 4 +++- 6 files changed, 15 insertions(+), 29 deletions(-) diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index b593ad5c34d..dfff6c295b5 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -525,10 +525,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxCommitSigning(ctx *context.Con } if data.requireSigned && !data.willSign { - data.infoProtectionBlockers.AddErrorItem( - svg.RenderHTML("octicon-x"), - ctx.Locale.Tr("repo.pulls.require_signed_wont_sign"), - ) + data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.require_signed_wont_sign")) if wontSignReason != "" { data.infoProtectionBlockers.AddInfoItem( svg.RenderHTML("octicon-unlock"), @@ -1053,29 +1050,28 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxProtectedRules(ctx *context.Co if pb.EnableApprovalsWhitelist { blockerInfo = ctx.Locale.Tr("repo.pulls.blocked_by_approvals_whitelisted", grantedApprovals, pb.RequiredApprovals) } - data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), blockerInfo) + data.infoProtectionBlockers.AddErrorItem(blockerInfo) } data.isBlockedByRejection = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull) if data.isBlockedByRejection { - data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), ctx.Locale.Tr("repo.pulls.blocked_by_rejection")) + data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_rejection")) } data.isBlockedByOfficialReviewRequests = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull) if data.isBlockedByOfficialReviewRequests { - data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), ctx.Locale.Tr("repo.pulls.blocked_by_official_review_requests")) + data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_official_review_requests")) } data.isBlockedByOutdatedBranch = issues_model.MergeBlockedByOutdatedBranch(pb, pull) if data.isBlockedByOutdatedBranch { - data.infoProtectionBlockers.AddErrorItem(svg.RenderHTML("octicon-x"), ctx.Locale.Tr("repo.pulls.blocked_by_outdated_branch")) + data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_outdated_branch")) } data.isBlockedByChangedProtectedFiles = len(pull.ChangedProtectedFiles) != 0 if data.isBlockedByChangedProtectedFiles { detailItems := escapeStringSliceToHTML(pull.ChangedProtectedFiles) data.infoProtectionBlockers.AddErrorItem( - svg.RenderHTML("octicon-x"), ctx.Locale.TrN(len(pull.ChangedProtectedFiles), "repo.pulls.blocked_by_changed_protected_files_1", "repo.pulls.blocked_by_changed_protected_files_n"), detailItems, ) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index eb6d5408a52..fcd45deea0a 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -36,7 +36,6 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/optional" "gitea.dev/modules/setting" - "gitea.dev/modules/svg" "gitea.dev/modules/templates" "gitea.dev/modules/translation" "gitea.dev/modules/util" @@ -484,15 +483,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C if data.enableStatusCheck { if statusCheckData.RequiredChecksState.IsError() || statusCheckData.RequiredChecksState.IsFailure() { - data.infoProtectionBlockers.AddErrorItem( - svg.RenderHTML("octicon-x"), - ctx.Locale.Tr("repo.pulls.required_status_check_failed"), - ) + data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.required_status_check_failed")) } else if !statusCheckData.RequiredChecksState.IsSuccess() { - data.infoProtectionBlockers.AddErrorItem( - svg.RenderHTML("octicon-x"), - ctx.Locale.Tr("repo.pulls.required_status_check_missing"), - ) + data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.required_status_check_missing")) } } } diff --git a/routers/web/repo/pull_merge_box.go b/routers/web/repo/pull_merge_box.go index 471909f4253..4a167c2912f 100644 --- a/routers/web/repo/pull_merge_box.go +++ b/routers/web/repo/pull_merge_box.go @@ -13,7 +13,6 @@ import ( ) type pullMergeBoxInfoItem struct { - ItemClass string SvgIconHTML template.HTML InfoHTML template.HTML ListItems []template.HTML @@ -42,10 +41,9 @@ func (c *pullMergeBoxInfoItemCollection) AddInfoItem(svg, info template.HTML, op }) } -func (c *pullMergeBoxInfoItemCollection) AddErrorItem(svg, info template.HTML, optItems ...[]template.HTML) { +func (c *pullMergeBoxInfoItemCollection) AddErrorItem(info template.HTML, optItems ...[]template.HTML) { c.items = append(c.items, &pullMergeBoxInfoItem{ - ItemClass: "tw-text-red", - SvgIconHTML: svg, + SvgIconHTML: svg.RenderHTML("octicon-x", 16, "tw-text-red"), InfoHTML: info, ListItems: util.OptionalArg(optItems), }) @@ -151,10 +149,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxInfoItems(ctx *context.Context ctx.Locale.Tr("repo.pulls.is_empty"), ) } else { - prInfo.MergeBoxData.infoProtectionBlockers.AddErrorItem( - svg.RenderHTML("octicon-x"), - ctx.Locale.Tr("repo.pulls.cannot_auto_merge_desc"), - ) + prInfo.MergeBoxData.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.cannot_auto_merge_desc")) prInfo.MergeBoxData.infoProtectionBlockers.AddInfoItem( svg.RenderHTML("octicon-info"), ctx.Locale.Tr("repo.pulls.cannot_auto_merge_helper"), diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index d1594a52dda..8be1079e809 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -32,7 +32,7 @@ {{if $infoSection.InfoItems}}
{{range $infoItem := $infoSection.InfoItems}} -
{{$infoItem.SvgIconHTML}} {{$infoItem.InfoHTML}}
+
{{$infoItem.SvgIconHTML}} {{$infoItem.InfoHTML}}
{{if $infoItem.ListItems}}
    {{/* align with the info icon and text */}} {{range $listItem := $infoItem.ListItems}} diff --git a/templates/repo/pulls/status_items.tmpl b/templates/repo/pulls/status_items.tmpl index 415f9a8a328..fa2a5f80bf7 100644 --- a/templates/repo/pulls/status_items.tmpl +++ b/templates/repo/pulls/status_items.tmpl @@ -17,7 +17,7 @@ {{$cs.Context}} {{$cs.Description}}
-
+
{{if and $statusCheckData $statusCheckData.IsContextRequired}} {{if (call $statusCheckData.IsContextRequired $cs.Context)}}
{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 0682290e1a6..ad9a43b098c 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1855,8 +1855,10 @@ tbody.commit-list { width: 100%; } -.commit-status-item { +.commit-status-item { /* the item can be used at 2 places: PR's merge box (commit-status-list), commit's status popup (no commit-status-list) */ height: 40px; + padding-top: 0 !important; /* use "height" + "align items center", don't use padding-y (from the list container) to layout */ + padding-bottom: 0 !important; display: flex; gap: var(--gap-block); align-items: center; From bc2fbe77b13721723f1025e117886251b5114392 Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 11 Jun 2026 11:18:31 +0200 Subject: [PATCH 09/16] refactor(actions): read runner capabilities from proto field (#38068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [actions-proto-go v0.6.0](https://gitea.com/gitea/actions-proto-go) adds a `capabilities` field to `RegisterRequest` and `DeclareRequest`. This lets a runner advertise the transitional `cancelling` capability directly in the proto message instead of through the out-of-band mechanism we used while the proto bump was pending. This PR: - Bumps `gitea.dev/actions-proto-go` to `v0.6.0`. - Drops the forward-compat `capabilityGetter` type-assertion shim and the `runnerRequestHasCancellingCapability` helper, reading `GetCapabilities()` directly (now part of the `declareRequest` interface). - Removes the "capability state unknown → preserve existing value" branch. ## Why the behaviour change is correct The shim and the `(hasSupport, known)` two-value return only existed because the old proto had no `capabilities` field, so we couldn't tell "runner doesn't support it" from "we can't see the field." With v0.6.0 the field is always present. Since proto3 repeated fields have no presence, "no capabilities sent" now unambiguously means the runner does not advertise the capability, so a runner that omits `cancelling` is correctly recorded as `HasCancellingSupport = false`. There is no regression: prior to this bump Gitea was on `v0.5.0`, where the type assertion always failed and `HasCancellingSupport` was therefore never set from requests — so no runner relied on the preserved-unknown path. ## Compatibility The change is wire-compatible in both directions of version skew, because the new field uses a previously unused field number (8 on `RegisterRequest`, 3 on `DeclareRequest`) and the transport uses the binary protobuf codec: - **Old runner → new Gitea:** the runner omits the field; it decodes to an empty capability list. Registration/declaration succeed; the runner simply doesn't get the cancelling feature. - **New runner → old Gitea:** the runner sends the field; the old server's generated code doesn't know the field number and silently ignores it. Registration/declaration succeed. The feature only activates once both server and runner are on `v0.6.0`. --- go.mod | 2 +- go.sum | 4 +- routers/api/actions/runner/runner.go | 23 ++----- routers/api/actions/runner/runner_test.go | 83 ++++++++--------------- 4 files changed, 34 insertions(+), 78 deletions(-) diff --git a/go.mod b/go.mod index 25e319aefeb..faf89bb607c 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 - gitea.dev/actions-proto-go v0.5.0 + gitea.dev/actions-proto-go v0.6.0 gitea.dev/sdk v1.0.1 github.com/42wim/httpsig v1.2.4 github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 diff --git a/go.sum b/go.sum index 687b2458812..aff785fe47a 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHq gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= -gitea.dev/actions-proto-go v0.5.0 h1:Fc3DI4Fm3B3JBRXFUjegql+usoNAjjAw1cxMansfA2I= -gitea.dev/actions-proto-go v0.5.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs= +gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY= +gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs= gitea.dev/sdk v1.0.1 h1:CWXQUQvp2I6YKOWkhYo1Flx2sRNfMK1X9Op4oR2awXs= gitea.dev/sdk v1.0.1/go.mod h1:jCf5Uzz0Jkb61jxNgMxLOCWwle1J1B2nKdcRtxuK9rY= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index 803b7c13a12..012f875ec70 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -69,7 +69,7 @@ func (s *Service) Register( } labels := req.Msg.Labels - hasCancellingSupport, _ := runnerRequestHasCancellingCapability(req.Msg) + hasCancellingSupport := slices.Contains(req.Msg.GetCapabilities(), runnerCapabilityCancelling) // create new runner name := util.EllipsisDisplayString(req.Msg.Name, 255) @@ -116,26 +116,11 @@ func (s *Service) Register( // state and will run post-step cleanup before finalizing the task. const runnerCapabilityCancelling = "cancelling" -type capabilityGetter interface { - GetCapabilities() []string -} - type declareRequest interface { proto.Message GetVersion() string GetLabels() []string -} - -func runnerRequestHasCancellingCapability(req proto.Message) (bool, bool) { - if req == nil { - return false, false - } - - if typedReq, ok := any(req).(capabilityGetter); ok { - return slices.Contains(typedReq.GetCapabilities(), runnerCapabilityCancelling), true - } - - return false, false + GetCapabilities() []string } func applyDeclareRequestToRunner(runner *actions_model.ActionRunner, req declareRequest) []string { @@ -143,8 +128,8 @@ func applyDeclareRequestToRunner(runner *actions_model.ActionRunner, req declare runner.Version = req.GetVersion() cols := []string{"agent_labels", "version"} - hasCancellingSupport, capabilityStateKnown := runnerRequestHasCancellingCapability(req) - if capabilityStateKnown && runner.HasCancellingSupport != hasCancellingSupport { + hasCancellingSupport := slices.Contains(req.GetCapabilities(), runnerCapabilityCancelling) + if runner.HasCancellingSupport != hasCancellingSupport { runner.HasCancellingSupport = hasCancellingSupport cols = append(cols, "has_cancelling_support") } diff --git a/routers/api/actions/runner/runner_test.go b/routers/api/actions/runner/runner_test.go index b2ed9290878..8a38ac70a23 100644 --- a/routers/api/actions/runner/runner_test.go +++ b/routers/api/actions/runner/runner_test.go @@ -12,47 +12,22 @@ import ( "github.com/stretchr/testify/assert" ) -type capabilityRegisterRequest struct { - *runnerv1.RegisterRequest - capabilities []string -} - -func (r *capabilityRegisterRequest) GetCapabilities() []string { - return r.capabilities -} - -type capabilityDeclareRequest struct { - *runnerv1.DeclareRequest - capabilities []string -} - -func (r *capabilityDeclareRequest) GetCapabilities() []string { - return r.capabilities -} - -func TestRunnerRequestHasCancellingCapabilityTypedAccessor(t *testing.T) { - registerReq := &capabilityRegisterRequest{ - RegisterRequest: &runnerv1.RegisterRequest{}, - capabilities: []string{runnerCapabilityCancelling, "other"}, +func TestApplyDeclareRequestToRunnerAdvertisedCapabilityEnablesCancelling(t *testing.T) { + runner := &actions_model.ActionRunner{} + req := &runnerv1.DeclareRequest{ + Version: "1.2.3", + Labels: []string{"linux"}, + Capabilities: []string{runnerCapabilityCancelling, "other"}, } - hasCapability, known := runnerRequestHasCancellingCapability(registerReq) - assert.True(t, hasCapability) - assert.True(t, known) - declareReq := &capabilityDeclareRequest{ - DeclareRequest: &runnerv1.DeclareRequest{}, - capabilities: nil, - } - hasCapability, known = runnerRequestHasCancellingCapability(declareReq) - assert.False(t, hasCapability) - assert.True(t, known) - - hasCapability, known = runnerRequestHasCancellingCapability(nil) - assert.False(t, hasCapability) - assert.False(t, known) + cols := applyDeclareRequestToRunner(runner, req) + assert.Equal(t, []string{"agent_labels", "version", "has_cancelling_support"}, cols) + assert.True(t, runner.HasCancellingSupport) + assert.Equal(t, "1.2.3", runner.Version) + assert.Equal(t, []string{"linux"}, runner.AgentLabels) } -func TestApplyDeclareRequestToRunnerPreservesUnknownCapabilityState(t *testing.T) { +func TestApplyDeclareRequestToRunnerMissingCapabilityDisablesCancelling(t *testing.T) { runner := &actions_model.ActionRunner{ HasCancellingSupport: true, } @@ -61,26 +36,22 @@ func TestApplyDeclareRequestToRunnerPreservesUnknownCapabilityState(t *testing.T Labels: []string{"linux"}, } - cols := applyDeclareRequestToRunner(runner, req) - assert.Equal(t, []string{"agent_labels", "version"}, cols) - assert.True(t, runner.HasCancellingSupport) - assert.Equal(t, "1.2.3", runner.Version) - assert.Equal(t, []string{"linux"}, runner.AgentLabels) -} - -func TestApplyDeclareRequestToRunnerUpdatesTypedCapabilityState(t *testing.T) { - runner := &actions_model.ActionRunner{ - HasCancellingSupport: true, - } - req := &capabilityDeclareRequest{ - DeclareRequest: &runnerv1.DeclareRequest{ - Version: "1.2.3", - Labels: []string{"linux"}, - }, - capabilities: []string{}, - } - cols := applyDeclareRequestToRunner(runner, req) assert.Equal(t, []string{"agent_labels", "version", "has_cancelling_support"}, cols) assert.False(t, runner.HasCancellingSupport) } + +func TestApplyDeclareRequestToRunnerUnchangedCapabilityOmitsColumn(t *testing.T) { + runner := &actions_model.ActionRunner{ + HasCancellingSupport: true, + } + req := &runnerv1.DeclareRequest{ + Version: "1.2.3", + Labels: []string{"linux"}, + Capabilities: []string{runnerCapabilityCancelling}, + } + + cols := applyDeclareRequestToRunner(runner, req) + assert.Equal(t, []string{"agent_labels", "version"}, cols) + assert.True(t, runner.HasCancellingSupport) +} From 360f34d7fad5eaa1a07da113de90818e716fcc5d Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 11 Jun 2026 12:48:05 +0200 Subject: [PATCH 10/16] ci: bound seeded Go cache size and speed up disk cleanup (#38048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces the CI cache growth and disk pressure behind the flaky `No space left on device` failures in https://github.com/go-gitea/gitea/issues/37974. **`go-cache`** — the cache-seeder saved with a `restore-keys` prefix fallback, so every `go.sum` change restored the previous cache and re-saved the union; old module versions and stale build objects accumulated (~3 GB → ~7 GB) and overflowed disk on smaller runners. Drop `restore-keys` from the seeder **save** branches so each `go.sum` seeds a clean, size-bounded cache. PR runs keep `restore-keys` for warm-start fallback. **`free-disk-space`** — delete the unused preinstalled toolchains in parallel (~86 s → ~54 s) and log `df -h /` before/after. Measured during review: the hosted `ubuntu-latest` fleet is heterogeneous — most runners have ~89 GB free on `/` (a full pgsql integration shard peaks at ~17 GB used), but a minority arrive nearly full and fail mid cache-restore. The toolchain deletion is the headroom that keeps those runners green, so it stays; the cache bound shrinks the footprint for every runner. Authored with assistance from Claude (Opus 4.8). --------- Signed-off-by: silverwind Co-authored-by: bircni --- .github/actions/free-disk-space/action.yml | 12 ++++++++++-- .github/actions/go-cache/action.yml | 5 ++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/actions/free-disk-space/action.yml b/.github/actions/free-disk-space/action.yml index 510b643a334..a20f2bd5ae3 100644 --- a/.github/actions/free-disk-space/action.yml +++ b/.github/actions/free-disk-space/action.yml @@ -1,9 +1,17 @@ name: free-disk-space description: Free space on / before large cache restores -# Delete preinstalled toolchains which gitea doesn't use +# Delete preinstalled toolchains which gitea doesn't use and show disk space usage runs: using: composite steps: - shell: bash - run: sudo rm -rf /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet + run: | + echo "free space before cleanup:" + df -h / + for dir in /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet; do + sudo rm -rf "$dir" & + done + wait + echo "free space after cleanup:" + df -h / diff --git a/.github/actions/go-cache/action.yml b/.github/actions/go-cache/action.yml index 7096fa3952c..5abf4e319a6 100644 --- a/.github/actions/go-cache/action.yml +++ b/.github/actions/go-cache/action.yml @@ -4,6 +4,8 @@ description: Restore the go module, build, and golangci-lint caches. Save only o # Only the cache-seeder workflow saves; rename requires updating cache-seeder.yml. # The lint job restores but does not save the gobuild cache, so only one writer # (the gobuild job) populates it and there is no contention on the cache key. +# Seeder restores by exact key only (no restore-keys) so each go.sum seeds a clean +# cache and size stays bounded; do not add restore-keys here. PR runs keep them. inputs: lint-cache: @@ -18,7 +20,6 @@ runs: with: path: ~/go/pkg/mod key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} - restore-keys: gomod-${{ runner.os }}-${{ runner.arch }} - if: ${{ github.workflow != 'cache-seeder' }} uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -30,7 +31,6 @@ runs: with: path: ~/.cache/go-build key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} - restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }} - if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }} uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: @@ -42,7 +42,6 @@ runs: with: path: ~/.cache/golangci-lint key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }} - restore-keys: golint-${{ runner.os }}-${{ runner.arch }} - if: ${{ inputs.lint-cache == 'true' && github.workflow != 'cache-seeder' }} uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: From 250a38abb5d82d0d31e4ad4f8d983fc0c8716a0a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 11 Jun 2026 22:37:22 +0800 Subject: [PATCH 11/16] chore: migrate unescaped-html-literal eslint rule to our repo and fix more cases (#38072) --- eslint.config.ts | 7 +- .../unescaped-html-literal.test.ts | 85 +++++++++++++++++++ tools/eslint-rules/unescaped-html-literal.ts | 41 +++++++++ vitest.config.ts | 5 +- web_src/css/base.css | 1 + web_src/js/features/dropzone.ts | 11 +-- web_src/js/features/repo-issue-content.ts | 41 ++++----- web_src/js/features/repo-issue.ts | 18 ++-- web_src/js/modules/toast.ts | 16 ++-- web_src/js/svg.ts | 4 + 10 files changed, 184 insertions(+), 45 deletions(-) create mode 100644 tools/eslint-rules/unescaped-html-literal.test.ts create mode 100644 tools/eslint-rules/unescaped-html-literal.ts diff --git a/eslint.config.ts b/eslint.config.ts index 91adc06e193..a31b6c1fc74 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -18,6 +18,8 @@ import wc from 'eslint-plugin-wc'; import {defineConfig, globalIgnores} from 'eslint/config'; import type {ESLint} from 'eslint'; +import unescapedHtmlLiteral from './tools/eslint-rules/unescaped-html-literal.ts'; + const jsExts = ['js', 'mjs', 'cjs'] as const; const tsExts = ['ts', 'mts', 'cts'] as const; @@ -65,6 +67,7 @@ export default defineConfig([ '@typescript-eslint': typescriptPlugin.plugin, 'array-func': arrayFunc, 'de-morgan': deMorgan, + 'gitea': {rules: {'unescaped-html-literal': unescapedHtmlLiteral}}, 'import-x': importPlugin as unknown as ESLint.Plugin, // https://github.com/un-ts/eslint-plugin-import-x/issues/203 regexp, sonarjs, @@ -331,7 +334,7 @@ export default defineConfig([ 'github/no-useless-passive': [2], 'github/prefer-observers': [0], 'github/require-passive-events': [2], - 'github/unescaped-html-literal': [2], + 'gitea/unescaped-html-literal': [2], 'grouped-accessor-pairs': [2], 'guard-for-in': [0], 'id-blacklist': [0], @@ -952,7 +955,7 @@ export default defineConfig([ plugins: {vitest}, languageOptions: {globals: globals.vitest}, rules: { - 'github/unescaped-html-literal': [0], + 'gitea/unescaped-html-literal': [0], 'vitest/consistent-test-filename': [0], 'vitest/consistent-test-it': [0], 'vitest/expect-expect': [0], diff --git a/tools/eslint-rules/unescaped-html-literal.test.ts b/tools/eslint-rules/unescaped-html-literal.test.ts new file mode 100644 index 00000000000..9424c0006ab --- /dev/null +++ b/tools/eslint-rules/unescaped-html-literal.test.ts @@ -0,0 +1,85 @@ +// MIT license, Copyright (c) GitHub, Inc. +// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js +/* eslint-disable no-template-curly-in-string */ +import rule from './unescaped-html-literal.ts'; +import {RuleTester} from 'eslint'; + +class VitestRuleTester extends RuleTester { + static describe = describe; + static it = it; + static itOnly = it.only; +} + +const ruleTester = new VitestRuleTester(); + +ruleTester.run('unescaped-html-literal', rule, { + valid: [ + { + code: '`Hello World!`;', + languageOptions: {ecmaVersion: 2017}, + }, + { + code: "'Hello World!'", + languageOptions: {ecmaVersion: 2017}, + }, + { + code: '"Hello World!"', + languageOptions: {ecmaVersion: 2017}, + }, + { + code: 'const helloTemplate = () => html`
Hello World!
`;', + languageOptions: {ecmaVersion: 2017}, + }, + { + code: 'const helloTemplate = (name) => html`
Hello ${name}!
`;', + languageOptions: {ecmaVersion: 2017}, + }, + ], + invalid: [ + { + code: "const helloHTML = '
Hello, World!
'", + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = "

Hello, World!

"', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = `
Hello ${name}!
`', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = ` \n\t
Hello ${name}!
`', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = foo`
Hello ${name}!
`', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + ], +}); diff --git a/tools/eslint-rules/unescaped-html-literal.ts b/tools/eslint-rules/unescaped-html-literal.ts new file mode 100644 index 00000000000..3cbd82f7fe9 --- /dev/null +++ b/tools/eslint-rules/unescaped-html-literal.ts @@ -0,0 +1,41 @@ +// MIT license, Copyright (c) GitHub, Inc. +// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js +import type {JSRuleDefinition, JSRuleDefinitionTypeOptions} from 'eslint'; + +const htmlOpenTag = /^\s*<[a-zA-Z]/; + +const rule: JSRuleDefinition = { + meta: { + type: 'problem', + messages: { + unescapedHtmlLiteral: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + }, + + create(context) { + return { + Literal(node) { + if (typeof node.value !== 'string' || !htmlOpenTag.test(node.value)) return; + + context.report({ + node, + messageId: 'unescapedHtmlLiteral', + }); + }, + TemplateLiteral(node) { + const templateStart = node.quasis[0]?.value.raw; + if (!templateStart || !htmlOpenTag.test(templateStart)) return; + + const parent = node.parent; + if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && parent.tag.name === 'html') return; + + context.report({ + node, + messageId: 'unescapedHtmlLiteral', + }); + }, + }; + }, +}; + +export default rule; diff --git a/vitest.config.ts b/vitest.config.ts index d2d3eeef1aa..93218aed4db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,10 @@ import {stringPlugin} from 'vite-string-plugin'; export default defineConfig({ test: { - include: ['web_src/**/*.test.ts'], + include: [ + 'web_src/**/*.test.ts', + 'tools/eslint-rules/**/*.test.ts', + ], setupFiles: ['web_src/js/vitest.setup.ts'], environment: 'happy-dom', testTimeout: 20000, diff --git a/web_src/css/base.css b/web_src/css/base.css index 8b8cfac2d1f..d26711b1541 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -848,6 +848,7 @@ table th[data-sortt-desc] .svg { } /* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */ +.ui.modal .header.flex-left-right, .flex-left-right { display: flex; flex-wrap: wrap; diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index fcde16e8454..63271c303b3 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -1,4 +1,4 @@ -import {svg} from '../svg.ts'; +import {svgRaw} from '../svg.ts'; import {html} from '../utils/html.ts'; import {copyToClipboardWithFeedback} from '../modules/clipboard.ts'; import {GET, POST} from '../modules/fetch.ts'; @@ -45,10 +45,11 @@ export function generateMarkdownLinkForAttachment(file: Partial) { // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone - const copyLinkEl = createElementFromHTML(` -`); + const copyLinkEl = createElementFromHTML(html` + + `); copyLinkEl.addEventListener('click', async (e) => { e.preventDefault(); await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file)); diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts index e385d5b1e8b..eda5f4c8cec 100644 --- a/web_src/js/features/repo-issue-content.ts +++ b/web_src/js/features/repo-issue-content.ts @@ -1,10 +1,11 @@ -import {svg} from '../svg.ts'; +import {svgRaw} from '../svg.ts'; import {showErrorToast} from '../modules/toast.ts'; import {GET, POST} from '../modules/fetch.ts'; import {createElementFromHTML, showElem} from '../utils/dom.ts'; import {parseIssuePageInfo} from '../utils.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts'; +import {html, htmlRaw} from '../utils/html.ts'; let i18nTextEdited: string; let i18nTextOptions: string; @@ -12,21 +13,22 @@ let i18nTextDeleteFromHistory: string; let i18nTextDeleteFromHistoryConfirm: string; function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) { - const elDetailDialog = createElementFromHTML(` - diff --git a/templates/explore/repos.tmpl b/templates/explore/repos.tmpl index 68da3983063..02ea81b3688 100644 --- a/templates/explore/repos.tmpl +++ b/templates/explore/repos.tmpl @@ -3,6 +3,7 @@ {{template "explore/navbar" .}}
{{template "shared/repo/search" .}} +
{{template "shared/repo/list" .}} {{template "base/paginate" .}}
diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 12b41c3e942..d1472a889ee 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -9,6 +9,7 @@
{{.ProfileReadmeContent}}
{{end}} {{template "shared/repo/search" .}} +
{{if not .Repos}}
{{svg "octicon-repo" 48}} diff --git a/templates/org/member/members.tmpl b/templates/org/member/members.tmpl index 7c6b097765b..1a8aefa7a73 100644 --- a/templates/org/member/members.tmpl +++ b/templates/org/member/members.tmpl @@ -9,16 +9,13 @@
{{ctx.Locale.Tr "org.teams.manage_team_member_prompt"}}
{{ctx.Locale.Tr "org.teams.manage_team_member"}}
-
{{end}} -
-
-
- {{template "shared/search/input" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.user_kind")}} - {{template "shared/search/button"}} -
-
-
+
+
+ {{template "shared/search/input" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.user_kind")}} + {{template "shared/search/button"}} +
+
{{range .Members}} {{$isPublic := index $.MembersIsPublicMember .ID}} diff --git a/templates/shared/repo/list.tmpl b/templates/shared/repo/list.tmpl index af2f5d97003..0b84aa0812e 100644 --- a/templates/shared/repo/list.tmpl +++ b/templates/shared/repo/list.tmpl @@ -36,8 +36,8 @@