fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)

Backport #38323 by @bircni

Three independent security hardening fixes, each with a regression test:

- **Locale DoS:** the `Locale` middleware passed the raw
`Accept-Language` header to `ParseAcceptLanguage`, whose guard only
counts `-` while the scanner aliases `_` to `-` — a large `_`-separated
header on an unauthenticated request burned CPU. The header is now
length-bounded before parsing.
- **Public-only token scope:** `GET /teams/{id}/repos`,
`.../repos/{org}/{repo}`, `/teams/{id}/activities/feeds`, and
`/users/{username}/orgs/{org}/permissions` still returned private
repo/activity/permission data to a public-only token. They now filter
via `TokenCanAccessRepo` / `ApplyPublicOnly` and reject non-public org
permissions.
- **Push-option visibility:** `repo.private` / `repo.template` push
options were applied to any existing repo, letting an owner/admin
silently flip visibility bypassing audit, webhooks, and notifications.
They are now honored only on push-to-create.

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
Giteabot
2026-07-10 10:07:04 -07:00
committed by GitHub
parent 8b4252e7f6
commit af7ba2edef
8 changed files with 207 additions and 17 deletions

View File

@@ -12,6 +12,24 @@ import (
"golang.org/x/text/language"
)
// maxAcceptLanguageLen bounds the Accept-Language header before it reaches
// language.ParseAcceptLanguage. That parser has quadratic-time behavior on long
// malformed inputs, and its built-in guard only counts "-" separators while the
// scanner treats "_" as an alias for "-", so a "_"-heavy header slips past the
// guard and burns CPU. Only the leading (highest-priority) languages are used, so
// truncating a longer header is safe.
const maxAcceptLanguageLen = 200
// parseAcceptLanguage parses the Accept-Language header after bounding its length
// to avoid a quadratic-time DoS on attacker-controlled input.
func parseAcceptLanguage(header string) []language.Tag {
if len(header) > maxAcceptLanguageLen {
header = header[:maxAcceptLanguageLen]
}
tags, _, _ := language.ParseAcceptLanguage(header)
return tags
}
// Locale handle locale
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
// 1. Check URL arguments.
@@ -35,7 +53,7 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
// 3. Get language information from 'Accept-Language'.
// The first element in the list is chosen to be the default language automatically.
if len(lang) == 0 {
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
tags := parseAcceptLanguage(req.Header.Get("Accept-Language"))
tag := translation.Match(tags...)
lang = tag.String()
}

View File

@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package middleware
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseAcceptLanguage(t *testing.T) {
// a normal header is parsed and its leading language preserved
tags := parseAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")
assert.NotEmpty(t, tags)
assert.Equal(t, "de-DE", tags[0].String())
// an oversized "_"-separated header would drive ParseAcceptLanguage into its
// quadratic-time path (the built-in guard only counts "-"); the length bound
// keeps the input passed to the parser small so it cannot be used for a DoS.
malicious := strings.Repeat("_aaaaaaaaa", 1<<16) // ~640 KiB, zero "-" characters
assert.Greater(t, len(malicious), maxAcceptLanguageLen)
tags = parseAcceptLanguage(malicious)
// no panic / hang, and nothing meaningful is parsed out of the garbage
assert.Empty(t, tags)
}